From b236048a24e5c17d7721d047606041a25f43fce5 Mon Sep 17 00:00:00 2001 From: dev <182542500@qq.com> Date: Sun, 22 Feb 2026 12:12:02 +0800 Subject: [PATCH] first commit --- .DS_Store | Bin 0 -> 10244 bytes .gitignore | 57 + LICENSE | 21 + README.md | 687 ++++ UPDATE.md | 229 ++ pom.xml | 719 ++++ ruoyi-common/.DS_Store | Bin 0 -> 8196 bytes ruoyi-common/pom.xml | 45 + ruoyi-common/ruoyi-common-bom/pom.xml | 178 + ruoyi-common/ruoyi-common-core/pom.xml | 111 + .../common/core/config/ApplicationConfig.java | 17 + .../common/core/config/AsyncConfig.java | 52 + .../common/core/config/ThreadPoolConfig.java | 87 + .../common/core/config/ValidatorConfig.java | 41 + .../properties/ThreadPoolProperties.java | 30 + .../common/core/constant/CacheConstants.java | 30 + .../common/core/constant/CacheNames.java | 83 + .../common/core/constant/Constants.java | 76 + .../common/core/constant/GlobalConstants.java | 34 + .../common/core/constant/HttpStatus.java | 93 + .../common/core/constant/RegexConstants.java | 59 + .../common/core/constant/SystemConstants.java | 95 + .../common/core/constant/UserConstants.java | 118 + .../org/dromara/common/core/domain/R.java | 122 + .../core/domain/dto/CompleteTaskDTO.java | 71 + .../common/core/domain/dto/DeptDTO.java | 36 + .../common/core/domain/dto/DictDataDTO.java | 41 + .../common/core/domain/dto/DictTypeDTO.java | 41 + .../common/core/domain/dto/FlowCopyDTO.java | 30 + .../common/core/domain/dto/OssDTO.java | 46 + .../common/core/domain/dto/PostDTO.java | 46 + .../common/core/domain/dto/RoleDTO.java | 42 + .../core/domain/dto/StartProcessDTO.java | 45 + .../domain/dto/StartProcessReturnDTO.java | 30 + .../core/domain/dto/TaskAssigneeDTO.java | 101 + .../common/core/domain/dto/UserDTO.java | 73 + .../common/core/domain/dto/UserOnlineDTO.java | 72 + .../core/domain/event/ProcessDeleteEvent.java | 29 + .../core/domain/event/ProcessEvent.java | 60 + .../core/domain/event/ProcessTaskEvent.java | 54 + .../core/domain/model/EmailLoginBody.java | 31 + .../common/core/domain/model/LoginBody.java | 43 + .../common/core/domain/model/LoginUser.java | 143 + .../core/domain/model/PasswordLoginBody.java | 31 + .../core/domain/model/RegisterBody.java | 33 + .../core/domain/model/SmsLoginBody.java | 29 + .../core/domain/model/SocialLoginBody.java | 35 + .../core/domain/model/TaskAssigneeBody.java | 56 + .../core/domain/model/XcxLoginBody.java | 28 + .../core/domain/model/XcxLoginUser.java | 32 + .../common/core/enums/BusinessStatusEnum.java | 215 ++ .../dromara/common/core/enums/DeviceType.java | 39 + .../common/core/enums/FormatsType.java | 146 + .../dromara/common/core/enums/LoginType.java | 44 + .../dromara/common/core/enums/UserStatus.java | 30 + .../dromara/common/core/enums/UserType.java | 39 + .../core/exception/ServiceException.java | 67 + .../common/core/exception/SseException.java | 62 + .../core/exception/base/BaseException.java | 74 + .../core/exception/file/FileException.java | 21 + .../FileNameLengthLimitExceededException.java | 18 + .../file/FileSizeLimitExceededException.java | 18 + .../core/exception/user/CaptchaException.java | 18 + .../user/CaptchaExpireException.java | 18 + .../core/exception/user/UserException.java | 20 + .../core/factory/RegexPatternPoolFactory.java | 52 + .../factory/YmlPropertySourceFactory.java | 31 + .../common/core/service/ConfigService.java | 18 + .../common/core/service/DeptService.java | 37 + .../common/core/service/DictService.java | 87 + .../common/core/service/OssService.java | 29 + .../core/service/PermissionService.java | 28 + .../common/core/service/PostService.java | 10 + .../common/core/service/RoleService.java | 10 + .../core/service/TaskAssigneeService.java | 45 + .../common/core/service/UserService.java | 127 + .../common/core/service/WorkflowService.java | 88 + .../dromara/common/core/utils/DateUtils.java | 300 ++ .../common/core/utils/MapstructUtils.java | 93 + .../common/core/utils/MessageUtils.java | 33 + .../dromara/common/core/utils/NetUtils.java | 84 + .../common/core/utils/ObjectUtils.java | 60 + .../common/core/utils/ServletUtils.java | 289 ++ .../common/core/utils/SpringUtils.java | 67 + .../common/core/utils/StreamUtils.java | 282 ++ .../common/core/utils/StringUtils.java | 365 ++ .../dromara/common/core/utils/Threads.java | 63 + .../common/core/utils/TreeBuildUtils.java | 123 + .../common/core/utils/ValidatorUtils.java | 35 + .../common/core/utils/file/FileUtils.java | 43 + .../common/core/utils/file/MimeTypeUtils.java | 40 + .../common/core/utils/ip/AddressUtils.java | 70 + .../common/core/utils/ip/RegionUtils.java | 51 + .../core/utils/reflect/ReflectUtils.java | 56 + .../common/core/utils/regex/RegexUtils.java | 31 + .../core/utils/regex/RegexValidator.java | 105 + .../common/core/utils/sql/SqlUtil.java | 56 + .../common/core/validate/AddGroup.java | 9 + .../common/core/validate/EditGroup.java | 9 + .../common/core/validate/QueryGroup.java | 9 + .../core/validate/dicts/DictPattern.java | 40 + .../validate/dicts/DictPatternValidator.java | 55 + .../core/validate/enumd/EnumPattern.java | 51 + .../validate/enumd/EnumPatternValidator.java | 37 + .../java/org/dromara/common/core/xss/Xss.java | 27 + .../dromara/common/core/xss/XssValidator.java | 20 + ...ot.autoconfigure.AutoConfiguration.imports | 5 + ruoyi-common/ruoyi-common-doc/pom.xml | 52 + .../common/doc/config/SpringDocConfig.java | 126 + .../properties/SpringDocProperties.java | 94 + .../common/doc/handler/OpenApiHandler.java | 253 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-encrypt/pom.xml | 54 + .../common/encrypt/annotation/ApiEncrypt.java | 20 + .../encrypt/annotation/EncryptField.java | 44 + .../config/ApiDecryptAutoConfiguration.java | 32 + .../config/EncryptorAutoConfiguration.java | 49 + .../common/encrypt/core/EncryptContext.java | 41 + .../common/encrypt/core/EncryptorManager.java | 159 + .../common/encrypt/core/IEncryptor.java | 35 + .../core/encryptor/AbstractEncryptor.java | 18 + .../encrypt/core/encryptor/AesEncryptor.java | 55 + .../core/encryptor/Base64Encryptor.java | 48 + .../encrypt/core/encryptor/RsaEncryptor.java | 62 + .../encrypt/core/encryptor/Sm2Encryptor.java | 61 + .../encrypt/core/encryptor/Sm4Encryptor.java | 55 + .../common/encrypt/enumd/AlgorithmType.java | 48 + .../common/encrypt/enumd/EncodeType.java | 26 + .../common/encrypt/filter/CryptoFilter.java | 110 + .../filter/DecryptRequestBodyWrapper.java | 94 + .../filter/EncryptResponseBodyWrapper.java | 126 + .../MybatisDecryptInterceptor.java | 132 + .../MybatisEncryptInterceptor.java | 124 + .../properties/ApiDecryptProperties.java | 34 + .../properties/EncryptorProperties.java | 48 + .../common/encrypt/utils/EncryptUtils.java | 313 ++ ...ot.autoconfigure.AutoConfiguration.imports | 3 + ruoyi-common/ruoyi-common-excel/pom.xml | 30 + .../common/excel/annotation/CellMerge.java | 29 + .../excel/annotation/ExcelDictFormat.java | 32 + .../excel/annotation/ExcelEnumFormat.java | 30 + .../excel/annotation/ExcelNotation.java | 20 + .../excel/annotation/ExcelRequired.java | 22 + .../excel/convert/ExcelBigNumberConvert.java | 52 + .../excel/convert/ExcelDictConvert.java | 73 + .../excel/convert/ExcelEnumConvert.java | 87 + .../common/excel/core/CellMergeStrategy.java | 163 + .../excel/core/DefaultExcelListener.java | 104 + .../common/excel/core/DefaultExcelResult.java | 73 + .../common/excel/core/DropDownOptions.java | 149 + .../common/excel/core/ExcelDownHandler.java | 401 ++ .../common/excel/core/ExcelListener.java | 14 + .../common/excel/core/ExcelResult.java | 26 + .../excel/handler/DataWriteHandler.java | 123 + .../dromara/common/excel/utils/ExcelUtil.java | 440 +++ ruoyi-common/ruoyi-common-idempotent/pom.xml | 41 + .../idempotent/annotation/RepeatSubmit.java | 29 + .../aspectj/RepeatSubmitAspect.java | 146 + .../idempotent/config/IdempotentConfig.java | 21 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-job/pom.xml | 46 + .../common/job/config/SnailJobConfig.java | 36 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-json/pom.xml | 37 + .../common/json/config/JacksonConfig.java | 51 + .../json/handler/BigNumberSerializer.java | 42 + .../json/handler/CustomDateDeserializer.java | 31 + .../dromara/common/json/utils/JsonUtils.java | 170 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-log/pom.xml | 32 + .../dromara/common/log/annotation/Log.java | 48 + .../dromara/common/log/aspect/LogAspect.java | 218 ++ .../common/log/enums/BusinessStatus.java | 18 + .../common/log/enums/BusinessType.java | 58 + .../common/log/enums/OperatorType.java | 23 + .../common/log/event/LogininforEvent.java | 46 + .../common/log/event/OperLogEvent.java | 110 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-mail/pom.xml | 34 + .../common/mail/config/MailConfig.java | 37 + .../config/properties/MailProperties.java | 75 + .../dromara/common/mail/utils/MailUtils.java | 469 +++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-mybatis/pom.xml | 52 + .../common/mybatis/annotation/DataColumn.java | 40 + .../mybatis/annotation/DataPermission.java | 30 + .../mybatis/aspect/DataPermissionAspect.java | 50 + .../mybatis/config/MybatisPlusConfig.java | 130 + .../mybatis/core/domain/BaseEntity.java | 70 + .../mybatis/core/mapper/BaseMapperPlus.java | 334 ++ .../common/mybatis/core/page/PageQuery.java | 129 + .../mybatis/core/page/TableDataInfo.java | 107 + .../common/mybatis/enums/DataBaseType.java | 58 + .../common/mybatis/enums/DataScopeType.java | 87 + .../handler/InjectionMetaObjectHandler.java | 113 + .../handler/MybatisExceptionHandler.java | 47 + .../handler/PlusDataPermissionHandler.java | 357 ++ .../handler/PlusPostInitTableInfoHandler.java | 27 + .../common/mybatis/helper/DataBaseHelper.java | 81 + .../mybatis/helper/DataPermissionHelper.java | 175 + .../PlusDataPermissionInterceptor.java | 181 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../src/main/resources/common-mybatis.yml | 33 + .../src/main/resources/spy.properties | 20 + ruoyi-common/ruoyi-common-oss/pom.xml | 66 + .../common/oss/constant/OssConstant.java | 40 + .../dromara/common/oss/core/OssClient.java | 491 +++ .../common/oss/entity/UploadResult.java | 30 + .../common/oss/enums/AccessPolicyType.java | 56 + .../common/oss/exception/OssException.java | 19 + .../common/oss/factory/OssFactory.java | 68 + .../common/oss/properties/OssProperties.java | 58 + ruoyi-common/ruoyi-common-ratelimiter/pom.xml | 30 + .../ratelimiter/annotation/RateLimiter.java | 47 + .../aspectj/RateLimiterAspect.java | 112 + .../ratelimiter/config/RateLimiterConfig.java | 20 + .../common/ratelimiter/enums/LimitType.java | 24 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../src/main/resources/spel-extension.json | 7 + ruoyi-common/ruoyi-common-redis/pom.xml | 59 + .../common/redis/config/CacheConfig.java | 45 + .../common/redis/config/RedisConfig.java | 159 + .../config/properties/RedissonProperties.java | 135 + .../redis/handler/KeyPrefixHandler.java | 50 + .../redis/handler/RedisExceptionHandler.java | 30 + .../redis/manager/CaffeineCacheDecorator.java | 97 + .../redis/manager/PlusSpringCacheManager.java | 192 + .../common/redis/utils/CacheUtils.java | 61 + .../common/redis/utils/QueueUtils.java | 239 ++ .../common/redis/utils/RedisUtils.java | 586 +++ .../common/redis/utils/SequenceUtils.java | 180 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + ruoyi-common/ruoyi-common-satoken/pom.xml | 46 + .../common/satoken/config/SaTokenConfig.java | 54 + .../satoken/core/dao/PlusSaTokenDao.java | 196 + .../core/service/SaPermissionImpl.java | 77 + .../handler/SaTokenExceptionHandler.java | 63 + .../common/satoken/utils/LoginHelper.java | 178 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../src/main/resources/common-satoken.yml | 13 + ruoyi-common/ruoyi-common-security/pom.xml | 26 + .../security/config/SecurityConfig.java | 100 + .../config/properties/SecurityProperties.java | 21 + .../security/handler/AllUrlHandler.java | 39 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + ruoyi-common/ruoyi-common-sensitive/pom.xml | 25 + .../sensitive/annotation/Sensitive.java | 34 + .../sensitive/core/SensitiveService.java | 18 + .../sensitive/core/SensitiveStrategy.java | 99 + .../sensitive/handler/SensitiveHandler.java | 58 + ruoyi-common/ruoyi-common-sms/pom.xml | 33 + .../sms/config/SmsAutoConfiguration.java | 33 + .../common/sms/core/dao/PlusSmsDao.java | 71 + .../sms/handler/SmsExceptionHandler.java | 30 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-social/pom.xml | 46 + .../AbstractAuthWeChatEnterpriseRequest.java | 154 + .../config/SocialAutoConfiguration.java | 23 + .../SocialLoginConfigProperties.java | 75 + .../config/properties/SocialProperties.java | 24 + .../common/social/gitea/AuthGiteaRequest.java | 92 + .../common/social/gitea/AuthGiteaSource.java | 50 + .../social/maxkey/AuthMaxKeyRequest.java | 80 + .../social/maxkey/AuthMaxKeySource.java | 52 + .../social/topiam/AuthTopIamRequest.java | 113 + .../social/topiam/AuthTopIamSource.java | 51 + .../social/utils/AuthRedisStateCache.java | 61 + .../common/social/utils/SocialUtils.java | 82 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-sse/pom.xml | 36 + .../sse/config/SseAutoConfiguration.java | 36 + .../common/sse/config/SseProperties.java | 21 + .../common/sse/controller/SseController.java | 88 + .../common/sse/core/SseEmitterManager.java | 173 + .../dromara/common/sse/dto/SseMessageDto.java | 29 + .../common/sse/listener/SseTopicListener.java | 48 + .../common/sse/utils/SseMessageUtils.java | 84 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-common/ruoyi-common-translation/pom.xml | 27 + .../translation/annotation/Translation.java | 39 + .../annotation/TranslationType.java | 23 + .../translation/config/TranslationConfig.java | 50 + .../translation/constant/TransConstant.java | 35 + .../core/TranslationInterface.java | 20 + .../TranslationBeanSerializerModifier.java | 29 + .../core/handler/TranslationHandler.java | 65 + .../core/impl/DeptNameTranslationImpl.java | 29 + .../core/impl/DictTypeTranslationImpl.java | 28 + .../core/impl/NicknameTranslationImpl.java | 29 + .../core/impl/OssUrlTranslationImpl.java | 29 + .../core/impl/UserNameTranslationImpl.java | 27 + ...ot.autoconfigure.AutoConfiguration.imports | 6 + ruoyi-common/ruoyi-common-web/pom.xml | 62 + .../common/web/config/CaptchaConfig.java | 65 + .../common/web/config/FilterConfig.java | 44 + .../dromara/common/web/config/I18nConfig.java | 22 + .../common/web/config/ResourcesConfig.java | 77 + .../common/web/config/UndertowConfig.java | 63 + .../config/properties/CaptchaProperties.java | 38 + .../web/config/properties/XssProperties.java | 28 + .../common/web/core/BaseController.java | 40 + .../common/web/core/I18nLocaleResolver.java | 31 + .../common/web/enums/CaptchaCategory.java | 35 + .../dromara/common/web/enums/CaptchaType.java | 29 + .../common/web/filter/RepeatableFilter.java | 40 + .../web/filter/RepeatedlyRequestWrapper.java | 67 + .../dromara/common/web/filter/XssFilter.java | 59 + .../filter/XssHttpServletRequestWrapper.java | 134 + .../web/handler/GlobalExceptionHandler.java | 205 ++ .../PlusWebInvokeTimeInterceptor.java | 89 + .../web/utils/UnsignedMathGenerator.java | 88 + ...ot.autoconfigure.AutoConfiguration.imports | 5 + ruoyi-common/ruoyi-common-websocket/pom.xml | 46 + .../websocket/config/WebSocketConfig.java | 63 + .../properties/WebSocketProperties.java | 26 + .../constant/WebSocketConstants.java | 29 + .../websocket/dto/WebSocketMessageDto.java | 29 + .../handler/PlusWebSocketHandler.java | 122 + .../holder/WebSocketSessionHolder.java | 74 + .../interceptor/PlusWebSocketInterceptor.java | 75 + .../listener/WebSocketTopicListener.java | 50 + .../websocket/utils/WebSocketUtils.java | 127 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ruoyi-extend/pom.xml | 19 + ruoyi-extend/ruoyi-monitor-admin/Dockerfile | 21 + ruoyi-extend/ruoyi-monitor-admin/pom.xml | 73 + .../admin/MonitorAdminApplication.java | 19 + .../admin/config/AdminServerConfig.java | 31 + .../monitor/admin/config/SecurityConfig.java | 54 + .../admin/notifier/CustomNotifier.java | 55 + .../src/main/resources/application.yml | 48 + .../src/main/resources/banner.txt | 8 + .../src/main/resources/logback-plus.xml | 34 + ruoyi-extend/ruoyi-snailjob-server/Dockerfile | 22 + ruoyi-extend/ruoyi-snailjob-server/pom.xml | 58 + .../starter/filter/ActuatorAuthFilter.java | 64 + .../server/starter/filter/SecurityConfig.java | 29 + .../snailjob/SnailJobServerApplication.java | 18 + .../src/main/resources/application-dev.yml | 40 + .../src/main/resources/application-prod.yml | 40 + .../src/main/resources/application.yml | 39 + .../src/main/resources/banner.txt | 11 + .../src/main/resources/logback-plus.xml | 93 + ruoyi-modules/pom.xml | 33 + ruoyi-modules/ruoyi-ai/MODULE_STRUCTURE.md | 1066 ++++++ ruoyi-modules/ruoyi-ai/README.md | 549 +++ ruoyi-modules/ruoyi-ai/pom.xml | 114 + .../org/dromara/ai/config/AiApiConfig.java | 53 + .../ai/config/AiAutoConfiguration.java | 23 + .../ai/config/AiConfigurationManager.java | 532 +++ .../ai/config/AiGatewayProperties.java | 35 + .../ai/config/AiProviderRegistrar.java | 44 + .../ai/config/AzureOpenAiProviderConfig.java | 72 + .../dromara/ai/config/CozePlatformConfig.java | 64 + .../dromara/ai/config/DifyPlatformConfig.java | 64 + .../ai/config/FastGptPlatformConfig.java | 64 + .../ai/config/OpenAiProviderConfig.java | 71 + .../dromara/ai/config/QwenProviderConfig.java | 66 + .../ai/config/ZhipuAiProviderConfig.java | 66 + .../admin/AdminAiChatRecordController.java | 102 + .../AdminAiPaintingFavoriteController.java | 89 + .../AdminAiPaintingRecordController.java | 129 + .../AdminAiPaintingTemplateController.java | 112 + .../AdminAiProviderConfigController.java | 138 + ...dminAiProviderConfigHistoryController.java | 101 + .../admin/AdminAiProviderController.java | 170 + .../admin/AdminAiRatingRecordController.java | 102 + .../admin/AdminAiSensitiveWordController.java | 128 + .../admin/AdminAiUsageStatsController.java | 129 + .../admin/AdminAiUserQuotaController.java | 139 + .../admin/AdminSysModelController.java | 87 + .../controller/app/AiAssistantController.java | 75 + .../ai/controller/app/AiChatController.java | 284 ++ .../ai/controller/app/AiImageController.java | 207 ++ .../controller/app/AiPaintingController.java | 117 + .../app/AppAiPaintingFavoriteController.java | 82 + .../app/AppAiPaintingRecordController.java | 91 + .../app/AppAiPaintingTemplateController.java | 106 + .../app/AppAiRatingRecordController.java | 60 + .../app/AppAiUserQuotaController.java | 41 + .../ai/controller/app/CozeController.java | 66 + .../ai/controller/app/DifyController.java | 72 + .../ai/controller/app/FastGptController.java | 95 + .../ai/controller/app/McpController.java | 82 + .../controller/app/MultimodalController.java | 168 + .../ai/controller/app/PptController.java | 133 + .../app/QueueMetricsController.java | 45 + .../ai/controller/app/SimpleAiController.java | 113 + .../org/dromara/ai/domain/AiChatRecord.java | 92 + .../dromara/ai/domain/AiPaintingFavorite.java | 56 + .../dromara/ai/domain/AiPaintingRecord.java | 96 + .../dromara/ai/domain/AiPaintingTemplate.java | 92 + .../dromara/ai/domain/AiProviderConfig.java | 137 + .../ai/domain/AiProviderConfigHistory.java | 66 + .../org/dromara/ai/domain/AiRatingRecord.java | 71 + .../dromara/ai/domain/AiSensitiveWord.java | 82 + .../org/dromara/ai/domain/AiUsageStats.java | 92 + .../org/dromara/ai/domain/AiUserQuota.java | 107 + .../dromara/ai/domain/bo/AiChatRecordBo.java | 96 + .../ai/domain/bo/AiPaintingFavoriteBo.java | 60 + .../ai/domain/bo/AiPaintingRecordBo.java | 95 + .../ai/domain/bo/AiPaintingTemplateBo.java | 78 + .../ai/domain/bo/AiProviderConfigBo.java | 123 + .../domain/bo/AiProviderConfigHistoryBo.java | 75 + .../ai/domain/bo/AiRatingRecordBo.java | 75 + .../ai/domain/bo/AiSensitiveWordBo.java | 68 + .../dromara/ai/domain/bo/AiUsageStatsBo.java | 86 + .../dromara/ai/domain/bo/AiUserQuotaBo.java | 90 + .../dromara/ai/domain/dto/AiChatRequest.java | 165 + .../dromara/ai/domain/dto/AiChatResponse.java | 222 ++ .../dromara/ai/domain/dto/AiModelInfo.java | 394 ++ .../ai/domain/enums/AiProviderType.java | 125 + .../dromara/ai/domain/vo/AiChatRecordVo.java | 94 + .../ai/domain/vo/AiPaintingFavoriteVo.java | 60 + .../ai/domain/vo/AiPaintingRecordVo.java | 110 + .../ai/domain/vo/AiPaintingTemplateVo.java | 95 + .../domain/vo/AiProviderConfigHistoryVo.java | 70 + .../ai/domain/vo/AiProviderConfigVo.java | 135 + .../ai/domain/vo/AiRatingRecordVo.java | 75 + .../ai/domain/vo/AiSensitiveWordVo.java | 85 + .../dromara/ai/domain/vo/AiUsageStatsVo.java | 94 + .../dromara/ai/domain/vo/AiUserQuotaVo.java | 115 + .../org/dromara/ai/error/AiErrorHandler.java | 836 +++++ .../dromara/ai/mapper/AiChatRecordMapper.java | 16 + .../ai/mapper/AiPaintingFavoriteMapper.java | 16 + .../ai/mapper/AiPaintingRecordMapper.java | 16 + .../ai/mapper/AiPaintingTemplateMapper.java | 16 + .../mapper/AiProviderConfigHistoryMapper.java | 16 + .../ai/mapper/AiProviderConfigMapper.java | 16 + .../ai/mapper/AiRatingRecordMapper.java | 16 + .../ai/mapper/AiSensitiveWordMapper.java | 16 + .../dromara/ai/mapper/AiUsageStatsMapper.java | 16 + .../dromara/ai/mapper/AiUserQuotaMapper.java | 16 + .../dromara/ai/mcp/McpIntegrationService.java | 96 + .../main/java/org/dromara/ai/mcp/McpTool.java | 19 + .../org/dromara/ai/mcp/McpToolRegistry.java | 60 + .../ai/mcp/tools/AlgorithmComputeTool.java | 34 + .../org/dromara/ai/mcp/tools/ApiCallTool.java | 43 + .../ai/mcp/tools/DataTransformTool.java | 33 + .../dromara/ai/mcp/tools/FileParseTool.java | 39 + .../dromara/ai/mcp/tools/HttpFetchTool.java | 43 + .../ai/mcp/tools/ProjectScaffoldTool.java | 34 + .../java/org/dromara/ai/model/ApiResult.java | 60 + .../dromara/ai/monitor/AiMonitorService.java | 433 +++ .../org/dromara/ai/monitor/BaseMetrics.java | 252 ++ .../org/dromara/ai/monitor/ModelMetrics.java | 323 ++ .../dromara/ai/monitor/ProviderMetrics.java | 211 ++ .../org/dromara/ai/monitor/SystemMetrics.java | 477 +++ .../org/dromara/ai/monitor/UserMetrics.java | 374 ++ .../orchestration/AiOrchestrationEngine.java | 347 ++ .../ai/painting/PaintingEngineType.java | 8 + .../dromara/ai/painting/PaintingProvider.java | 13 + .../dromara/ai/painting/PaintingStatus.java | 9 + .../org/dromara/ai/painting/PaintingTask.java | 23 + .../ai/painting/provider/DallE3Provider.java | 52 + .../painting/provider/MidJourneyProvider.java | 56 + .../provider/StableDiffusionProvider.java | 53 + .../org/dromara/ai/platform/CozeService.java | 33 + .../ai/platform/DifyClientService.java | 62 + .../dromara/ai/platform/FastGptService.java | 42 + .../ai/provider/AbstractAiProvider.java | 367 ++ .../org/dromara/ai/provider/AiProvider.java | 158 + .../ai/provider/AiProviderManager.java | 434 +++ .../ai/provider/AzureOpenAiProvider.java | 178 + .../org/dromara/ai/provider/CozeProvider.java | 154 + .../org/dromara/ai/provider/DifyProvider.java | 150 + .../dromara/ai/provider/FastGptProvider.java | 153 + .../dromara/ai/provider/OpenAiProvider.java | 242 ++ .../org/dromara/ai/provider/QwenProvider.java | 166 + .../dromara/ai/provider/ZhipuAiProvider.java | 174 + .../ai/provider/impl/MockAiProvider.java | 138 + .../dromara/ai/queue/TaskQueueService.java | 66 + .../ai/security/AiAccessControlService.java | 690 ++++ .../ai/security/AiContentFilterService.java | 524 +++ .../ai/security/AiSecurityManager.java | 556 +++ .../ai/service/AiChatHistoryService.java | 480 +++ .../ai/service/AiChatRecordService.java | 56 + .../org/dromara/ai/service/AiChatService.java | 375 ++ .../ai/service/AiCodingAssistantService.java | 140 + .../dromara/ai/service/AiModelService.java | 440 +++ .../ai/service/AiPaintingFavoriteService.java | 94 + .../ai/service/AiPaintingRecordService.java | 108 + .../dromara/ai/service/AiPaintingService.java | 132 + .../ai/service/AiPaintingTemplateService.java | 77 + .../AiProviderConfigHistoryService.java | 68 + .../ai/service/AiProviderConfigService.java | 58 + .../ai/service/AiRatingRecordService.java | 53 + .../ai/service/AiSensitiveWordService.java | 93 + .../ai/service/AiUsageStatsService.java | 87 + .../ai/service/AiUserQuotaService.java | 95 + .../dromara/ai/service/SimpleAiService.java | 153 + .../service/impl/AiChatRecordServiceImpl.java | 157 + .../impl/AiPaintingFavoriteServiceImpl.java | 176 + .../impl/AiPaintingRecordServiceImpl.java | 269 ++ .../impl/AiPaintingTemplateServiceImpl.java | 177 + .../AiProviderConfigHistoryServiceImpl.java | 147 + .../impl/AiProviderConfigServiceImpl.java | 262 ++ .../impl/AiRatingRecordServiceImpl.java | 175 + .../impl/AiSensitiveWordServiceImpl.java | 222 ++ .../service/impl/AiUsageStatsServiceImpl.java | 305 ++ .../service/impl/AiUserQuotaServiceImpl.java | 308 ++ .../ai/websocket/ChatWebSocketConfig.java | 21 + .../ai/websocket/ChatWebSocketHandler.java | 81 + .../ApiGatewayIntegrationTest.java | 7 + .../java/org/dromara/ai/mock/MockData.java | 8 + .../ai/unit/ApiGatewayControllerTest.java | 7 + ruoyi-modules/ruoyi-chat/README.md | 747 ++++ ruoyi-modules/ruoyi-chat/pom.xml | 80 + .../java/org/dromara/chat/client/ChatSdk.java | 38 + .../chat/config/StompWebSocketConfig.java | 37 + .../controller/AIKnowledgeChatController.java | 97 + .../chat/controller/ChatConfigController.java | 111 + .../controller/ChatMessageController.java | 120 + .../chat/controller/ChatModelController.java | 129 + .../controller/ChatOpenApiController.java | 267 ++ .../controller/ChatSessionController.java | 122 + .../chat/controller/ChatStompController.java | 43 + .../controller/ChatUsageTokenController.java | 129 + .../org/dromara/chat/domain/ChatConfig.java | 88 + .../org/dromara/chat/domain/ChatMessage.java | 91 + .../org/dromara/chat/domain/ChatModel.java | 100 + .../org/dromara/chat/domain/ChatSession.java | 70 + .../dromara/chat/domain/ChatUsageToken.java | 48 + .../dromara/chat/domain/bo/ChatConfigBo.java | 62 + .../dromara/chat/domain/bo/ChatMessageBo.java | 76 + .../dromara/chat/domain/bo/ChatModelBo.java | 78 + .../dromara/chat/domain/bo/ChatSessionBo.java | 46 + .../chat/domain/bo/ChatUsageTokenBo.java | 53 + .../chat/domain/request/ChatRequest.java | 31 + .../request/MultiProviderChatRequest.java | 40 + .../chat/domain/request/RateRequest.java | 46 + .../dromara/chat/domain/vo/ChatConfigVo.java | 89 + .../dromara/chat/domain/vo/ChatMessageVo.java | 87 + .../dromara/chat/domain/vo/ChatModelVo.java | 102 + .../dromara/chat/domain/vo/ChatSessionVo.java | 62 + .../chat/domain/vo/ChatUsageTokenVo.java | 68 + .../dromara/chat/mapper/ChatConfigMapper.java | 14 + .../chat/mapper/ChatMessageMapper.java | 14 + .../dromara/chat/mapper/ChatModelMapper.java | 14 + .../chat/mapper/ChatSessionMapper.java | 14 + .../chat/mapper/ChatUsageTokenMapper.java | 14 + .../chat/service/AiKnowledgeChatService.java | 65 + .../chat/service/ChatConfigService.java | 60 + .../chat/service/ChatMessageService.java | 65 + .../chat/service/ChatModelService.java | 70 + .../chat/service/ChatSessionService.java | 65 + .../chat/service/ChatUsageTokenService.java | 70 + .../impl/AiKnowledgeChatServiceImpl.java | 198 + .../service/impl/ChatConfigServiceImpl.java | 129 + .../service/impl/ChatMessageServiceImpl.java | 151 + .../service/impl/ChatModelServiceImpl.java | 150 + .../service/impl/ChatSessionServiceImpl.java | 157 + .../impl/ChatUsageTokenServiceImpl.java | 152 + .../chat/service/ChatSessionServiceTest.java | 56 + ruoyi-modules/ruoyi-face/README.md | 747 ++++ ruoyi-modules/ruoyi-face/pom.xml | 80 + .../java/org/dromara/chat/client/ChatSdk.java | 38 + .../chat/config/StompWebSocketConfig.java | 37 + .../controller/AIKnowledgeChatController.java | 97 + .../chat/controller/ChatConfigController.java | 111 + .../controller/ChatMessageController.java | 120 + .../chat/controller/ChatModelController.java | 129 + .../controller/ChatOpenApiController.java | 267 ++ .../controller/ChatSessionController.java | 122 + .../chat/controller/ChatStompController.java | 43 + .../controller/ChatUsageTokenController.java | 129 + .../org/dromara/chat/domain/ChatConfig.java | 88 + .../org/dromara/chat/domain/ChatMessage.java | 91 + .../org/dromara/chat/domain/ChatModel.java | 100 + .../org/dromara/chat/domain/ChatSession.java | 70 + .../dromara/chat/domain/ChatUsageToken.java | 48 + .../dromara/chat/domain/bo/ChatConfigBo.java | 62 + .../dromara/chat/domain/bo/ChatMessageBo.java | 76 + .../dromara/chat/domain/bo/ChatModelBo.java | 78 + .../dromara/chat/domain/bo/ChatSessionBo.java | 46 + .../chat/domain/bo/ChatUsageTokenBo.java | 53 + .../chat/domain/request/ChatRequest.java | 31 + .../request/MultiProviderChatRequest.java | 40 + .../chat/domain/request/RateRequest.java | 46 + .../dromara/chat/domain/vo/ChatConfigVo.java | 89 + .../dromara/chat/domain/vo/ChatMessageVo.java | 87 + .../dromara/chat/domain/vo/ChatModelVo.java | 102 + .../dromara/chat/domain/vo/ChatSessionVo.java | 62 + .../chat/domain/vo/ChatUsageTokenVo.java | 68 + .../dromara/chat/mapper/ChatConfigMapper.java | 14 + .../chat/mapper/ChatMessageMapper.java | 14 + .../dromara/chat/mapper/ChatModelMapper.java | 14 + .../chat/mapper/ChatSessionMapper.java | 14 + .../chat/mapper/ChatUsageTokenMapper.java | 14 + .../chat/service/AiKnowledgeChatService.java | 65 + .../chat/service/ChatConfigService.java | 60 + .../chat/service/ChatMessageService.java | 65 + .../chat/service/ChatModelService.java | 70 + .../chat/service/ChatSessionService.java | 65 + .../chat/service/ChatUsageTokenService.java | 70 + .../impl/AiKnowledgeChatServiceImpl.java | 198 + .../service/impl/ChatConfigServiceImpl.java | 129 + .../service/impl/ChatMessageServiceImpl.java | 151 + .../service/impl/ChatModelServiceImpl.java | 150 + .../service/impl/ChatSessionServiceImpl.java | 157 + .../impl/ChatUsageTokenServiceImpl.java | 152 + .../chat/service/ChatSessionServiceTest.java | 56 + ruoyi-modules/ruoyi-generator/pom.xml | 84 + .../dromara/generator/config/GenConfig.java | 73 + .../config/MyBatisDataSourceMonitor.java | 106 + .../generator/constant/GenConstants.java | 186 + .../generator/controller/GenController.java | 214 ++ .../dromara/generator/domain/GenTable.java | 195 + .../generator/domain/GenTableColumn.java | 221 ++ .../mapper/GenTableColumnMapper.java | 15 + .../generator/mapper/GenTableMapper.java | 50 + .../service/GenTableServiceImpl.java | 575 +++ .../generator/service/IGenTableService.java | 141 + .../org/dromara/generator/util/GenUtils.java | 219 ++ .../generator/util/VelocityInitializer.java | 35 + .../dromara/generator/util/VelocityUtils.java | 341 ++ .../src/main/resources/generator.yml | 10 + .../mapper/generator/GenTableColumnMapper.xml | 10 + .../mapper/generator/GenTableMapper.xml | 76 + .../src/main/resources/mapper/package-info.md | 3 + .../src/main/resources/vm/java/bo.java.vm | 51 + .../main/resources/vm/java/controller.java.vm | 115 + .../src/main/resources/vm/java/domain.java.vm | 48 + .../src/main/resources/vm/java/mapper.java.vm | 15 + .../main/resources/vm/java/service.java.vm | 72 + .../resources/vm/java/serviceImpl.java.vm | 160 + .../src/main/resources/vm/java/vo.java.vm | 66 + .../src/main/resources/vm/sql/oracle/sql.vm | 19 + .../src/main/resources/vm/sql/postgres/sql.vm | 20 + .../src/main/resources/vm/sql/sql.vm | 19 + .../main/resources/vm/sql/sqlserver/sql.vm | 19 + .../src/main/resources/vm/ts/api.ts.vm | 63 + .../src/main/resources/vm/ts/types.ts.vm | 70 + .../main/resources/vm/vue/index-tree.vue.vm | 544 +++ .../src/main/resources/vm/vue/index.vue.vm | 519 +++ .../src/main/resources/vm/xml/mapper.xml.vm | 7 + ruoyi-modules/ruoyi-job/pom.xml | 34 + .../java/org/dromara/job/entity/BillDto.java | 30 + .../java/org/dromara/job/package-info.java | 1 + .../dromara/job/snailjob/AlipayBillTask.java | 42 + .../dromara/job/snailjob/SummaryBillTask.java | 45 + .../job/snailjob/TestAnnoJobExecutor.java | 25 + .../job/snailjob/TestBroadcastJob.java | 37 + .../job/snailjob/TestClassJobExecutor.java | 20 + .../job/snailjob/TestMapJobAnnotation.java | 53 + .../snailjob/TestMapReduceAnnotation1.java | 60 + .../job/snailjob/TestStaticShardingJob.java | 36 + .../dromara/job/snailjob/WechatBillTask.java | 43 + ruoyi-modules/ruoyi-knowledge/README.md | 1553 ++++++++ ruoyi-modules/ruoyi-knowledge/pom.xml | 134 + .../knowledge/config/RagConfigProperties.java | 35 + .../knowledge/config/RagConfiguration.java | 10 + .../controller/KnowledgeAttachController.java | 118 + .../KnowledgeFragmentController.java | 129 + .../controller/KnowledgeHitLogController.java | 165 + .../controller/KnowledgeInfoController.java | 175 + .../controller/KnowledgeJobController.java | 129 + .../KnowledgePromptTemplateController.java | 118 + .../controller/KnowledgeSchemaController.java | 140 + .../KnowledgeSchemaFieldController.java | 141 + .../KnowledgeSchemaGroupController.java | 129 + .../controller/KnowledgeVectorController.java | 180 + .../knowledge/data/CsvDocumentParser.java | 44 + .../dromara/knowledge/data/DataPipeline.java | 10 + .../knowledge/data/DocumentParser.java | 6 + .../knowledge/data/DocumentParserFactory.java | 152 + .../knowledge/data/ExcelDocumentParser.java | 57 + .../knowledge/data/HtmlDocumentParser.java | 53 + .../data/MarkdownDocumentParser.java | 24 + .../knowledge/data/PdfDocumentParser.java | 37 + .../knowledge/data/TextDocumentParser.java | 32 + .../knowledge/data/WordDocumentParser.java | 35 + .../knowledge/domain/KnowledgeAttach.java | 106 + .../knowledge/domain/KnowledgeFragment.java | 86 + .../knowledge/domain/KnowledgeHitLog.java | 96 + .../knowledge/domain/KnowledgeInfo.java | 126 + .../knowledge/domain/KnowledgeJob.java | 147 + .../domain/KnowledgePromptTemplate.java | 76 + .../knowledge/domain/KnowledgeRole.java | 86 + .../knowledge/domain/KnowledgeRoleGroup.java | 86 + .../domain/KnowledgeRoleRelation.java | 86 + .../knowledge/domain/KnowledgeSchema.java | 81 + .../domain/KnowledgeSchemaField.java | 161 + .../domain/KnowledgeSchemaGroup.java | 76 + .../knowledge/domain/KnowledgeVector.java | 76 + .../domain/bo/KnowledgeAttachBo.java | 82 + .../domain/bo/KnowledgeFragmentBo.java | 63 + .../domain/bo/KnowledgeHitLogBo.java | 110 + .../knowledge/domain/bo/KnowledgeInfoBo.java | 101 + .../knowledge/domain/bo/KnowledgeJobBo.java | 167 + .../domain/bo/KnowledgePromptTemplateBo.java | 51 + .../knowledge/domain/bo/KnowledgeRoleBo.java | 61 + .../domain/bo/KnowledgeRoleGroupBo.java | 61 + .../domain/bo/KnowledgeRoleRelationBo.java | 60 + .../domain/bo/KnowledgeSchemaBo.java | 57 + .../domain/bo/KnowledgeSchemaFieldBo.java | 138 + .../domain/bo/KnowledgeSchemaGroupBo.java | 51 + .../domain/bo/KnowledgeVectorBo.java | 86 + .../domain/vo/KnowledgeAttachVo.java | 107 + .../domain/vo/KnowledgeFragmentVo.java | 80 + .../domain/vo/KnowledgeHitLogVo.java | 110 + .../knowledge/domain/vo/KnowledgeInfoVo.java | 128 + .../knowledge/domain/vo/KnowledgeJobVo.java | 170 + .../domain/vo/KnowledgePromptTemplateVo.java | 68 + .../domain/vo/KnowledgeRoleGroupVo.java | 77 + .../domain/vo/KnowledgeRoleRelationVo.java | 77 + .../knowledge/domain/vo/KnowledgeRoleVo.java | 77 + .../domain/vo/KnowledgeSchemaFieldVo.java | 170 + .../domain/vo/KnowledgeSchemaGroupVo.java | 68 + .../domain/vo/KnowledgeSchemaVo.java | 74 + .../domain/vo/KnowledgeSearchResultVo.java | 73 + .../domain/vo/KnowledgeStatusVo.java | 107 + .../domain/vo/KnowledgeVectorVo.java | 94 + .../knowledge/embedding/EmbeddingEngine.java | 13 + .../knowledge/embedding/EmbeddingService.java | 5 + .../embedding/HttpEmbeddingService.java | 73 + .../generation/DefaultGenerator.java | 97 + .../knowledge/generation/Generator.java | 8 + .../mapper/KnowledgeAttachMapper.java | 14 + .../mapper/KnowledgeFragmentMapper.java | 14 + .../mapper/KnowledgeHitLogMapper.java | 14 + .../knowledge/mapper/KnowledgeInfoMapper.java | 14 + .../knowledge/mapper/KnowledgeJobMapper.java | 14 + .../mapper/KnowledgePromptTemplateMapper.java | 14 + .../mapper/KnowledgeRoleGroupMapper.java | 14 + .../knowledge/mapper/KnowledgeRoleMapper.java | 14 + .../mapper/KnowledgeRoleRelationMapper.java | 14 + .../mapper/KnowledgeSchemaFieldMapper.java | 14 + .../mapper/KnowledgeSchemaGroupMapper.java | 14 + .../mapper/KnowledgeSchemaMapper.java | 14 + .../mapper/KnowledgeVectorMapper.java | 14 + .../retrieval/DefaultIndexUpdater.java | 19 + .../knowledge/retrieval/DefaultRetriever.java | 20 + .../knowledge/retrieval/IndexUpdater.java | 6 + .../knowledge/retrieval/Retriever.java | 10 + .../service/KnowledgeAttachService.java | 59 + .../service/KnowledgeDocumentService.java | 97 + .../service/KnowledgeFragmentService.java | 64 + .../service/KnowledgeHitLogService.java | 126 + .../service/KnowledgeInfoService.java | 117 + .../service/KnowledgeJobService.java | 125 + .../KnowledgePromptTemplateService.java | 59 + .../service/KnowledgeRoleGroupService.java | 60 + .../service/KnowledgeRoleRelationService.java | 65 + .../service/KnowledgeRoleService.java | 60 + .../service/KnowledgeSchemaFieldService.java | 64 + .../service/KnowledgeSchemaGroupService.java | 59 + .../service/KnowledgeSchemaService.java | 64 + .../service/KnowledgeVectorService.java | 186 + .../impl/KnowledgeAttachServiceImpl.java | 135 + .../impl/KnowledgeDocumentServiceImpl.java | 407 +++ .../impl/KnowledgeFragmentServiceImpl.java | 142 + .../impl/KnowledgeHitLogServiceImpl.java | 317 ++ .../impl/KnowledgeInfoServiceImpl.java | 260 ++ .../service/impl/KnowledgeJobServiceImpl.java | 288 ++ .../KnowledgePromptTemplateServiceImpl.java | 129 + .../impl/KnowledgeRoleGroupServiceImpl.java | 134 + .../KnowledgeRoleRelationServiceImpl.java | 145 + .../impl/KnowledgeRoleServiceImpl.java | 134 + .../impl/KnowledgeSchemaFieldServiceImpl.java | 150 + .../impl/KnowledgeSchemaGroupServiceImpl.java | 129 + .../impl/KnowledgeSchemaServiceImpl.java | 141 + .../impl/KnowledgeVectorServiceImpl.java | 410 +++ .../vectorstore/DbVectorStoreService.java | 94 + .../vectorstore/QdrantVectorStoreService.java | 176 + .../vectorstore/VectorStoreManager.java | 25 + .../vectorstore/VectorStoreService.java | 15 + .../WeaviateVectorStoreService.java | 143 + .../version/DefaultVersionManager.java | 22 + .../knowledge/version/VersionManager.java | 8 + .../knowledge/KnowledgeAttachMapper.xml | 44 + .../knowledge/KnowledgeFragmentMapper.xml | 36 + .../knowledge/KnowledgeHitLogMapper.xml | 44 + .../mapper/knowledge/KnowledgeInfoMapper.xml | 52 + .../mapper/knowledge/KnowledgeJobMapper.xml | 44 + .../KnowledgePromptTemplateMapper.xml | 34 + .../knowledge/KnowledgeRoleGroupMapper.xml | 30 + .../mapper/knowledge/KnowledgeRoleMapper.xml | 32 + .../knowledge/KnowledgeRoleRelationMapper.xml | 32 + .../knowledge/KnowledgeSchemaFieldMapper.xml | 68 + .../knowledge/KnowledgeSchemaGroupMapper.xml | 34 + .../knowledge/KnowledgeSchemaMapper.xml | 36 + .../knowledge/KnowledgeVectorMapper.xml | 42 + .../integration/RagIntegrationTest.java | 7 + .../org/dromara/knowledge/mock/MockDocs.java | 8 + .../knowledge/unit/RagContractsTest.java | 7 + ruoyi-modules/ruoyi-mall/MODULE_STRUCTURE.md | 1253 +++++++ ruoyi-modules/ruoyi-mall/README.md | 264 ++ ruoyi-modules/ruoyi-mall/pom.xml | 27 + .../ruoyi-mall/ruoyi-mall-coupon/README.md | 133 + .../ruoyi-mall/ruoyi-mall-coupon/pom.xml | 67 + .../config/MallCouponAutoConfiguration.java | 23 + .../admin/AdminMallCouponController.java | 140 + .../admin/AdminMallCouponUserController.java | 89 + .../app/AppMallCouponController.java | 50 + .../app/AppMallCouponUserController.java | 78 + .../mall/coupon/domain/MallCoupon.java | 118 + .../mall/coupon/domain/MallCouponUser.java | 54 + .../mall/coupon/domain/bo/MallCouponBo.java | 99 + .../coupon/domain/bo/MallCouponUserBo.java | 51 + .../coupon/domain/vo/CouponStatisticsVo.java | 49 + .../coupon/domain/vo/MallCouponUserVo.java | 63 + .../mall/coupon/domain/vo/MallCouponVo.java | 99 + .../mall/coupon/job/MallCouponJob.java | 37 + .../mall/coupon/mapper/MallCouponMapper.java | 16 + .../coupon/mapper/MallCouponUserMapper.java | 16 + .../coupon/service/MallCouponService.java | 67 + .../coupon/service/MallCouponUserService.java | 63 + .../service/impl/MallCouponServiceImpl.java | 264 ++ .../impl/MallCouponUserServiceImpl.java | 296 ++ .../main/resources/META-INF/spring.factories | 3 + .../ruoyi-mall/ruoyi-mall-product/README.md | 223 ++ .../ruoyi-mall/ruoyi-mall-product/pom.xml | 61 + .../config/MallProductAutoConfiguration.java | 23 + .../admin/AdminMallAttributeController.java | 122 + .../AdminMallAttributeTemplateController.java | 110 + .../AdminMallAttributeValueController.java | 122 + .../admin/AdminMallBrandController.java | 110 + .../admin/AdminMallCategoryController.java | 121 + .../AdminMallProductAttributeController.java | 132 + .../admin/AdminMallProductController.java | 94 + .../app/AppMallBrandController.java | 40 + .../app/AppMallCategoryController.java | 37 + .../app/AppMallProductController.java | 82 + .../mall/product/domain/MallAttribute.java | 92 + .../product/domain/MallAttributeTemplate.java | 82 + .../product/domain/MallAttributeValue.java | 77 + .../mall/product/domain/MallBrand.java | 82 + .../mall/product/domain/MallCategory.java | 87 + .../mall/product/domain/MallProduct.java | 107 + .../product/domain/MallProductAttribute.java | 77 + .../dromara/mall/product/domain/MallSku.java | 97 + .../product/domain/bo/MallAttributeBo.java | 64 + .../domain/bo/MallAttributeTemplateBo.java | 53 + .../domain/bo/MallAttributeValueBo.java | 49 + .../mall/product/domain/bo/MallBrandBo.java | 53 + .../product/domain/bo/MallCategoryBo.java | 58 + .../domain/bo/MallProductAttributeBo.java | 47 + .../mall/product/domain/bo/MallProductBo.java | 82 + .../mall/product/domain/bo/MallSkuBo.java | 72 + .../domain/vo/MallAttributeTemplateVo.java | 74 + .../domain/vo/MallAttributeValueVo.java | 68 + .../product/domain/vo/MallAttributeVo.java | 88 + .../mall/product/domain/vo/MallBrandVo.java | 63 + .../product/domain/vo/MallCategoryVo.java | 74 + .../domain/vo/MallProductAttributeVo.java | 47 + .../mall/product/domain/vo/MallProductVo.java | 93 + .../mall/product/domain/vo/MallSkuVo.java | 83 + .../mall/product/enums/AttributeType.java | 57 + .../product/mapper/MallAttributeMapper.java | 16 + .../mapper/MallAttributeTemplateMapper.java | 16 + .../mapper/MallAttributeValueMapper.java | 16 + .../mall/product/mapper/MallBrandMapper.java | 16 + .../product/mapper/MallCategoryMapper.java | 16 + .../mapper/MallProductAttributeMapper.java | 16 + .../product/mapper/MallProductMapper.java | 16 + .../mall/product/mapper/MallSkuMapper.java | 16 + .../product/service/MallAttributeService.java | 53 + .../service/MallAttributeTemplateService.java | 48 + .../service/MallAttributeValueService.java | 53 + .../product/service/MallBrandService.java | 48 + .../product/service/MallCategoryService.java | 53 + .../service/MallProductAttributeService.java | 56 + .../product/service/MallProductService.java | 48 + .../mall/product/service/MallSkuService.java | 53 + .../impl/MallAttributeServiceImpl.java | 167 + .../MallAttributeTemplateServiceImpl.java | 141 + .../impl/MallAttributeValueServiceImpl.java | 158 + .../service/impl/MallBrandServiceImpl.java | 141 + .../service/impl/MallCategoryServiceImpl.java | 250 ++ .../impl/MallProductAttributeServiceImpl.java | 163 + .../service/impl/MallProductServiceImpl.java | 137 + .../service/impl/MallSkuServiceImpl.java | 158 + .../main/resources/META-INF/spring.factories | 3 + .../ruoyi-mall/ruoyi-mall-promotion/README.md | 204 ++ .../ruoyi-mall/ruoyi-mall-promotion/pom.xml | 81 + .../MallPromotionAutoConfiguration.java | 23 + .../admin/AdminMallBargainController.java | 126 + .../admin/AdminMallGroupBuyController.java | 126 + .../admin/AdminMallPromotionController.java | 126 + .../admin/AdminMallSeckillController.java | 126 + .../app/AppMallBargainController.java | 64 + .../app/AppMallGroupBuyController.java | 64 + .../app/AppMallPromotionController.java | 57 + .../app/AppMallSeckillController.java | 64 + .../mall/promotion/domain/MallBargain.java | 102 + .../mall/promotion/domain/MallGroupBuy.java | 87 + .../promotion/domain/MallGroupBuyOrder.java | 71 + .../mall/promotion/domain/MallPromotion.java | 92 + .../mall/promotion/domain/MallSeckill.java | 92 + .../promotion/domain/bo/MallBargainBo.java | 86 + .../promotion/domain/bo/MallGroupBuyBo.java | 66 + .../promotion/domain/bo/MallPromotionBo.java | 73 + .../promotion/domain/bo/MallSeckillBo.java | 74 + .../promotion/domain/vo/MallBargainVo.java | 98 + .../promotion/domain/vo/MallGroupBuyVo.java | 80 + .../promotion/domain/vo/MallPromotionVo.java | 86 + .../promotion/domain/vo/MallSeckillVo.java | 86 + .../mall/promotion/enums/PromotionType.java | 57 + .../mall/promotion/job/MallPromotionJob.java | 78 + .../promotion/mapper/MallBargainMapper.java | 16 + .../promotion/mapper/MallGroupBuyMapper.java | 16 + .../promotion/mapper/MallPromotionMapper.java | 16 + .../promotion/mapper/MallSeckillMapper.java | 35 + .../promotion/service/MallBargainService.java | 63 + .../service/MallGroupBuyService.java | 63 + .../service/MallPromotionService.java | 60 + .../promotion/service/MallSeckillService.java | 89 + .../service/impl/MallBargainServiceImpl.java | 210 ++ .../service/impl/MallGroupBuyServiceImpl.java | 169 + .../impl/MallPromotionServiceImpl.java | 173 + .../service/impl/MallSeckillServiceImpl.java | 263 ++ .../main/resources/META-INF/spring.factories | 3 + .../mapper/promotion/MallSeckillMapper.xml | 50 + .../ruoyi-mall-statistics/README.md | 109 + .../ruoyi-mall/ruoyi-mall-statistics/pom.xml | 71 + .../MallStatisticsAutoConfiguration.java | 23 + .../admin/AdminMallStatisticsController.java | 82 + .../domain/bo/StatisticsQueryBo.java | 50 + .../domain/vo/MemberStatisticsVo.java | 88 + .../domain/vo/TransactionStatisticsVo.java | 129 + .../mapper/MallStatisticsMapper.java | 50 + .../service/MallStatisticsService.java | 30 + .../impl/MallStatisticsServiceImpl.java | 128 + .../main/resources/META-INF/spring.factories | 3 + .../statistics/MallStatisticsMapper.xml | 101 + .../ruoyi-mall/ruoyi-mall-trade/README.md | 201 + .../ruoyi-mall/ruoyi-mall-trade/pom.xml | 105 + .../config/MallTradeAutoConfiguration.java | 23 + .../admin/AdminMallAddressController.java | 111 + .../admin/AdminMallAfterSaleController.java | 132 + .../AdminMallOrderEvaluateController.java | 120 + .../app/AppMallAddressController.java | 106 + .../app/AppMallAfterSaleController.java | 106 + .../app/AppMallOrderEvaluateController.java | 93 + .../mall/trade/domain/MallAddress.java | 81 + .../mall/trade/domain/MallAfterSale.java | 150 + .../mall/trade/domain/MallOrderCoupon.java | 61 + .../mall/trade/domain/MallOrderEvaluate.java | 105 + .../mall/trade/domain/bo/MallAddressBo.java | 76 + .../mall/trade/domain/bo/MallAfterSaleBo.java | 110 + .../trade/domain/bo/MallOrderCouponBo.java | 56 + .../trade/domain/bo/MallOrderEvaluateBo.java | 87 + .../mall/trade/domain/vo/MallAddressVo.java | 98 + .../mall/trade/domain/vo/MallAfterSaleVo.java | 143 + .../trade/domain/vo/MallOrderCouponVo.java | 58 + .../trade/domain/vo/MallOrderEvaluateVo.java | 98 + .../mall/trade/mapper/MallAddressMapper.java | 16 + .../trade/mapper/MallAfterSaleMapper.java | 16 + .../trade/mapper/MallOrderCouponMapper.java | 16 + .../trade/mapper/MallOrderEvaluateMapper.java | 16 + .../trade/service/MallAddressService.java | 63 + .../trade/service/MallAfterSaleService.java | 78 + .../trade/service/MallOrderCouponService.java | 77 + .../service/MallOrderEvaluateService.java | 58 + .../trade/service/SeckillOrderService.java | 102 + .../service/impl/MallAddressServiceImpl.java | 207 ++ .../impl/MallAfterSaleServiceImpl.java | 293 ++ .../impl/MallOrderCouponServiceImpl.java | 227 ++ .../impl/MallOrderEvaluateServiceImpl.java | 172 + .../service/impl/SeckillOrderServiceImpl.java | 175 + .../mall/trade/utils/CouponCalculator.java | 139 + .../main/resources/META-INF/spring.factories | 3 + .../ruoyi-member/MODULE_STRUCTURE.md | 935 +++++ ruoyi-modules/ruoyi-member/pom.xml | 94 + .../dromara/member/config/AppJwtFilter.java | 81 + .../member/config/AppJwtWebConfig.java | 30 + .../member/controller/AppFileController.java | 266 ++ .../member/controller/AppJwtController.java | 180 + .../admin/AdminConfigController.java | 175 + .../controller/admin/AdminRoleController.java | 172 + .../controller/admin/AdminUserController.java | 191 + .../controller/app/AppDeviceController.java | 247 ++ .../controller/app/AppMessageController.java | 222 ++ .../controller/app/AppOperLogController.java | 257 ++ .../controller/app/AppRoleController.java | 189 + .../controller/app/AppUserController.java | 220 ++ .../controller/app/AppVersionController.java | 255 ++ .../org/dromara/member/domain/AppDevice.java | 109 + .../org/dromara/member/domain/AppFile.java | 107 + .../dromara/member/domain/AppLoginLog.java | 100 + .../org/dromara/member/domain/AppMessage.java | 98 + .../org/dromara/member/domain/AppOperLog.java | 130 + .../org/dromara/member/domain/AppRole.java | 83 + .../dromara/member/domain/AppSocialAuth.java | 114 + .../org/dromara/member/domain/AppUser.java | 118 + .../dromara/member/domain/AppUserMessage.java | 83 + .../dromara/member/domain/AppUserRole.java | 70 + .../org/dromara/member/domain/AppVersion.java | 93 + .../dromara/member/domain/bo/AppDeviceBo.java | 87 + .../dromara/member/domain/bo/AppFileBo.java | 108 + .../member/domain/bo/AppMessageBo.java | 82 + .../member/domain/bo/AppOperLogBo.java | 107 + .../dromara/member/domain/bo/AppRoleBo.java | 61 + .../dromara/member/domain/bo/AppUserBo.java | 96 + .../member/domain/bo/AppVersionBo.java | 67 + .../dromara/member/domain/vo/AppDeviceVo.java | 108 + .../dromara/member/domain/vo/AppFileVo.java | 123 + .../member/domain/vo/AppLoginLogVo.java | 83 + .../member/domain/vo/AppMessageVo.java | 91 + .../member/domain/vo/AppOperLogVo.java | 120 + .../dromara/member/domain/vo/AppRoleVo.java | 71 + .../member/domain/vo/AppSocialAuthVo.java | 98 + .../member/domain/vo/AppUserMessageVo.java | 65 + .../member/domain/vo/AppUserRoleVo.java | 68 + .../dromara/member/domain/vo/AppUserVo.java | 114 + .../member/domain/vo/AppVersionVo.java | 77 + .../member/exception/AppExceptionHandler.java | 159 + .../member/exception/DeviceException.java | 96 + .../member/exception/FileException.java | 127 + .../member/exception/VersionException.java | 110 + .../member/mapper/AppDeviceMapper.java | 14 + .../dromara/member/mapper/AppFileMapper.java | 14 + .../member/mapper/AppLoginLogMapper.java | 14 + .../member/mapper/AppMessageMapper.java | 39 + .../member/mapper/AppOperLogMapper.java | 14 + .../dromara/member/mapper/AppRoleMapper.java | 64 + .../member/mapper/AppSocialAuthMapper.java | 14 + .../dromara/member/mapper/AppUserMapper.java | 62 + .../member/mapper/AppUserMessageMapper.java | 14 + .../member/mapper/AppUserRoleMapper.java | 51 + .../member/mapper/AppVersionMapper.java | 16 + .../member/service/AppDeviceService.java | 98 + .../member/service/AppFileService.java | 167 + .../member/service/AppMessageService.java | 162 + .../member/service/AppOperLogService.java | 141 + .../member/service/AppRoleService.java | 169 + .../member/service/AppSocialAuthService.java | 129 + .../member/service/AppUserService.java | 155 + .../member/service/AppVersionService.java | 88 + .../service/impl/AppDeviceServiceImpl.java | 333 ++ .../service/impl/AppFileServiceImpl.java | 420 +++ .../service/impl/AppMessageServiceImpl.java | 310 ++ .../service/impl/AppOperLogServiceImpl.java | 296 ++ .../service/impl/AppRoleServiceImpl.java | 237 ++ .../impl/AppSocialAuthServiceImpl.java | 273 ++ .../service/impl/AppUserServiceImpl.java | 239 ++ .../service/impl/AppVersionServiceImpl.java | 297 ++ .../org/dromara/member/utils/JwtUtil.java | 117 + .../mapper/member/AppDeviceMapper.xml | 162 + .../resources/mapper/member/AppFileMapper.xml | 166 + .../mapper/member/AppLoginLogMapper.xml | 128 + .../mapper/member/AppMessageMapper.xml | 175 + .../mapper/member/AppOperLogMapper.xml | 159 + .../resources/mapper/member/AppRoleMapper.xml | 85 + .../mapper/member/AppSocialAuthMapper.xml | 186 + .../resources/mapper/member/AppUserMapper.xml | 103 + .../mapper/member/AppUserMessageMapper.xml | 153 + .../mapper/member/AppUserRoleMapper.xml | 42 + .../mapper/member/AppVersionMapper.xml | 120 + ruoyi-modules/ruoyi-mp/pom.xml | 79 + .../mp/annotation/SwitchoverCheck.java | 16 + .../mp/aspect/SwitchoverCheckAspect.java | 46 + .../mp/builder/WxMpAbstractBuilder.java | 17 + .../mp/builder/WxMpImageBuilderWxMp.java | 23 + .../mp/builder/WxMpTextBuilderWxMp.java | 23 + .../dromara/mp/config/WxMpConfiguration.java | 81 + .../org/dromara/mp/config/WxMpProperties.java | 87 + .../mp/controller/WxMpJsapiController.java | 107 + .../mp/controller/WxMpMediaController.java | 205 ++ .../mp/controller/WxMpMenuController.java | 124 + .../mp/controller/WxMpOAuth2Controller.java | 235 ++ .../WxMpOauth2RedirectController.java | 177 + .../mp/controller/WxMpPortalController.java | 181 + .../mp/controller/WxMpQrCodeController.java | 129 + .../mp/controller/WxMpTemplateController.java | 226 ++ .../mp/controller/WxMpUserController.java | 251 ++ .../dromara/mp/handler/AbstractHandler.java | 67 + .../dromara/mp/handler/KfSessionHandler.java | 30 + .../dromara/mp/handler/LocationHandler.java | 52 + .../org/dromara/mp/handler/LogHandler.java | 28 + .../org/dromara/mp/handler/MenuHandler.java | 42 + .../org/dromara/mp/handler/MsgHandler.java | 44 + .../org/dromara/mp/handler/NullHandler.java | 32 + .../org/dromara/mp/handler/ScanHandler.java | 62 + .../mp/handler/StoreCheckNotifyHandler.java | 32 + .../dromara/mp/handler/SubscribeHandler.java | 34 + .../mp/handler/UnsubscribeHandler.java | 35 + .../UserAuthorizationRevokeHandler.java | 34 + .../mp/service/WxMpMaterialService.java | 84 + .../dromara/mp/service/WxMpMenuService.java | 50 + .../mp/service/WxMpMessageService.java | 123 + .../dromara/mp/service/WxMpQrCodeService.java | 76 + .../mp/service/WxMpStatisticsService.java | 158 + .../mp/service/WxMpTemplateService.java | 81 + .../dromara/mp/service/WxMpUserService.java | 133 + .../service/impl/WxMpMaterialServiceImpl.java | 139 + .../mp/service/impl/WxMpMenuServiceImpl.java | 120 + .../service/impl/WxMpMessageServiceImpl.java | 293 ++ .../service/impl/WxMpQrCodeServiceImpl.java | 96 + .../impl/WxMpStatisticsServiceImpl.java | 493 +++ .../service/impl/WxMpTemplateServiceImpl.java | 134 + .../mp/service/impl/WxMpUserServiceImpl.java | 199 + .../java/org/dromara/mp/utils/WxMpUtils.java | 365 ++ ruoyi-modules/ruoyi-pay/README.md | 912 +++++ ruoyi-modules/ruoyi-pay/pom.xml | 78 + .../org/dromara/pay/config/PayProperties.java | 131 + .../pay/controller/PayNotifyController.java | 136 + .../controller/admin/PayAppController.java | 84 + .../admin/PayChannelController.java | 84 + .../controller/admin/PayOrderController.java | 95 + .../controller/admin/PayRefundController.java | 84 + .../controller/app/AppPayOrderController.java | 69 + .../app/AppPayRefundController.java | 70 + .../org/dromara/pay/core/PayStrategy.java | 70 + .../dromara/pay/core/PayStrategyFactory.java | 60 + .../dromara/pay/core/impl/AlipayStrategy.java | 254 ++ .../pay/core/impl/WechatPayStrategy.java | 319 ++ .../dromara/pay/core/impl/WechatPayUtil.java | 53 + .../java/org/dromara/pay/domain/PayApp.java | 77 + .../org/dromara/pay/domain/PayChannel.java | 82 + .../org/dromara/pay/domain/PayNotifyTask.java | 92 + .../java/org/dromara/pay/domain/PayOrder.java | 149 + .../org/dromara/pay/domain/PayRefund.java | 146 + .../org/dromara/pay/domain/bo/PayAppBo.java | 54 + .../dromara/pay/domain/bo/PayChannelBo.java | 60 + .../org/dromara/pay/domain/bo/PayOrderBo.java | 117 + .../dromara/pay/domain/bo/PayRefundBo.java | 123 + .../org/dromara/pay/domain/vo/PayAppVo.java | 61 + .../dromara/pay/domain/vo/PayChannelVo.java | 66 + .../pay/domain/vo/PayNotifyResult.java | 31 + .../pay/domain/vo/PayOrderSubmitRespVO.java | 33 + .../org/dromara/pay/domain/vo/PayOrderVo.java | 126 + .../dromara/pay/domain/vo/PayRefundVo.java | 131 + .../org/dromara/pay/enums/PayChannelEnum.java | 38 + .../dromara/pay/enums/PayOrderStatusEnum.java | 23 + .../dromara/pay/enums/RefundStatusEnum.java | 30 + .../dromara/pay/event/PaySuccessEvent.java | 21 + .../org/dromara/pay/job/PayNotifyJob.java | 38 + .../dromara/pay/job/PayOrderExpireJob.java | 63 + .../org/dromara/pay/job/PayOrderSyncJob.java | 77 + .../pay/listener/PayOrderListener.java | 36 + .../org/dromara/pay/mapper/PayAppMapper.java | 16 + .../dromara/pay/mapper/PayChannelMapper.java | 16 + .../pay/mapper/PayNotifyTaskMapper.java | 13 + .../dromara/pay/mapper/PayOrderMapper.java | 16 + .../dromara/pay/mapper/PayRefundMapper.java | 16 + .../dromara/pay/service/PayAppService.java | 46 + .../pay/service/PayChannelService.java | 46 + .../dromara/pay/service/PayNotifyService.java | 131 + .../dromara/pay/service/PayOrderService.java | 57 + .../dromara/pay/service/PayRefundService.java | 56 + .../pay/service/impl/PayAppServiceImpl.java | 132 + .../service/impl/PayChannelServiceImpl.java | 141 + .../pay/service/impl/PayOrderServiceImpl.java | 223 ++ .../service/impl/PayRefundServiceImpl.java | 234 ++ .../org/dromara/pay/util/WechatPayUtil.java | 72 + .../resources/mapper/pay/PayAppMapper.xml | 7 + .../resources/mapper/pay/PayChannelMapper.xml | 7 + .../resources/mapper/pay/PayOrderMapper.xml | 7 + .../resources/mapper/pay/PayRefundMapper.xml | 7 + ruoyi-modules/ruoyi-system/pom.xml | 100 + .../controller/monitor/CacheController.java | 59 + .../monitor/SysLogininforController.java | 90 + .../monitor/SysOperlogController.java | 76 + .../monitor/SysUserOnlineController.java | 131 + .../system/SysClientController.java | 116 + .../system/SysConfigController.java | 138 + .../controller/system/SysDeptController.java | 143 + .../system/SysDictDataController.java | 124 + .../system/SysDictTypeController.java | 126 + .../controller/system/SysMenuController.java | 171 + .../system/SysNoticeController.java | 90 + .../system/SysOssConfigController.java | 105 + .../controller/system/SysOssController.java | 108 + .../controller/system/SysPostController.java | 133 + .../system/SysProfileController.java | 135 + .../controller/system/SysRoleController.java | 233 ++ .../system/SysSocialController.java | 38 + .../controller/system/SysUserController.java | 292 ++ .../org/dromara/system/domain/SysCache.java | 47 + .../org/dromara/system/domain/SysClient.java | 79 + .../org/dromara/system/domain/SysConfig.java | 50 + .../org/dromara/system/domain/SysDept.java | 92 + .../dromara/system/domain/SysDictData.java | 71 + .../dromara/system/domain/SysDictType.java | 41 + .../dromara/system/domain/SysLogininfor.java | 80 + .../org/dromara/system/domain/SysMenu.java | 191 + .../org/dromara/system/domain/SysNotice.java | 51 + .../org/dromara/system/domain/SysOperLog.java | 111 + .../org/dromara/system/domain/SysOss.java | 55 + .../dromara/system/domain/SysOssConfig.java | 89 + .../org/dromara/system/domain/SysPost.java | 61 + .../org/dromara/system/domain/SysRole.java | 79 + .../dromara/system/domain/SysRoleDept.java | 29 + .../dromara/system/domain/SysRoleMenu.java | 29 + .../org/dromara/system/domain/SysSocial.java | 136 + .../org/dromara/system/domain/SysUser.java | 115 + .../dromara/system/domain/SysUserOnline.java | 63 + .../dromara/system/domain/SysUserPost.java | 29 + .../dromara/system/domain/SysUserRole.java | 29 + .../dromara/system/domain/bo/SysClientBo.java | 81 + .../dromara/system/domain/bo/SysConfigBo.java | 59 + .../dromara/system/domain/bo/SysDeptBo.java | 81 + .../system/domain/bo/SysDictDataBo.java | 79 + .../system/domain/bo/SysDictTypeBo.java | 50 + .../system/domain/bo/SysLogininforBo.java | 82 + .../dromara/system/domain/bo/SysMenuBo.java | 109 + .../dromara/system/domain/bo/SysNoticeBo.java | 60 + .../system/domain/bo/SysOperLogBo.java | 116 + .../dromara/system/domain/bo/SysOssBo.java | 54 + .../system/domain/bo/SysOssConfigBo.java | 109 + .../dromara/system/domain/bo/SysPostBo.java | 75 + .../dromara/system/domain/bo/SysRoleBo.java | 94 + .../dromara/system/domain/bo/SysSocialBo.java | 141 + .../dromara/system/domain/bo/SysUserBo.java | 124 + .../system/domain/bo/SysUserPasswordBo.java | 29 + .../system/domain/bo/SysUserProfileBo.java | 53 + .../org/dromara/system/domain/vo/MetaVo.java | 61 + .../dromara/system/domain/vo/RouterVo.java | 62 + .../dromara/system/domain/vo/SysClientVo.java | 90 + .../dromara/system/domain/vo/SysConfigVo.java | 71 + .../dromara/system/domain/vo/SysDeptVo.java | 109 + .../system/domain/vo/SysDictDataVo.java | 88 + .../system/domain/vo/SysDictTypeVo.java | 57 + .../system/domain/vo/SysLogininforVo.java | 100 + .../dromara/system/domain/vo/SysMenuVo.java | 116 + .../dromara/system/domain/vo/SysNoticeVo.java | 72 + .../system/domain/vo/SysOperLogVo.java | 139 + .../system/domain/vo/SysOssConfigVo.java | 97 + .../system/domain/vo/SysOssUploadVo.java | 28 + .../dromara/system/domain/vo/SysOssVo.java | 77 + .../dromara/system/domain/vo/SysPostVo.java | 91 + .../dromara/system/domain/vo/SysRoleVo.java | 100 + .../dromara/system/domain/vo/SysSocialVo.java | 139 + .../system/domain/vo/SysUserExportVo.java | 94 + .../system/domain/vo/SysUserImportVo.java | 76 + .../system/domain/vo/SysUserInfoVo.java | 40 + .../dromara/system/domain/vo/SysUserVo.java | 137 + .../dromara/system/domain/vo/UserInfoVo.java | 30 + .../listener/SysUserImportListener.java | 126 + .../system/mapper/SysClientMapper.java | 15 + .../system/mapper/SysConfigMapper.java | 14 + .../dromara/system/mapper/SysDeptMapper.java | 84 + .../system/mapper/SysDictDataMapper.java | 29 + .../system/mapper/SysDictTypeMapper.java | 14 + .../system/mapper/SysLogininforMapper.java | 14 + .../dromara/system/mapper/SysMenuMapper.java | 76 + .../system/mapper/SysNoticeMapper.java | 14 + .../system/mapper/SysOperLogMapper.java | 14 + .../system/mapper/SysOssConfigMapper.java | 14 + .../dromara/system/mapper/SysOssMapper.java | 13 + .../dromara/system/mapper/SysPostMapper.java | 43 + .../system/mapper/SysRoleDeptMapper.java | 13 + .../dromara/system/mapper/SysRoleMapper.java | 75 + .../system/mapper/SysRoleMenuMapper.java | 26 + .../system/mapper/SysSocialMapper.java | 16 + .../dromara/system/mapper/SysUserMapper.java | 133 + .../system/mapper/SysUserPostMapper.java | 13 + .../system/mapper/SysUserRoleMapper.java | 23 + .../runner/SystemApplicationRunner.java | 28 + .../system/service/SysClientService.java | 58 + .../system/service/SysConfigService.java | 87 + .../system/service/SysDataScopeService.java | 26 + .../system/service/SysDeptService.java | 137 + .../system/service/SysDictDataService.java | 76 + .../system/service/SysDictTypeService.java | 95 + .../system/service/SysLogininforService.java | 47 + .../system/service/SysMenuService.java | 155 + .../system/service/SysNoticeService.java | 67 + .../system/service/SysOperLogService.java | 54 + .../system/service/SysOssConfigService.java | 64 + .../dromara/system/service/SysOssService.java | 80 + .../system/service/SysPermissionService.java | 28 + .../system/service/SysPostService.java | 135 + .../system/service/SysRoleService.java | 207 ++ .../system/service/SysSocialService.java | 53 + .../system/service/SysUserService.java | 247 ++ .../service/impl/SysClientServiceImpl.java | 138 + .../service/impl/SysConfigServiceImpl.java | 205 ++ .../service/impl/SysDataScopeServiceImpl.java | 78 + .../service/impl/SysDeptServiceImpl.java | 412 +++ .../service/impl/SysDictDataServiceImpl.java | 152 + .../service/impl/SysDictTypeServiceImpl.java | 297 ++ .../impl/SysLogininforServiceImpl.java | 169 + .../service/impl/SysMenuServiceImpl.java | 374 ++ .../service/impl/SysNoticeServiceImpl.java | 124 + .../service/impl/SysOperLogServiceImpl.java | 136 + .../service/impl/SysOssConfigServiceImpl.java | 175 + .../service/impl/SysOssServiceImpl.java | 269 ++ .../impl/SysPermissionServiceImpl.java | 62 + .../service/impl/SysPostServiceImpl.java | 254 ++ .../service/impl/SysRoleServiceImpl.java | 551 +++ .../service/impl/SysSensitiveServiceImpl.java | 42 + .../service/impl/SysSocialServiceImpl.java | 112 + .../impl/SysTaskAssigneeServiceImpl.java | 138 + .../service/impl/SysUserServiceImpl.java | 840 +++++ .../src/main/resources/mapper/package-info.md | 3 + .../mapper/system/SysClientMapper.xml | 7 + .../mapper/system/SysConfigMapper.xml | 7 + .../resources/mapper/system/SysDeptMapper.xml | 22 + .../mapper/system/SysDictDataMapper.xml | 7 + .../mapper/system/SysDictTypeMapper.xml | 7 + .../mapper/system/SysLogininforMapper.xml | 7 + .../resources/mapper/system/SysMenuMapper.xml | 84 + .../mapper/system/SysNoticeMapper.xml | 7 + .../mapper/system/SysOperLogMapper.xml | 7 + .../mapper/system/SysOssConfigMapper.xml | 7 + .../resources/mapper/system/SysOssMapper.xml | 5 + .../resources/mapper/system/SysPostMapper.xml | 18 + .../mapper/system/SysRoleDeptMapper.xml | 7 + .../resources/mapper/system/SysRoleMapper.xml | 60 + .../mapper/system/SysRoleMenuMapper.xml | 7 + .../mapper/system/SysSocialMapper.xml | 7 + .../resources/mapper/system/SysUserMapper.xml | 71 + .../mapper/system/SysUserPostMapper.xml | 7 + .../mapper/system/SysUserRoleMapper.xml | 14 + ruoyi-modules/ruoyi-workflow/pom.xml | 80 + .../workflow/common/ConditionalOnEnable.java | 29 + .../common/constant/FlowConstant.java | 81 + .../common/enums/ButtonPermissionEnum.java | 65 + .../common/enums/MessageTypeEnum.java | 51 + .../workflow/common/enums/NodeExtEnum.java | 32 + .../common/enums/TaskAssigneeEnum.java | 109 + .../common/enums/TaskAssigneeType.java | 49 + .../workflow/common/enums/TaskStatusEnum.java | 102 + .../workflow/config/WarmFlowConfig.java | 16 + .../controller/FlwCategoryController.java | 136 + .../controller/FlwDefinitionController.java | 194 + .../controller/FlwInstanceController.java | 157 + .../controller/FlwTaskController.java | 212 ++ .../controller/TestLeaveController.java | 108 + .../dromara/workflow/domain/FlowCategory.java | 67 + .../dromara/workflow/domain/TestLeave.java | 63 + .../workflow/domain/bo/BackProcessBo.java | 69 + .../workflow/domain/bo/CompleteTaskBo.java | 80 + .../workflow/domain/bo/FlowCancelBo.java | 31 + .../workflow/domain/bo/FlowCategoryBo.java | 46 + .../workflow/domain/bo/FlowCopyBo.java | 30 + .../workflow/domain/bo/FlowInstanceBo.java | 55 + .../workflow/domain/bo/FlowInvalidBo.java | 31 + .../workflow/domain/bo/FlowNextNodeBo.java | 38 + .../workflow/domain/bo/FlowTaskBo.java | 55 + .../workflow/domain/bo/FlowTerminationBo.java | 31 + .../workflow/domain/bo/StartProcessBo.java | 49 + .../workflow/domain/bo/TaskOperationBo.java | 48 + .../workflow/domain/bo/TestLeaveBo.java | 82 + .../domain/vo/ButtonPermissionVo.java | 43 + .../workflow/domain/vo/FlowCategoryVo.java | 69 + .../workflow/domain/vo/FlowDefinitionVo.java | 99 + .../workflow/domain/vo/FlowHisTaskVo.java | 244 ++ .../workflow/domain/vo/FlowInstanceVo.java | 137 + .../workflow/domain/vo/FlowTaskVo.java | 182 + .../workflow/domain/vo/TestLeaveVo.java | 70 + .../handler/FlowProcessEventHandler.java | 84 + .../handler/WorkflowPermissionHandler.java | 59 + .../listener/WorkflowGlobalListener.java | 193 + .../workflow/mapper/FlwCategoryMapper.java | 62 + .../workflow/mapper/FlwInstanceMapper.java | 27 + .../workflow/mapper/FlwTaskMapper.java | 57 + .../workflow/mapper/TestLeaveMapper.java | 15 + .../workflow/service/IFlwCategoryService.java | 102 + .../workflow/service/IFlwCommonService.java | 37 + .../service/IFlwDefinitionService.java | 72 + .../workflow/service/IFlwInstanceService.java | 159 + .../workflow/service/IFlwNodeExtService.java | 22 + .../service/IFlwTaskAssigneeService.java | 24 + .../workflow/service/IFlwTaskService.java | 218 ++ .../workflow/service/ITestLeaveService.java | 47 + .../impl/CategoryNameTranslationImpl.java | 31 + .../service/impl/FlwCategoryServiceImpl.java | 258 ++ .../service/impl/FlwChartExtServiceImpl.java | 247 ++ .../service/impl/FlwCommonServiceImpl.java | 122 + .../impl/FlwDefinitionServiceImpl.java | 198 + .../service/impl/FlwInstanceServiceImpl.java | 430 +++ .../service/impl/FlwNodeExtServiceImpl.java | 243 ++ .../impl/FlwTaskAssigneeServiceImpl.java | 254 ++ .../service/impl/FlwTaskServiceImpl.java | 755 ++++ .../service/impl/TestLeaveServiceImpl.java | 198 + .../service/impl/WorkflowServiceImpl.java | 141 + .../src/main/resources/mapper/package-info.md | 3 + .../mapper/workflow/FlwCategoryMapper.xml | 7 + .../mapper/workflow/FlwInstanceMapper.xml | 36 + .../mapper/workflow/FlwTaskMapper.xml | 109 + .../mapper/workflow/TestLeaveMapper.xml | 7 + ruoyi-plugins/.DS_Store | Bin 0 -> 6148 bytes ruoyi-server/Dockerfile | 30 + .../docs/LOGIN_COMPREHENSIVE_GUIDE.md | 718 ++++ ruoyi-server/pom.xml | 188 + .../java/org/dromara/PoJieApplication.java | 22 + .../org/dromara/PoJieServletInitializer.java | 18 + .../app/controller/AppAuthController.java | 321 ++ .../app/controller/AppSessionController.java | 59 + .../org/dromara/app/domain/AppLoginUser.java | 118 + .../dromara/app/domain/vo/AppConfigVo.java | 13 + .../app/listener/AppUserActionListener.java | 157 + .../app/service/AppLoginRiskService.java | 53 + .../dromara/app/service/AppLoginService.java | 151 + .../dromara/app/service/IAppAuthStrategy.java | 44 + .../service/impl/AppEmailAuthStrategy.java | 107 + .../service/impl/AppPasswordAuthStrategy.java | 166 + .../app/service/impl/AppSmsAuthStrategy.java | 114 + .../service/impl/AppSocialAuthStrategy.java | 116 + .../AppWechatMiniprogramAuthStrategy.java | 118 + .../service/impl/AppWechatMpAuthStrategy.java | 130 + .../impl/AppWechatOpenAuthStrategy.java | 128 + .../org/dromara/app/session/AppSession.java | 19 + .../app/session/AppSessionManager.java | 80 + .../web/config/AppInfoCompletionFilter.java | 73 + .../dromara/web/config/RiskInterceptor.java | 52 + .../dromara/web/config/WebMvcPlusConfig.java | 41 + .../properties/RiskControlProperties.java | 41 + .../web/controller/AuthController.java | 208 ++ .../web/controller/CaptchaController.java | 154 + .../web/controller/IndexController.java | 28 + .../org/dromara/web/domain/vo/CaptchaVo.java | 28 + .../org/dromara/web/domain/vo/LoginVo.java | 54 + .../web/listener/UserActionListener.java | 173 + .../dromara/web/service/IAuthStrategy.java | 45 + .../web/service/RiskControlService.java | 137 + .../dromara/web/service/SysLoginService.java | 205 ++ .../web/service/SysRegisterService.java | 108 + .../web/service/impl/EmailAuthStrategy.java | 99 + .../service/impl/PasswordAuthStrategy.java | 120 + .../web/service/impl/SmsAuthStrategy.java | 99 + .../web/service/impl/SocialAuthStrategy.java | 107 + ruoyi-server/src/main/resources/.DS_Store | Bin 0 -> 8196 bytes .../src/main/resources/application-ai.yml | 274 ++ .../src/main/resources/application-dev.yml | 337 ++ .../src/main/resources/application-prod.yml | 276 ++ .../src/main/resources/application.yml | 274 ++ ruoyi-server/src/main/resources/banner.txt | 8 + .../main/resources/i18n/messages.properties | 56 + .../resources/i18n/messages_en_US.properties | 56 + .../resources/i18n/messages_zh_CN.properties | 56 + ruoyi-server/src/main/resources/ip2region.xdb | Bin 0 -> 11070083 bytes .../src/main/resources/logback-plus.xml | 129 + .../app/session/AppSessionManagerTest.java | 13 + .../java/org/dromara/test/AssertUnitTest.java | 45 + .../java/org/dromara/test/DemoUnitTest.java | 70 + .../java/org/dromara/test/ParamUnitTest.java | 72 + .../java/org/dromara/test/TagUnitTest.java | 54 + script/bin/ry.bat | 68 + script/bin/ry.sh | 86 + script/docker/database.yml | 59 + script/docker/docker-compose.yml | 157 + script/docker/nginx/conf/nginx.conf | 123 + script/docker/redis/conf/redis.conf | 28 + script/docker/redis/data/README.md | 1 + script/leave/leave1.json | 75 + script/leave/leave2.json | 111 + script/leave/leave3.json | 121 + script/leave/leave4.json | 90 + script/leave/leave5.json | 121 + script/leave/leave6.json | 215 ++ script/sql/oracle/oracle_ry_app_5.X.sql | 331 ++ script/sql/oracle/oracle_ry_job.sql | 924 +++++ script/sql/oracle/oracle_ry_vue_5.X.sql | 1270 +++++++ script/sql/oracle/oracle_ry_workflow.sql | 429 +++ script/sql/postgres/postgres_ry_app_5.X.sql | 331 ++ script/sql/postgres/postgres_ry_job.sql | 855 +++++ script/sql/postgres/postgres_ry_vue_5.X.sql | 1257 +++++++ script/sql/postgres/postgres_ry_workflow.sql | 407 +++ script/sql/ry_ai.sql | 257 ++ script/sql/ry_app.sql | 229 ++ script/sql/ry_chat.sql | 116 + script/sql/ry_job.sql | 523 +++ script/sql/ry_knowledge.sql | 386 ++ script/sql/ry_mall.sql | 566 +++ script/sql/ry_pay.sql | 142 + script/sql/ry_update.sql | 15 + script/sql/ry_vue.sql | 885 +++++ script/sql/ry_workflow.sql | 255 ++ script/sql/sqlserver/sqlserver_ry_app_5.X.sql | 320 ++ script/sql/sqlserver/sqlserver_ry_job.sql | 2825 ++++++++++++++ script/sql/sqlserver/sqlserver_ry_vue_5.X.sql | 3245 +++++++++++++++++ .../sql/sqlserver/sqlserver_ry_workflow.sql | 1354 +++++++ 1467 files changed, 160403 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 UPDATE.md create mode 100644 pom.xml create mode 100644 ruoyi-common/.DS_Store create mode 100644 ruoyi-common/pom.xml create mode 100644 ruoyi-common/ruoyi-common-bom/pom.xml create mode 100644 ruoyi-common/ruoyi-common-core/pom.xml create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ApplicationConfig.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/AsyncConfig.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ThreadPoolConfig.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ValidatorConfig.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/properties/ThreadPoolProperties.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheConstants.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheNames.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/Constants.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/GlobalConstants.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/HttpStatus.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/RegexConstants.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/UserConstants.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/CompleteTaskDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DeptDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DictDataDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DictTypeDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/FlowCopyDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/OssDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/PostDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/RoleDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/StartProcessDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/StartProcessReturnDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/TaskAssigneeDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/UserDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/UserOnlineDTO.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessDeleteEvent.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessEvent.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessTaskEvent.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/EmailLoginBody.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/LoginBody.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/LoginUser.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/PasswordLoginBody.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/RegisterBody.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/SmsLoginBody.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/SocialLoginBody.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/TaskAssigneeBody.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/XcxLoginBody.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/XcxLoginUser.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/BusinessStatusEnum.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/DeviceType.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/FormatsType.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/LoginType.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserStatus.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserType.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/ServiceException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/SseException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/base/BaseException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileNameLengthLimitExceededException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileSizeLimitExceededException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/CaptchaException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/CaptchaExpireException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/UserException.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/factory/RegexPatternPoolFactory.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/factory/YmlPropertySourceFactory.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/ConfigService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/DeptService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/DictService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/OssService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/PermissionService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/PostService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/RoleService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/TaskAssigneeService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/UserService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/WorkflowService.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MapstructUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MessageUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/NetUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ObjectUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ServletUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/SpringUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StreamUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StringUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/Threads.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/TreeBuildUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ValidatorUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/file/FileUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/file/MimeTypeUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/reflect/ReflectUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexUtils.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexValidator.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/AddGroup.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/EditGroup.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/QueryGroup.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPattern.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPatternValidator.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPattern.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPatternValidator.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/xss/Xss.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/xss/XssValidator.java create mode 100644 ruoyi-common/ruoyi-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-doc/pom.xml create mode 100644 ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/SpringDocConfig.java create mode 100644 ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/properties/SpringDocProperties.java create mode 100644 ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/handler/OpenApiHandler.java create mode 100644 ruoyi-common/ruoyi-common-doc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-encrypt/pom.xml create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/ApiEncrypt.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/EncryptField.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/config/ApiDecryptAutoConfiguration.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/config/EncryptorAutoConfiguration.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/EncryptContext.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/EncryptorManager.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/IEncryptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/AbstractEncryptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/AesEncryptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Base64Encryptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/RsaEncryptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Sm2Encryptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Sm4Encryptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/enumd/AlgorithmType.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/enumd/EncodeType.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/CryptoFilter.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/DecryptRequestBodyWrapper.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/EncryptResponseBodyWrapper.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/interceptor/MybatisDecryptInterceptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/interceptor/MybatisEncryptInterceptor.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/properties/ApiDecryptProperties.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/properties/EncryptorProperties.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/utils/EncryptUtils.java create mode 100644 ruoyi-common/ruoyi-common-encrypt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-excel/pom.xml create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/CellMerge.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelDictFormat.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelEnumFormat.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelNotation.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelRequired.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelBigNumberConvert.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelDictConvert.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelEnumConvert.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeStrategy.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DefaultExcelListener.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DefaultExcelResult.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DropDownOptions.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelListener.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelResult.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/handler/DataWriteHandler.java create mode 100644 ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/utils/ExcelUtil.java create mode 100644 ruoyi-common/ruoyi-common-idempotent/pom.xml create mode 100644 ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/annotation/RepeatSubmit.java create mode 100644 ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/aspectj/RepeatSubmitAspect.java create mode 100644 ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/config/IdempotentConfig.java create mode 100644 ruoyi-common/ruoyi-common-idempotent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-job/pom.xml create mode 100644 ruoyi-common/ruoyi-common-job/src/main/java/org/dromara/common/job/config/SnailJobConfig.java create mode 100644 ruoyi-common/ruoyi-common-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-json/pom.xml create mode 100644 ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/config/JacksonConfig.java create mode 100644 ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/BigNumberSerializer.java create mode 100644 ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/CustomDateDeserializer.java create mode 100644 ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/utils/JsonUtils.java create mode 100644 ruoyi-common/ruoyi-common-json/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-log/pom.xml create mode 100644 ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/annotation/Log.java create mode 100644 ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/aspect/LogAspect.java create mode 100644 ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessStatus.java create mode 100644 ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessType.java create mode 100644 ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/OperatorType.java create mode 100644 ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/event/LogininforEvent.java create mode 100644 ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/event/OperLogEvent.java create mode 100644 ruoyi-common/ruoyi-common-log/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-mail/pom.xml create mode 100644 ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/config/MailConfig.java create mode 100644 ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/config/properties/MailProperties.java create mode 100644 ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/utils/MailUtils.java create mode 100644 ruoyi-common/ruoyi-common-mail/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-mybatis/pom.xml create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/annotation/DataColumn.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/annotation/DataPermission.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/aspect/DataPermissionAspect.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/domain/BaseEntity.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/mapper/BaseMapperPlus.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/TableDataInfo.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/enums/DataBaseType.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/enums/DataScopeType.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/InjectionMetaObjectHandler.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/MybatisExceptionHandler.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/PlusDataPermissionHandler.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/PlusPostInitTableInfoHandler.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/helper/DataBaseHelper.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/helper/DataPermissionHelper.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/interceptor/PlusDataPermissionInterceptor.java create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/resources/common-mybatis.yml create mode 100644 ruoyi-common/ruoyi-common-mybatis/src/main/resources/spy.properties create mode 100644 ruoyi-common/ruoyi-common-oss/pom.xml create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/constant/OssConstant.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/entity/UploadResult.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/enums/AccessPolicyType.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/exception/OssException.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/factory/OssFactory.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/properties/OssProperties.java create mode 100644 ruoyi-common/ruoyi-common-ratelimiter/pom.xml create mode 100644 ruoyi-common/ruoyi-common-ratelimiter/src/main/java/org/dromara/common/ratelimiter/annotation/RateLimiter.java create mode 100644 ruoyi-common/ruoyi-common-ratelimiter/src/main/java/org/dromara/common/ratelimiter/aspectj/RateLimiterAspect.java create mode 100644 ruoyi-common/ruoyi-common-ratelimiter/src/main/java/org/dromara/common/ratelimiter/config/RateLimiterConfig.java create mode 100644 ruoyi-common/ruoyi-common-ratelimiter/src/main/java/org/dromara/common/ratelimiter/enums/LimitType.java create mode 100644 ruoyi-common/ruoyi-common-ratelimiter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-ratelimiter/src/main/resources/spel-extension.json create mode 100644 ruoyi-common/ruoyi-common-redis/pom.xml create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/config/CacheConfig.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/config/RedisConfig.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/config/properties/RedissonProperties.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/handler/KeyPrefixHandler.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/handler/RedisExceptionHandler.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/manager/CaffeineCacheDecorator.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/manager/PlusSpringCacheManager.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/utils/CacheUtils.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/utils/QueueUtils.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/utils/RedisUtils.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/utils/SequenceUtils.java create mode 100644 ruoyi-common/ruoyi-common-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-satoken/pom.xml create mode 100644 ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/config/SaTokenConfig.java create mode 100644 ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/core/dao/PlusSaTokenDao.java create mode 100644 ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/core/service/SaPermissionImpl.java create mode 100644 ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/handler/SaTokenExceptionHandler.java create mode 100644 ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/utils/LoginHelper.java create mode 100644 ruoyi-common/ruoyi-common-satoken/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-satoken/src/main/resources/common-satoken.yml create mode 100644 ruoyi-common/ruoyi-common-security/pom.xml create mode 100644 ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java create mode 100644 ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/properties/SecurityProperties.java create mode 100644 ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/handler/AllUrlHandler.java create mode 100644 ruoyi-common/ruoyi-common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-sensitive/pom.xml create mode 100644 ruoyi-common/ruoyi-common-sensitive/src/main/java/org/dromara/common/sensitive/annotation/Sensitive.java create mode 100644 ruoyi-common/ruoyi-common-sensitive/src/main/java/org/dromara/common/sensitive/core/SensitiveService.java create mode 100644 ruoyi-common/ruoyi-common-sensitive/src/main/java/org/dromara/common/sensitive/core/SensitiveStrategy.java create mode 100644 ruoyi-common/ruoyi-common-sensitive/src/main/java/org/dromara/common/sensitive/handler/SensitiveHandler.java create mode 100644 ruoyi-common/ruoyi-common-sms/pom.xml create mode 100644 ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java create mode 100644 ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/core/dao/PlusSmsDao.java create mode 100644 ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/handler/SmsExceptionHandler.java create mode 100644 ruoyi-common/ruoyi-common-sms/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-social/pom.xml create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/me/zhyd/oauth/request/AbstractAuthWeChatEnterpriseRequest.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/config/SocialAutoConfiguration.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/config/properties/SocialLoginConfigProperties.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/config/properties/SocialProperties.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/gitea/AuthGiteaRequest.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/gitea/AuthGiteaSource.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/maxkey/AuthMaxKeyRequest.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/maxkey/AuthMaxKeySource.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/topiam/AuthTopIamRequest.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/topiam/AuthTopIamSource.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/utils/AuthRedisStateCache.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/utils/SocialUtils.java create mode 100644 ruoyi-common/ruoyi-common-social/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-sse/pom.xml create mode 100644 ruoyi-common/ruoyi-common-sse/src/main/java/org/dromara/common/sse/config/SseAutoConfiguration.java create mode 100644 ruoyi-common/ruoyi-common-sse/src/main/java/org/dromara/common/sse/config/SseProperties.java create mode 100644 ruoyi-common/ruoyi-common-sse/src/main/java/org/dromara/common/sse/controller/SseController.java create mode 100644 ruoyi-common/ruoyi-common-sse/src/main/java/org/dromara/common/sse/core/SseEmitterManager.java create mode 100644 ruoyi-common/ruoyi-common-sse/src/main/java/org/dromara/common/sse/dto/SseMessageDto.java create mode 100644 ruoyi-common/ruoyi-common-sse/src/main/java/org/dromara/common/sse/listener/SseTopicListener.java create mode 100644 ruoyi-common/ruoyi-common-sse/src/main/java/org/dromara/common/sse/utils/SseMessageUtils.java create mode 100644 ruoyi-common/ruoyi-common-sse/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-translation/pom.xml create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/annotation/Translation.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/annotation/TranslationType.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/config/TranslationConfig.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/constant/TransConstant.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/core/TranslationInterface.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/core/handler/TranslationBeanSerializerModifier.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/core/handler/TranslationHandler.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/core/impl/DeptNameTranslationImpl.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/core/impl/DictTypeTranslationImpl.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/core/impl/NicknameTranslationImpl.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/core/impl/OssUrlTranslationImpl.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/java/org/dromara/common/translation/core/impl/UserNameTranslationImpl.java create mode 100644 ruoyi-common/ruoyi-common-translation/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-web/pom.xml create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/CaptchaConfig.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/FilterConfig.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/I18nConfig.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/ResourcesConfig.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/UndertowConfig.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/properties/CaptchaProperties.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/properties/XssProperties.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/core/BaseController.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/core/I18nLocaleResolver.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/enums/CaptchaCategory.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/enums/CaptchaType.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/filter/RepeatableFilter.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/filter/RepeatedlyRequestWrapper.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/filter/XssFilter.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/filter/XssHttpServletRequestWrapper.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/interceptor/PlusWebInvokeTimeInterceptor.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/utils/UnsignedMathGenerator.java create mode 100644 ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-common/ruoyi-common-websocket/pom.xml create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/config/WebSocketConfig.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/config/properties/WebSocketProperties.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/constant/WebSocketConstants.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/dto/WebSocketMessageDto.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/handler/PlusWebSocketHandler.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/holder/WebSocketSessionHolder.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/interceptor/PlusWebSocketInterceptor.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/listener/WebSocketTopicListener.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/java/org/dromara/common/websocket/utils/WebSocketUtils.java create mode 100644 ruoyi-common/ruoyi-common-websocket/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ruoyi-extend/pom.xml create mode 100644 ruoyi-extend/ruoyi-monitor-admin/Dockerfile create mode 100644 ruoyi-extend/ruoyi-monitor-admin/pom.xml create mode 100644 ruoyi-extend/ruoyi-monitor-admin/src/main/java/org/dromara/monitor/admin/MonitorAdminApplication.java create mode 100644 ruoyi-extend/ruoyi-monitor-admin/src/main/java/org/dromara/monitor/admin/config/AdminServerConfig.java create mode 100644 ruoyi-extend/ruoyi-monitor-admin/src/main/java/org/dromara/monitor/admin/config/SecurityConfig.java create mode 100644 ruoyi-extend/ruoyi-monitor-admin/src/main/java/org/dromara/monitor/admin/notifier/CustomNotifier.java create mode 100644 ruoyi-extend/ruoyi-monitor-admin/src/main/resources/application.yml create mode 100644 ruoyi-extend/ruoyi-monitor-admin/src/main/resources/banner.txt create mode 100644 ruoyi-extend/ruoyi-monitor-admin/src/main/resources/logback-plus.xml create mode 100644 ruoyi-extend/ruoyi-snailjob-server/Dockerfile create mode 100644 ruoyi-extend/ruoyi-snailjob-server/pom.xml create mode 100644 ruoyi-extend/ruoyi-snailjob-server/src/main/java/com/aizuda/snailjob/server/starter/filter/ActuatorAuthFilter.java create mode 100644 ruoyi-extend/ruoyi-snailjob-server/src/main/java/com/aizuda/snailjob/server/starter/filter/SecurityConfig.java create mode 100644 ruoyi-extend/ruoyi-snailjob-server/src/main/java/org/dromara/snailjob/SnailJobServerApplication.java create mode 100644 ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml create mode 100644 ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-prod.yml create mode 100644 ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application.yml create mode 100644 ruoyi-extend/ruoyi-snailjob-server/src/main/resources/banner.txt create mode 100644 ruoyi-extend/ruoyi-snailjob-server/src/main/resources/logback-plus.xml create mode 100644 ruoyi-modules/pom.xml create mode 100644 ruoyi-modules/ruoyi-ai/MODULE_STRUCTURE.md create mode 100644 ruoyi-modules/ruoyi-ai/README.md create mode 100644 ruoyi-modules/ruoyi-ai/pom.xml create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/AiApiConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/AiAutoConfiguration.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/AiConfigurationManager.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/AiGatewayProperties.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/AiProviderRegistrar.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/AzureOpenAiProviderConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/CozePlatformConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/DifyPlatformConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/FastGptPlatformConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/OpenAiProviderConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/QwenProviderConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/config/ZhipuAiProviderConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiChatRecordController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiPaintingFavoriteController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiPaintingRecordController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiPaintingTemplateController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiProviderConfigController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiProviderConfigHistoryController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiProviderController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiRatingRecordController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiSensitiveWordController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiUsageStatsController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminAiUserQuotaController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/admin/AdminSysModelController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AiAssistantController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AiChatController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AiImageController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AiPaintingController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AppAiPaintingFavoriteController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AppAiPaintingRecordController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AppAiPaintingTemplateController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AppAiRatingRecordController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/AppAiUserQuotaController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/CozeController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/DifyController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/FastGptController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/McpController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/MultimodalController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/PptController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/QueueMetricsController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/controller/app/SimpleAiController.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiChatRecord.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiPaintingFavorite.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiPaintingRecord.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiPaintingTemplate.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiProviderConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiProviderConfigHistory.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiRatingRecord.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiSensitiveWord.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiUsageStats.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/AiUserQuota.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiChatRecordBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiPaintingFavoriteBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiPaintingRecordBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiPaintingTemplateBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiProviderConfigBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiProviderConfigHistoryBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiRatingRecordBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiSensitiveWordBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiUsageStatsBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/bo/AiUserQuotaBo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/dto/AiChatRequest.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/dto/AiChatResponse.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/dto/AiModelInfo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/enums/AiProviderType.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiChatRecordVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiPaintingFavoriteVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiPaintingRecordVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiPaintingTemplateVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiProviderConfigHistoryVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiProviderConfigVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiRatingRecordVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiSensitiveWordVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiUsageStatsVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/domain/vo/AiUserQuotaVo.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/error/AiErrorHandler.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiChatRecordMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiPaintingFavoriteMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiPaintingRecordMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiPaintingTemplateMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiProviderConfigHistoryMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiProviderConfigMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiRatingRecordMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiSensitiveWordMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiUsageStatsMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mapper/AiUserQuotaMapper.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/McpIntegrationService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/McpTool.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/McpToolRegistry.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/tools/AlgorithmComputeTool.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/tools/ApiCallTool.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/tools/DataTransformTool.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/tools/FileParseTool.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/tools/HttpFetchTool.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/mcp/tools/ProjectScaffoldTool.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/model/ApiResult.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/monitor/AiMonitorService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/monitor/BaseMetrics.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/monitor/ModelMetrics.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/monitor/ProviderMetrics.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/monitor/SystemMetrics.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/monitor/UserMetrics.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/orchestration/AiOrchestrationEngine.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/painting/PaintingEngineType.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/painting/PaintingProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/painting/PaintingStatus.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/painting/PaintingTask.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/painting/provider/DallE3Provider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/painting/provider/MidJourneyProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/painting/provider/StableDiffusionProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/platform/CozeService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/platform/DifyClientService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/platform/FastGptService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/AbstractAiProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/AiProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/AiProviderManager.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/AzureOpenAiProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/CozeProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/DifyProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/FastGptProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/OpenAiProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/QwenProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/ZhipuAiProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/provider/impl/MockAiProvider.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/queue/TaskQueueService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/security/AiAccessControlService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/security/AiContentFilterService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/security/AiSecurityManager.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiChatHistoryService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiChatRecordService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiChatService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiCodingAssistantService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiModelService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiPaintingFavoriteService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiPaintingRecordService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiPaintingService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiPaintingTemplateService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiProviderConfigHistoryService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiProviderConfigService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiRatingRecordService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiSensitiveWordService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiUsageStatsService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/AiUserQuotaService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/SimpleAiService.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiChatRecordServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiPaintingFavoriteServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiPaintingRecordServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiPaintingTemplateServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiProviderConfigHistoryServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiProviderConfigServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiRatingRecordServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiSensitiveWordServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiUsageStatsServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/service/impl/AiUserQuotaServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/websocket/ChatWebSocketConfig.java create mode 100644 ruoyi-modules/ruoyi-ai/src/main/java/org/dromara/ai/websocket/ChatWebSocketHandler.java create mode 100644 ruoyi-modules/ruoyi-ai/src/test/java/org/dromara/ai/integration/ApiGatewayIntegrationTest.java create mode 100644 ruoyi-modules/ruoyi-ai/src/test/java/org/dromara/ai/mock/MockData.java create mode 100644 ruoyi-modules/ruoyi-ai/src/test/java/org/dromara/ai/unit/ApiGatewayControllerTest.java create mode 100644 ruoyi-modules/ruoyi-chat/README.md create mode 100644 ruoyi-modules/ruoyi-chat/pom.xml create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/client/ChatSdk.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/config/StompWebSocketConfig.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/controller/AIKnowledgeChatController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/controller/ChatConfigController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/controller/ChatMessageController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/controller/ChatModelController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/controller/ChatOpenApiController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/controller/ChatSessionController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/controller/ChatStompController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/controller/ChatUsageTokenController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/ChatConfig.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/ChatMessage.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/ChatModel.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/ChatSession.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/ChatUsageToken.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/bo/ChatConfigBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/bo/ChatMessageBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/bo/ChatModelBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/bo/ChatSessionBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/bo/ChatUsageTokenBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/request/ChatRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/request/MultiProviderChatRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/request/RateRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/vo/ChatConfigVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/vo/ChatMessageVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/vo/ChatModelVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/vo/ChatSessionVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/domain/vo/ChatUsageTokenVo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/mapper/ChatConfigMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/mapper/ChatMessageMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/mapper/ChatModelMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/mapper/ChatSessionMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/mapper/ChatUsageTokenMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/AiKnowledgeChatService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/ChatConfigService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/ChatMessageService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/ChatModelService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/ChatSessionService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/ChatUsageTokenService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/impl/AiKnowledgeChatServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/impl/ChatConfigServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/impl/ChatMessageServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/impl/ChatModelServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/impl/ChatSessionServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/dromara/chat/service/impl/ChatUsageTokenServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/dromara/chat/service/ChatSessionServiceTest.java create mode 100644 ruoyi-modules/ruoyi-face/README.md create mode 100644 ruoyi-modules/ruoyi-face/pom.xml create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/client/ChatSdk.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/config/StompWebSocketConfig.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/controller/AIKnowledgeChatController.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/controller/ChatConfigController.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/controller/ChatMessageController.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/controller/ChatModelController.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/controller/ChatOpenApiController.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/controller/ChatSessionController.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/controller/ChatStompController.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/controller/ChatUsageTokenController.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/ChatConfig.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/ChatMessage.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/ChatModel.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/ChatSession.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/ChatUsageToken.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/bo/ChatConfigBo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/bo/ChatMessageBo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/bo/ChatModelBo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/bo/ChatSessionBo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/bo/ChatUsageTokenBo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/request/ChatRequest.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/request/MultiProviderChatRequest.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/request/RateRequest.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/vo/ChatConfigVo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/vo/ChatMessageVo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/vo/ChatModelVo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/vo/ChatSessionVo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/domain/vo/ChatUsageTokenVo.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/mapper/ChatConfigMapper.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/mapper/ChatMessageMapper.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/mapper/ChatModelMapper.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/mapper/ChatSessionMapper.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/mapper/ChatUsageTokenMapper.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/AiKnowledgeChatService.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/ChatConfigService.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/ChatMessageService.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/ChatModelService.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/ChatSessionService.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/ChatUsageTokenService.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/impl/AiKnowledgeChatServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/impl/ChatConfigServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/impl/ChatMessageServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/impl/ChatModelServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/impl/ChatSessionServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-face/src/main/java/org/dromara/chat/service/impl/ChatUsageTokenServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-face/src/test/java/org/dromara/chat/service/ChatSessionServiceTest.java create mode 100644 ruoyi-modules/ruoyi-generator/pom.xml create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/config/GenConfig.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/config/MyBatisDataSourceMonitor.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/constant/GenConstants.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/controller/GenController.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/domain/GenTable.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/domain/GenTableColumn.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/mapper/GenTableColumnMapper.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/mapper/GenTableMapper.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/service/GenTableServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/service/IGenTableService.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/util/GenUtils.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/util/VelocityInitializer.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/java/org/dromara/generator/util/VelocityUtils.java create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/generator.yml create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/mapper/generator/GenTableColumnMapper.xml create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/mapper/generator/GenTableMapper.xml create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/mapper/package-info.md create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/bo.java.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/controller.java.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/domain.java.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/mapper.java.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/service.java.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/serviceImpl.java.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/vo.java.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/oracle/sql.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/postgres/sql.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/sql.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/sqlserver/sql.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/ts/api.ts.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/ts/types.ts.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/vue/index-tree.vue.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/vue/index.vue.vm create mode 100644 ruoyi-modules/ruoyi-generator/src/main/resources/vm/xml/mapper.xml.vm create mode 100644 ruoyi-modules/ruoyi-job/pom.xml create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/entity/BillDto.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/package-info.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/AlipayBillTask.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/SummaryBillTask.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestAnnoJobExecutor.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestBroadcastJob.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestClassJobExecutor.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestMapJobAnnotation.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestMapReduceAnnotation1.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestStaticShardingJob.java create mode 100644 ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/WechatBillTask.java create mode 100644 ruoyi-modules/ruoyi-knowledge/README.md create mode 100644 ruoyi-modules/ruoyi-knowledge/pom.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/config/RagConfigProperties.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/config/RagConfiguration.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeAttachController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeFragmentController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeHitLogController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeInfoController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeJobController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgePromptTemplateController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaFieldController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaGroupController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeVectorController.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/CsvDocumentParser.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DataPipeline.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DocumentParser.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DocumentParserFactory.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/ExcelDocumentParser.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/HtmlDocumentParser.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/MarkdownDocumentParser.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/PdfDocumentParser.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/TextDocumentParser.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/WordDocumentParser.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeAttach.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeFragment.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeHitLog.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeInfo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeJob.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgePromptTemplate.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRole.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRoleGroup.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRoleRelation.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchema.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchemaField.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchemaGroup.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeVector.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeAttachBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeFragmentBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeHitLogBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeInfoBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeJobBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgePromptTemplateBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleGroupBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleRelationBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaFieldBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaGroupBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeVectorBo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeAttachVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeFragmentVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeHitLogVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeInfoVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeJobVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgePromptTemplateVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleGroupVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleRelationVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaFieldVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaGroupVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSearchResultVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeStatusVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeVectorVo.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/EmbeddingEngine.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/EmbeddingService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/HttpEmbeddingService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/generation/DefaultGenerator.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/generation/Generator.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeAttachMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeFragmentMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeHitLogMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeInfoMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeJobMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgePromptTemplateMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleGroupMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleRelationMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaFieldMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaGroupMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeVectorMapper.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/DefaultIndexUpdater.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/DefaultRetriever.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/IndexUpdater.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/Retriever.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeAttachService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeDocumentService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeFragmentService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeHitLogService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeInfoService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeJobService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgePromptTemplateService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleGroupService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleRelationService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaFieldService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaGroupService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeVectorService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeAttachServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeDocumentServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeFragmentServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeHitLogServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeInfoServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeJobServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgePromptTemplateServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleGroupServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleRelationServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaFieldServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaGroupServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeVectorServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/DbVectorStoreService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/QdrantVectorStoreService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/VectorStoreManager.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/VectorStoreService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/WeaviateVectorStoreService.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/version/DefaultVersionManager.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/version/VersionManager.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeAttachMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeFragmentMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeHitLogMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeInfoMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeJobMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgePromptTemplateMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleGroupMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleRelationMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaFieldMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaGroupMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeVectorMapper.xml create mode 100644 ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/integration/RagIntegrationTest.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/mock/MockDocs.java create mode 100644 ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/unit/RagContractsTest.java create mode 100644 ruoyi-modules/ruoyi-mall/MODULE_STRUCTURE.md create mode 100644 ruoyi-modules/ruoyi-mall/README.md create mode 100644 ruoyi-modules/ruoyi-mall/pom.xml create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/README.md create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/pom.xml create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/config/MallCouponAutoConfiguration.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/admin/AdminMallCouponController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/admin/AdminMallCouponUserController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/app/AppMallCouponController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/app/AppMallCouponUserController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/MallCoupon.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/MallCouponUser.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/bo/MallCouponBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/bo/MallCouponUserBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/CouponStatisticsVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/MallCouponUserVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/MallCouponVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/job/MallCouponJob.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/mapper/MallCouponMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/mapper/MallCouponUserMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/MallCouponService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/MallCouponUserService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/impl/MallCouponServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/impl/MallCouponUserServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/resources/META-INF/spring.factories create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/README.md create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/pom.xml create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/config/MallProductAutoConfiguration.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeTemplateController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeValueController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallBrandController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallCategoryController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallProductAttributeController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallProductController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallBrandController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallCategoryController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallProductController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttribute.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttributeTemplate.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttributeValue.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallBrand.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallCategory.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallProduct.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallProductAttribute.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallSku.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeTemplateBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeValueBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallBrandBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallCategoryBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallProductAttributeBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallProductBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallSkuBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeTemplateVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeValueVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallBrandVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallCategoryVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallProductAttributeVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallProductVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallSkuVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/enums/AttributeType.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeTemplateMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeValueMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallBrandMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallCategoryMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallProductAttributeMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallProductMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallSkuMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeTemplateService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeValueService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallBrandService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallCategoryService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallProductAttributeService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallProductService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallSkuService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeTemplateServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeValueServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallBrandServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallCategoryServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallProductAttributeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallProductServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallSkuServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/resources/META-INF/spring.factories create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/README.md create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/pom.xml create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/config/MallPromotionAutoConfiguration.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallBargainController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallGroupBuyController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallPromotionController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallSeckillController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallBargainController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallGroupBuyController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallPromotionController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallSeckillController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallBargain.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallGroupBuy.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallGroupBuyOrder.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallPromotion.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallSeckill.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallBargainBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallGroupBuyBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallPromotionBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallSeckillBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallBargainVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallGroupBuyVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallPromotionVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallSeckillVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/enums/PromotionType.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/job/MallPromotionJob.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallBargainMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallGroupBuyMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallPromotionMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallSeckillMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallBargainService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallGroupBuyService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallPromotionService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallSeckillService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallBargainServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallGroupBuyServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallPromotionServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallSeckillServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/resources/META-INF/spring.factories create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/resources/mapper/promotion/MallSeckillMapper.xml create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/README.md create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/pom.xml create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/config/MallStatisticsAutoConfiguration.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/controller/admin/AdminMallStatisticsController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/bo/StatisticsQueryBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/vo/MemberStatisticsVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/vo/TransactionStatisticsVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/mapper/MallStatisticsMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/service/MallStatisticsService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/service/impl/MallStatisticsServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/resources/META-INF/spring.factories create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/resources/mapper/statistics/MallStatisticsMapper.xml create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/README.md create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/pom.xml create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/config/MallTradeAutoConfiguration.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallAddressController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallAfterSaleController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallOrderEvaluateController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallAddressController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallAfterSaleController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallOrderEvaluateController.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallAddress.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallAfterSale.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallOrderCoupon.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallOrderEvaluate.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallAddressBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallAfterSaleBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallOrderCouponBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallOrderEvaluateBo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallAddressVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallAfterSaleVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallOrderCouponVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallOrderEvaluateVo.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallAddressMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallAfterSaleMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallOrderCouponMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallOrderEvaluateMapper.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallAddressService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallAfterSaleService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallOrderCouponService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallOrderEvaluateService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/SeckillOrderService.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallAddressServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallAfterSaleServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallOrderCouponServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallOrderEvaluateServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/SeckillOrderServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/utils/CouponCalculator.java create mode 100644 ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/resources/META-INF/spring.factories create mode 100644 ruoyi-modules/ruoyi-member/MODULE_STRUCTURE.md create mode 100644 ruoyi-modules/ruoyi-member/pom.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/config/AppJwtFilter.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/config/AppJwtWebConfig.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/AppFileController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/AppJwtController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminConfigController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminRoleController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminUserController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppDeviceController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppMessageController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppOperLogController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppRoleController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppUserController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppVersionController.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppDevice.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppFile.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppLoginLog.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppMessage.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppOperLog.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppRole.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppSocialAuth.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUser.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUserMessage.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUserRole.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppVersion.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppDeviceBo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppFileBo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppMessageBo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppOperLogBo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppRoleBo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppUserBo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppVersionBo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppDeviceVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppFileVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppLoginLogVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppMessageVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppOperLogVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppRoleVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppSocialAuthVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserMessageVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserRoleVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppVersionVo.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/AppExceptionHandler.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/DeviceException.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/FileException.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/VersionException.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppDeviceMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppFileMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppLoginLogMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppMessageMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppOperLogMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppRoleMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppSocialAuthMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserMessageMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserRoleMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppVersionMapper.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppDeviceService.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppFileService.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppMessageService.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppOperLogService.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppRoleService.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppSocialAuthService.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppUserService.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppVersionService.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppDeviceServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppFileServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppMessageServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppOperLogServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppRoleServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppSocialAuthServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppUserServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppVersionServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/utils/JwtUtil.java create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppDeviceMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppFileMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppLoginLogMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppMessageMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppOperLogMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppRoleMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppSocialAuthMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserMessageMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserRoleMapper.xml create mode 100644 ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppVersionMapper.xml create mode 100644 ruoyi-modules/ruoyi-mp/pom.xml create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/annotation/SwitchoverCheck.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/aspect/SwitchoverCheckAspect.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpAbstractBuilder.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpImageBuilderWxMp.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpTextBuilderWxMp.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpConfiguration.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpProperties.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpJsapiController.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpMediaController.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpMenuController.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpOAuth2Controller.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpOauth2RedirectController.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpPortalController.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpQrCodeController.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpTemplateController.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpUserController.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/AbstractHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/KfSessionHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/LocationHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/LogHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/MenuHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/MsgHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/NullHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/ScanHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/StoreCheckNotifyHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/SubscribeHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/UnsubscribeHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/UserAuthorizationRevokeHandler.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMaterialService.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMenuService.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMessageService.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpQrCodeService.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpStatisticsService.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpTemplateService.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpUserService.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMaterialServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMenuServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMessageServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpQrCodeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpStatisticsServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpTemplateServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpUserServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/utils/WxMpUtils.java create mode 100644 ruoyi-modules/ruoyi-pay/README.md create mode 100644 ruoyi-modules/ruoyi-pay/pom.xml create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/config/PayProperties.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/PayNotifyController.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayAppController.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayChannelController.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayOrderController.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayRefundController.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/app/AppPayOrderController.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/app/AppPayRefundController.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/PayStrategy.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/PayStrategyFactory.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/AlipayStrategy.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/WechatPayStrategy.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/WechatPayUtil.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayApp.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayChannel.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayNotifyTask.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayOrder.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayRefund.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayAppBo.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayChannelBo.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayOrderBo.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayRefundBo.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayAppVo.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayChannelVo.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayNotifyResult.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayOrderSubmitRespVO.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayOrderVo.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayRefundVo.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/PayChannelEnum.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/PayOrderStatusEnum.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/RefundStatusEnum.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/event/PaySuccessEvent.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayNotifyJob.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayOrderExpireJob.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayOrderSyncJob.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/listener/PayOrderListener.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayAppMapper.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayChannelMapper.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayNotifyTaskMapper.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayOrderMapper.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayRefundMapper.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayAppService.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayChannelService.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayNotifyService.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayOrderService.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayRefundService.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayAppServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayChannelServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayOrderServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayRefundServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/util/WechatPayUtil.java create mode 100644 ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayAppMapper.xml create mode 100644 ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayChannelMapper.xml create mode 100644 ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayOrderMapper.xml create mode 100644 ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayRefundMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/pom.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/CacheController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysLogininforController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysOperlogController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysUserOnlineController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysClientController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysConfigController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDeptController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDictDataController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDictTypeController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysMenuController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysNoticeController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssConfigController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysPostController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysProfileController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysRoleController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysSocialController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysUserController.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysCache.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysClient.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysConfig.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDept.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDictData.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDictType.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysLogininfor.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysMenu.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysNotice.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOperLog.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOss.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOssConfig.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysPost.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRole.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRoleDept.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRoleMenu.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysSocial.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUser.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserOnline.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserPost.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserRole.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysClientBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysConfigBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDeptBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDictDataBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDictTypeBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysLogininforBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysMenuBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysNoticeBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOperLogBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOssBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOssConfigBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysPostBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysRoleBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysSocialBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserPasswordBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserProfileBo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/MetaVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/RouterVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysClientVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysConfigVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDeptVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDictDataVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDictTypeVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysLogininforVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysMenuVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysNoticeVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOperLogVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssConfigVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssUploadVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysPostVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysRoleVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysSocialVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserExportVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserImportVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserInfoVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/UserInfoVo.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/listener/SysUserImportListener.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysClientMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysConfigMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDeptMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDictDataMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDictTypeMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysLogininforMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysMenuMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysNoticeMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOperLogMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOssConfigMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOssMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysPostMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleDeptMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleMenuMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysSocialMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserPostMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserRoleMapper.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/runner/SystemApplicationRunner.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysClientService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysConfigService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDataScopeService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDeptService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDictDataService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDictTypeService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysLogininforService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysMenuService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysNoticeService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOperLogService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOssConfigService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOssService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysPermissionService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysPostService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysRoleService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysSocialService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysUserService.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysClientServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysConfigServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDataScopeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDeptServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDictDataServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDictTypeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysLogininforServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysMenuServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysNoticeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOperLogServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssConfigServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPermissionServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPostServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysRoleServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysSensitiveServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysSocialServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysTaskAssigneeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/package-info.md create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysClientMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysConfigMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDictDataMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDictTypeMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysLogininforMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysMenuMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysNoticeMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOssConfigMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOssMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysPostMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleDeptMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleMenuMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysSocialMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserPostMapper.xml create mode 100644 ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserRoleMapper.xml create mode 100644 ruoyi-modules/ruoyi-workflow/pom.xml create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/ConditionalOnEnable.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/constant/FlowConstant.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/ButtonPermissionEnum.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/MessageTypeEnum.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/NodeExtEnum.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskAssigneeEnum.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskAssigneeType.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskStatusEnum.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/config/WarmFlowConfig.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwCategoryController.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwDefinitionController.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwInstanceController.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwTaskController.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/TestLeaveController.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/FlowCategory.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/TestLeave.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/BackProcessBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/CompleteTaskBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCancelBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCategoryBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCopyBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowInstanceBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowInvalidBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowNextNodeBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowTaskBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowTerminationBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/StartProcessBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/TaskOperationBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/TestLeaveBo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/ButtonPermissionVo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowCategoryVo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowDefinitionVo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowHisTaskVo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowInstanceVo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowTaskVo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/TestLeaveVo.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/handler/FlowProcessEventHandler.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/handler/WorkflowPermissionHandler.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/listener/WorkflowGlobalListener.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwCategoryMapper.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwInstanceMapper.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwTaskMapper.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/TestLeaveMapper.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwCategoryService.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwCommonService.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwDefinitionService.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwInstanceService.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwNodeExtService.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwTaskAssigneeService.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwTaskService.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/ITestLeaveService.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/CategoryNameTranslationImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwCategoryServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwChartExtServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwCommonServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwDefinitionServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwInstanceServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwNodeExtServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskAssigneeServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/TestLeaveServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/WorkflowServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/package-info.md create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwCategoryMapper.xml create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwInstanceMapper.xml create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwTaskMapper.xml create mode 100644 ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/TestLeaveMapper.xml create mode 100644 ruoyi-plugins/.DS_Store create mode 100644 ruoyi-server/Dockerfile create mode 100644 ruoyi-server/docs/LOGIN_COMPREHENSIVE_GUIDE.md create mode 100644 ruoyi-server/pom.xml create mode 100644 ruoyi-server/src/main/java/org/dromara/PoJieApplication.java create mode 100644 ruoyi-server/src/main/java/org/dromara/PoJieServletInitializer.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/controller/AppSessionController.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/domain/AppLoginUser.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/domain/vo/AppConfigVo.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/listener/AppUserActionListener.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/AppLoginRiskService.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/AppLoginService.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/IAppAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/impl/AppEmailAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/impl/AppPasswordAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSmsAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSocialAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMiniprogramAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMpAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatOpenAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/session/AppSession.java create mode 100644 ruoyi-server/src/main/java/org/dromara/app/session/AppSessionManager.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/config/AppInfoCompletionFilter.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/config/RiskInterceptor.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/config/WebMvcPlusConfig.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/config/properties/RiskControlProperties.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/controller/CaptchaController.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/controller/IndexController.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/domain/vo/CaptchaVo.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/domain/vo/LoginVo.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/listener/UserActionListener.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/service/IAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/service/RiskControlService.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/service/SysLoginService.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/service/SysRegisterService.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/service/impl/EmailAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java create mode 100644 ruoyi-server/src/main/java/org/dromara/web/service/impl/SocialAuthStrategy.java create mode 100644 ruoyi-server/src/main/resources/.DS_Store create mode 100644 ruoyi-server/src/main/resources/application-ai.yml create mode 100644 ruoyi-server/src/main/resources/application-dev.yml create mode 100644 ruoyi-server/src/main/resources/application-prod.yml create mode 100644 ruoyi-server/src/main/resources/application.yml create mode 100644 ruoyi-server/src/main/resources/banner.txt create mode 100644 ruoyi-server/src/main/resources/i18n/messages.properties create mode 100644 ruoyi-server/src/main/resources/i18n/messages_en_US.properties create mode 100644 ruoyi-server/src/main/resources/i18n/messages_zh_CN.properties create mode 100644 ruoyi-server/src/main/resources/ip2region.xdb create mode 100644 ruoyi-server/src/main/resources/logback-plus.xml create mode 100644 ruoyi-server/src/test/java/org/dromara/app/session/AppSessionManagerTest.java create mode 100644 ruoyi-server/src/test/java/org/dromara/test/AssertUnitTest.java create mode 100644 ruoyi-server/src/test/java/org/dromara/test/DemoUnitTest.java create mode 100644 ruoyi-server/src/test/java/org/dromara/test/ParamUnitTest.java create mode 100644 ruoyi-server/src/test/java/org/dromara/test/TagUnitTest.java create mode 100644 script/bin/ry.bat create mode 100644 script/bin/ry.sh create mode 100644 script/docker/database.yml create mode 100644 script/docker/docker-compose.yml create mode 100644 script/docker/nginx/conf/nginx.conf create mode 100644 script/docker/redis/conf/redis.conf create mode 100644 script/docker/redis/data/README.md create mode 100644 script/leave/leave1.json create mode 100644 script/leave/leave2.json create mode 100644 script/leave/leave3.json create mode 100644 script/leave/leave4.json create mode 100644 script/leave/leave5.json create mode 100644 script/leave/leave6.json create mode 100644 script/sql/oracle/oracle_ry_app_5.X.sql create mode 100644 script/sql/oracle/oracle_ry_job.sql create mode 100644 script/sql/oracle/oracle_ry_vue_5.X.sql create mode 100644 script/sql/oracle/oracle_ry_workflow.sql create mode 100644 script/sql/postgres/postgres_ry_app_5.X.sql create mode 100644 script/sql/postgres/postgres_ry_job.sql create mode 100644 script/sql/postgres/postgres_ry_vue_5.X.sql create mode 100644 script/sql/postgres/postgres_ry_workflow.sql create mode 100644 script/sql/ry_ai.sql create mode 100644 script/sql/ry_app.sql create mode 100644 script/sql/ry_chat.sql create mode 100644 script/sql/ry_job.sql create mode 100644 script/sql/ry_knowledge.sql create mode 100644 script/sql/ry_mall.sql create mode 100644 script/sql/ry_pay.sql create mode 100644 script/sql/ry_update.sql create mode 100644 script/sql/ry_vue.sql create mode 100644 script/sql/ry_workflow.sql create mode 100644 script/sql/sqlserver/sqlserver_ry_app_5.X.sql create mode 100644 script/sql/sqlserver/sqlserver_ry_job.sql create mode 100644 script/sql/sqlserver/sqlserver_ry_vue_5.X.sql create mode 100644 script/sql/sqlserver/sqlserver_ry_workflow.sql diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c328ada19fdd71accccf25e47b0988892cb287df GIT binary patch literal 10244 zcmeHMU2GiH6+UP3NA!5C@AtrI0dhK}aIN9v(Oq?OJ zGt12EI>rQ9NW1_6!3ziof+zmkJOpSFJhW1qHm%f`Hm#JZKT@hz1s;L~FZ@XK+n&H85WT3@&wW0Q#W^`YP}5PL_IDZ-SnVuXv&ogSrI{ zOoAuw`Ez_1YWV7)Kzw=VfYX#))!f)x!ySPkn!g)*VSs1{N$52a&(ugL;6jmRmlN_EU(m)wb*_ZC9?&~^!(1wzo4{e@sg!=a%9b&T+qG8h2RSY_z*sd zkKyBZ7N5jt@f^N{FXJot2429o@E!aq{v0piZ}E5dN4$#v#DC$x@fv=PU*MP05^0Up zEUlGVr8cQu>X3FyyQDj%J<>sGP)bP|>3+$P3S#|I;Gf}Wu%CcP(^OapdIbSJ>HOD2 zujSr-{r4#czBkajvXX#RmkBO;5JfYzn3{G zmD|>rVR#*w0;nSd9^V9>psJ66s$OKeX;#H+(+x7^- zQC$>N+jmkGMMc?jSA>A6E)J@<#i zfSqLP3EYPVxwR*81dm}Fk7E{f%%hDCK0tQ=5T3z@aTXsTtAB#*{waJKpT`&QMY8=@ z@ilxM-^8Dg<^K%d#rN=+_$$1KzX>q#S3%%2LtR{M<=<&Tj{V?R5?24;&gI+7RlDSVCJfST;Zy#B?}}|B*$&ioVAk7dH;`+ z*(oo;D!g09>YvMQvQAzuA5D@U6bD}-;~rtPVaKtu<#Hx_n7m_JZR~MuKV#CF%LT|M z5zE9VnXNOGaEi9>aoMEkkRx&=tv1ihbZy_=7Ts~j?lW!Cncdqu+oD}R>^gHMA~(gh z?ios-vhud`8104V2G_{-)uD>5xCOg#FWyZ@KtGOSj=MSr97=O8NE7vPjvkd3c#mAvJpg*4PasDV%eH&+9T#B@>i{lD+V|Nn39 zqQcoi4TKuFsT#n=y~$p7cPPipwX?HNXi<8O)58+un-!FcP{vqTJO3O{74|uvM}gE< n{ + +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://gitee.com/dromara/RuoYi-Vue-Plus/blob/5.X/LICENSE) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.4.7-brightgreen.svg)](https://spring.io/projects/spring-boot) +[![JDK-17](https://img.shields.io/badge/JDK-17-green.svg)](https://openjdk.org/projects/jdk/17/) +[![JDK-21](https://img.shields.io/badge/JDK-21-green.svg)](https://openjdk.org/projects/jdk/21/) +[![MyBatis-Plus](https://img.shields.io/badge/MyBatis--Plus-3.5.12-blue.svg)](https://baomidou.com/) +[![Sa-Token](https://img.shields.io/badge/Sa--Token-1.44.0-orange.svg)](https://sa-token.cc/) +[![LangChain4j](https://img.shields.io/badge/LangChain4j-0.36.2-purple.svg)](https://github.com/langchain4j/langchain4j) +[![Spring AI](https://img.shields.io/badge/Spring%20AI-1.0.0--M5-green.svg)](https://spring.io/projects/spring-ai) + + +*集成多种AI框架,提供统一的AI服务管理平台* + +[在线预览](#) · [使用文档](#) · [更新日志](#) + + + +## 📖 平台简介 + +**PoJie** 是一款基于 **Spring Boot 3.4.7** + **JDK 17/21** 的AI增强企业级快速开发平台,在 RuoYi-Vue-Plus 的基础上深度集成了多种AI框架和服务,打造统一的AI服务管理平台。 + +### 🎯 核心特色 + +- 🤖 **统一AI服务**:集成Spring AI、LangChain4j等主流AI框架,提供统一的AI服务接口 +- 🔌 **多Provider支持**:支持OpenAI、ChatGLM、通义千问、智谱AI等多种AI服务提供商 +- 💬 **智能对话**:支持单轮对话、多轮对话、流式响应和记忆管理 +- 📚 **知识库集成**:内置知识库服务,支持RAG(检索增强生成) +- ⚖️ **智能编排**:多Provider负载均衡、故障转移和智能路由 +- 🛡️ **高可用性**:熔断器保护、限流控制、健康检查和自动重试 +- 📊 **AI监控**:实时性能监控、使用统计分析、成本追踪和质量评估 +- 🔐 **权限管理**:使用Sa-Token实现权限控制,支持多种认证方式 +- 🔗 **第三方登录**:集成JustAuth支持微信、钉钉等多种第三方认证 +- 💾 **数据库支持**:原生支持MySQL、Oracle、PostgreSQL、SQLServer等主流数据库 +- 🚀 **缓存优化**:使用Redisson作为Redis客户端,提供更丰富的分布式功能 +- 📈 **监控体系**:集成Spring Boot Admin,实现全方位监控 +- 🛠️ **代码生成**:提供智能化代码生成工具,支持多数据源 +- 💰 **支付集成**:内置支付宝、微信支付等主流支付方式 + +## 功能特性 + +### AI核心模块 +- **统一AI服务**:作为系统的AI大脑,统一管理和编排所有AI Provider +- **多框架集成**:支持Spring AI、LangChain4j等主流AI框架 +- **智能对话**:单轮对话、多轮对话、流式响应、记忆管理 +- **知识库服务**:内置知识库,支持RAG检索增强生成 +- **AI Provider管理**:支持OpenAI、ChatGLM、通义千问、智谱AI等多种服务商 +- **智能编排**:负载均衡、故障转移、智能路由、并行处理 +- **AI监控**:实时性能监控、使用统计、成本追踪、质量评估 + +### 系统核心模块 +- **系统管理(ruoyi-system)**:用户、角色、菜单、部门、字典等基础管理功能 +- **移动端模块(ruoyi-app)**:支持Web和移动端用户统一管理,提供移动端专用API +- **权限控制**:基于Sa-Token的多维度权限管理体系,支持RBAC权限模型 +- **工作流(ruoyi-workflow)**:支持复杂的审批流程管理(Warm-Flow工作流引擎) +- **文件存储**:集成MinIO实现分布式文件存储,支持多种OSS存储方式 +- **消息推送**:支持WebSocket和SSE实时消息推送 +- **聊天系统(ruoyi-chat)**:完整的聊天功能,支持AI助手集成、会话管理、消息历史 + +### 业务模块 +- **商城模块(ruoyi-mall)**:完整的电商业务模块 + - 商品中心:商品、SKU、分类、品牌、属性管理 + - 交易中心:购物车、订单、售后管理 + - 营销中心:活动、拼团、秒杀、砍价 + - 优惠券中心:优惠券发放、核销、过期处理 + - 统计中心:交易分析、会员分析 +- **支付模块(ruoyi-pay)**:集成支付宝、微信支付等主流支付方式 +- **微信公众号(ruoyi-mp)**:微信公众号开发支持 + +### 开发工具 +- **代码生成器**:根据表结构一键生成CRUD代码 +- **Excel处理**:基于FastExcel实现高效的Excel操作 +- **日志监控**:完整的操作日志和登录日志记录 +- **接口文档**:使用SpringDoc自动生成API文档 +- **AI开发工具**:LangChain4j集成工具、AI服务测试工具 + +### 安全特性 +- **数据脱敏**:敏感信息自动脱敏处理 +- **数据加密**:支持多种加密算法保障数据安全 +- **接口安全**:动态AES+RSA加密传输 +- **防重提交**:分布式环境下防重复提交 +- **XSS防护**:内置跨站脚本攻击防护 +- **AI安全**:API密钥安全管理、AI服务访问控制 + +### 运维支持 +- **多数据源管理**:支持动态切换不同数据库 +- **邮件服务**:集成邮件发送功能 +- **短信服务**:支持多种短信平台 +- **定时任务**:分布式任务调度系统(SnailJob) +- **系统监控**:实时监控服务状态和性能指标 +- **AI运维**:AI服务健康检查、性能监控、故障告警 + +## 🏗️ 技术架构 + +### 核心技术栈 + +| 分类 | 技术 | 版本 | 说明 | +|------|------|------|------| +| **基础框架** | Spring Boot | 3.4.7 | 核心框架 | +| **开发环境** | JDK | 17/21 | Java开发环境 | +| **Web容器** | Undertow | - | 高性能Web容器 | +| **权限认证** | Sa-Token | 1.44.0 | 轻量级权限框架 | +| **ORM框架** | MyBatis-Plus | 3.5.12 | 持久层框架 | +| **缓存中间件** | Redisson | 3.50.0 | Redis客户端 | +| **数据库连接池** | HikariCP | - | 高性能连接池 | +| **工具类库** | Hutool | 5.8.38 | Java工具包 | +| **API文档** | SpringDoc | 2.8.8 | OpenAPI 3.0 | +| **Excel处理** | FastExcel | 1.2.0 | 高性能Excel | +| **第三方登录** | JustAuth | 1.16.7 | 第三方认证 | +| **工作流引擎** | Warm-Flow | 1.7.4 | 国产工作流 | +| **任务调度** | SnailJob | 1.5.0 | 分布式任务 | +| **对象映射** | MapStruct Plus | 1.4.8 | 对象转换 | +| **监控管理** | Spring Boot Admin | 3.4.7 | 应用监控 | + +### AI技术栈 + +| 分类 | 技术 | 版本 | 说明 | +|------|------|------|------| +| **AI框架** | Spring AI | 1.0.0-M5 | Spring官方AI框架 | +| **AI框架** | LangChain4j | 0.36.2 | Java版LangChain | +| **AI服务** | OpenAI | - | GPT系列模型 | +| **AI服务** | Azure OpenAI | - | Azure托管OpenAI | +| **AI服务** | ChatGLM | - | 智谱AI对话模型 | +| **AI服务** | 通义千问 | - | 阿里云大语言模型 | +| **AI服务** | 智谱AI | - | 智谱AI平台 | +| **AI平台** | Coze | 0.4.2 | 字节跳动AI Bot | +| **AI平台** | Dify | 1.1.7 | 开源LLM应用平台 | +| **HTTP客户端** | OkHttp | 4.12.0 | AI服务HTTP客户端 | + +### 数据库支持 + +| 数据库 | 支持情况 | 说明 | +|--------|----------|------| +| MySQL | ✅ 原生支持 | 主要推荐 | +| Oracle | ✅ 原生支持 | 企业级 | +| PostgreSQL | ✅ 原生支持 | 开源数据库 | +| SQL Server | ✅ 原生支持 | 微软数据库 | +| 达梦数据库 | ✅ 支持 | 国产数据库 | +| 金仓数据库 | ✅ 支持 | 国产数据库 | + +## 🚀 快速开始 + +### 环境要求 + +- **JDK**: 17+ (推荐 JDK 21) **⚠️ 必须使用JDK 17+** +- **Maven**: 3.6+ +- **MySQL**: 5.7+ / 8.0+ +- **Redis**: 5.0+ +- **Node.js**: 16+ (前端开发) +- **AI服务**: OpenAI API Key 或其他AI服务商密钥 + +### 安装步骤 + +#### 1. 克隆项目 + +```bash +# 克隆仓库(示例) +git clone https://gitee.com/pengles/PoJie.git +cd PoJie +``` + +#### 2. 数据库初始化 + +**方式一:使用 MySQL 命令行** + +```sql +-- 1. 创建数据库 +CREATE DATABASE ruoyi_vue_plus DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +USE ruoyi_vue_plus; + +-- 2. 导入数据库脚本(按顺序执行,路径相对于项目根目录) +SOURCE script/sql/ry_vue.sql; -- 基础脚本(必须) +SOURCE script/sql/ry_ai.sql; -- AI模块脚本(如使用AI功能) +SOURCE script/sql/ry_chat.sql; -- 聊天模块脚本(如使用聊天功能) +SOURCE script/sql/ry_knowledge.sql; -- 知识库脚本(如使用知识库功能) +SOURCE script/sql/ry_workflow.sql; -- 工作流脚本(如使用工作流功能) +SOURCE script/sql/ry_mall.sql; -- 商城脚本(如使用商城功能) +SOURCE script/sql/ry_pay.sql; -- 支付脚本(如使用支付功能) +SOURCE script/sql/ry_app.sql; -- 移动端脚本(如使用移动端功能) +SOURCE script/sql/ry_job.sql; -- 定时任务脚本(如使用定时任务) +``` + +**方式二:使用图形化工具(Navicat、DBeaver、DataGrip 等)** + +1. 创建数据库 `ruoyi_vue_plus`,字符集选择 `utf8mb4`,排序规则选择 `utf8mb4_general_ci` +2. 按顺序执行 `script/sql/` 目录下的 SQL 文件: + - `ry_vue.sql`(必须,基础脚本) + - `ry_ai.sql`(如使用 AI 功能) + - `ry_chat.sql`(如使用聊天功能) + - `ry_knowledge.sql`(如使用知识库功能) + - `ry_workflow.sql`(如使用工作流功能) + - `ry_mall.sql`(如使用商城功能) + - `ry_pay.sql`(如使用支付功能) + - `ry_app.sql`(如使用移动端功能) + - `ry_job.sql`(如使用定时任务) + +> **注意**: +> - `ry_vue.sql` 为基础脚本,必须最先执行 +> - 其他脚本根据实际使用的功能模块选择执行 +> - 建议在生产环境执行前先备份数据库 + +#### 3. 配置AI服务 + +**重要:配置AI服务API密钥** + +```bash +# 设置环境变量(推荐方式) +# Windows +set OPENAI_API_KEY=your-openai-api-key +set ZHIPU_API_KEY=your-zhipu-api-key +set QWEN_API_KEY=your-qwen-api-key + +# Linux/Mac +export OPENAI_API_KEY=your-openai-api-key +export ZHIPU_API_KEY=your-zhipu-api-key +export QWEN_API_KEY=your-qwen-api-key +``` + +#### 4. 修改配置文件 + +编辑 `ruoyi-server/src/main/resources/application-dev.yml`: + +**基础配置(必须)** + +```yaml +spring: + # 数据源配置 + datasource: + dynamic: + primary: master + datasource: + master: + url: jdbc:mysql://localhost:3306/ruoyi_vue_plus?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: root + password: your_password + driver-class-name: com.mysql.cj.jdbc.Driver + + # Redis配置 + data: + redis: + host: localhost + port: 6379 + password: # 如有密码请填写 + database: 0 + timeout: 10s + lettuce: + pool: + max-active: 8 + max-wait: -1ms + max-idle: 8 + min-idle: 0 +``` + +**AI服务配置(可选,如使用AI功能)** + +```yaml +# AI模块配置(application-ai.yml 或 application-dev.yml) +ruoyi: + ai: + enabled: true + default-provider: openai # 默认AI提供商:openai、zhipu、qwen、chatglm等 + providers: + openai: + enabled: true + api-key: ${OPENAI_API_KEY:your-openai-api-key} + base-url: https://api.openai.com/v1 + model: gpt-3.5-turbo + temperature: 0.7 + max-tokens: 2000 + zhipu: + enabled: false + api-key: ${ZHIPU_API_KEY:} + base-url: https://open.bigmodel.cn/api/paas/v4 + model: glm-4 + qwen: + enabled: false + api-key: ${QWEN_API_KEY:} + base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 + model: qwen-turbo +``` + +> **配置说明**: +> - 数据库密码和Redis密码建议使用环境变量配置 +> - AI服务的API密钥强烈建议使用环境变量配置,避免泄露 +> - 更多配置项请参考 `application.yml` 和 `application-ai.yml` 文件 +> - 各模块的详细配置请参考对应模块的 README 文档 + +#### 5. 启动应用 + +**方式一:Maven启动(推荐用于开发)** + +```bash +# 进入项目根目录 +cd PoJie + +# 编译项目 +mvn clean compile + +# 启动应用 +mvn spring-boot:run -pl ruoyi-server -am + +# 或使用简化命令 +mvn -q -pl ruoyi-server -am spring-boot:run -DskipTests +``` + +**方式二:IDE启动(推荐用于开发调试)** + +1. 使用 IDEA 或 Eclipse 导入 Maven 项目 +2. 找到 `ruoyi-server/src/main/java/org/dromara/RuoYiApplication.java` +3. 右键运行 `main` 方法 +4. 或配置运行配置,设置工作目录为 `ruoyi-server` + +**方式三:JAR包启动(推荐用于生产)** + +```bash +# 进入项目根目录 +cd PoJie + +# 打包(跳过测试) +mvn clean package -DskipTests + +# 启动JAR包 +java -jar ruoyi-server/target/ruoyi-server.jar + +# 或指定配置文件 +java -jar ruoyi-server/target/ruoyi-server.jar --spring.profiles.active=prod + +# 后台启动(Linux) +nohup java -jar ruoyi-server/target/ruoyi-server.jar > app.log 2>&1 & +``` + +**方式四:Docker启动(推荐用于生产)** + +```bash +# 使用Docker Compose启动(包含MySQL、Redis等) +cd script/docker +docker-compose up -d + +# 构建应用镜像(需要Dockerfile) +docker build -t pojie:latest . +docker run -d -p 8080:8080 --name pojie pojie:latest +``` + +> **启动注意事项**: +> - 确保 MySQL 和 Redis 服务已启动 +> - 检查端口 8080 是否被占用 +> - 查看启动日志确认服务启动成功 +> - 如遇到端口冲突,可在配置文件中修改 `server.port` + +#### 6. 访问系统 + +启动成功后,访问以下地址: + +| 服务 | 地址 | 说明 | +|------|------|------| +| **系统首页** | http://localhost:8080 | 系统入口(需要前端配合) | +| **API文档(Swagger)** | http://localhost:8080/swagger-ui/index.html | Swagger UI 接口文档 | +| **API文档(Knife4j)** | http://localhost:8080/doc.html | Knife4j 增强文档(如已集成) | +| **监控中心** | http://localhost:8080/admin | Spring Boot Admin 监控 | +| **健康检查** | http://localhost:8080/actuator/health | Spring Boot Actuator | +| **AI服务健康检查** | http://localhost:8080/ai/health | AI模块健康状态(如启用) | +| **LangChain4j服务** | http://localhost:8080/ai/langchain4j/health | LangChain4j服务状态(如启用) | + +**默认登录账号**: +- 用户名:`admin` +- 密码:`admin123` + +> **注意**:首次登录后建议立即修改默认密码 + +#### 7. 验证AI功能(可选) + +如果已配置AI服务,可以通过以下方式验证: + +1. **测试AI聊天接口**: + ```bash + curl -X POST http://localhost:8080/ai/chat \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-token" \ + -d '{"message": "你好,请介绍一下自己"}' + ``` + +2. **查看支持的AI模型列表**: + ```bash + curl http://localhost:8080/ai/models + ``` + +3. **检查AI服务健康状态**: + 访问 http://localhost:8080/ai/health + +4. **通过API文档测试**: + 访问 Swagger UI 或 Knife4j 文档,找到AI相关接口进行测试 + +## 💡 使用示例 + +### AI服务开发 + +#### 统一AI服务接口 +- 提供统一的AI聊天接口,支持多种AI Provider +- 支持同步和异步(流式)响应模式 +- 集成请求验证和响应封装 +- 支持多模型切换和配置管理 + +#### LangChain4j服务使用 +- 单轮对话:直接调用AI模型进行问答 +- 带记忆的对话:支持上下文记忆的连续对话 +- 流式对话:实时返回AI生成内容 +- 模型管理:获取和切换支持的AI模型 + +### 移动端API开发 +- 移动端用户登录和认证 +- 用户信息管理和权限控制 +- 移动端专用的API接口设计 +- 支持多种登录方式(账号密码、第三方登录等) + + +### 分布式文件存储 +- 支持多种文件存储方式(本地、阿里云OSS、腾讯云COS、七牛云等) +- 提供统一的文件上传和下载接口 +- 支持文件预览和在线编辑 +- 集成文件安全检查和病毒扫描 + +### 实时消息推送 +- 基于SSE(Server-Sent Events)的实时消息推送 +- 支持用户连接管理和消息广播 +- 集成WebSocket支持双向通信 +- 提供消息队列和持久化存储 + +### 多数据源支持 +- 支持动态数据源切换和管理 +- 提供主从数据库读写分离 +- 支持分布式事务处理 +- 集成数据源监控和健康检查 + +### 权限控制 +- 基于Sa-Token的轻量级权限框架 +- 支持RBAC权限模型 +- 提供细粒度的权限控制 +- 集成操作日志和审计功能 + +## 📁 项目结构 + +``` +PoJie/ +├── ruoyi-common/ # 公共模块 +│ ├── ruoyi-common-bom/ # BOM依赖管理 +│ ├── ruoyi-common-core/ # 核心工具类 +│ ├── ruoyi-common-doc/ # 接口文档配置(SpringDoc) +│ ├── ruoyi-common-excel/ # Excel处理(FastExcel) +│ ├── ruoyi-common-mybatis/ # MyBatis-Plus配置 +│ ├── ruoyi-common-redis/ # Redis配置(Redisson) +│ ├── ruoyi-common-satoken/ # 权限认证(Sa-Token) +│ ├── ruoyi-common-oss/ # 对象存储(MinIO/OSS) +│ ├── ruoyi-common-sms/ # 短信服务 +│ ├── ruoyi-common-mail/ # 邮件服务 +│ ├── ruoyi-common-websocket/ # WebSocket支持 +│ ├── ruoyi-common-sse/ # SSE服务器推送 +│ ├── ruoyi-common-encrypt/ # 数据加密 +│ ├── ruoyi-common-idempotent/ # 幂等性控制 +│ ├── ruoyi-common-ratelimiter/ # 限流控制 +│ ├── ruoyi-common-log/ # 日志记录 +│ ├── ruoyi-common-security/ # 安全配置 +│ ├── ruoyi-common-sensitive/ # 敏感数据脱敏 +│ ├── ruoyi-common-social/ # 第三方登录(JustAuth) +│ ├── ruoyi-common-translation/ # 数据翻译 +│ ├── ruoyi-common-web/ # Web通用配置 +│ ├── ruoyi-common-json/ # JSON处理 +│ └── ruoyi-common-job/ # 任务调度 +├── ruoyi-modules/ # 业务模块 +│ ├── ruoyi-system/ # 系统管理模块 +│ ├── ruoyi-member/ # 移动端模块 +│ ├── ruoyi-chat/ # 聊天模块 +│ ├── ruoyi-ai/ # AI核心模块 +│ ├── ruoyi-knowledge/ # 知识库模块 +│ ├── ruoyi-workflow/ # 工作流模块(Warm-Flow) +│ ├── ruoyi-mall/ # 商城模块(聚合模块) +│ │ ├── ruoyi-mall-product/ # 商品中心 +│ │ ├── ruoyi-mall-trade/ # 交易中心 +│ │ ├── ruoyi-mall-coupon/ # 优惠券中心 +│ │ ├── ruoyi-mall-promotion/ # 营销中心 +│ │ └── ruoyi-mall-statistics/ # 统计中心 +│ ├── ruoyi-pay/ # 支付模块 +│ ├── ruoyi-generator/ # 代码生成器 +│ ├── ruoyi-job/ # 定时任务模块(SnailJob) +│ └── ruoyi-mp/ # 微信公众号模块 +├── ruoyi-extend/ # 扩展模块 +│ ├── ruoyi-monitor-admin/ # 监控中心(Spring Boot Admin) +│ └── ruoyi-snailjob-server/ # 分布式任务调度服务端 +├── ruoyi-server/ # 服务启动模块 +│ ├── src/main/java/ +│ │ └── org/dromara/ +│ │ └── RuoYiApplication.java # 启动类 +│ └── src/main/resources/ +│ ├── application.yml # 主配置文件 +│ ├── application-dev.yml # 开发环境配置 +│ ├── application-prod.yml # 生产环境配置 +│ └── application-ai.yml # AI模块配置 +├── script/ # 脚本文件 +│ ├── sql/ # 数据库脚本 +│ │ ├── ry_vue.sql # 基础数据库脚本(必须) +│ │ ├── ry_ai.sql # AI模块数据库脚本 +│ │ ├── ry_chat.sql # 聊天模块数据库脚本 +│ │ ├── ry_knowledge.sql # 知识库模块数据库脚本 +│ │ ├── ry_workflow.sql # 工作流模块数据库脚本 +│ │ ├── ry_mall.sql # 商城模块数据库脚本 +│ │ ├── ry_pay.sql # 支付模块数据库脚本 +│ │ ├── ry_app.sql # 移动端模块数据库脚本 +│ │ └── ry_job.sql # 定时任务数据库脚本 +│ ├── docker/ # Docker配置 +│ │ ├── docker-compose.yml # Docker Compose配置 +│ │ ├── database.yml # 数据库Docker配置 +│ │ └── redis/ # Redis配置 +│ └── bin/ # 启动脚本 +└── docs/ # 项目文档(可选) + ├── AI_INTEGRATION_GUIDE.md # AI集成指南 + ├── LANGCHAIN4J_INTEGRATION_GUIDE.md # LangChain4j集成指南 + └── DEPLOYMENT_GUIDE.md # 部署指南 +``` + +## 🔧 开发指南 + +### 环境配置 + +1. **开发环境** + - JDK 17+ (必须) + - Maven 3.6+ + - MySQL 8.0+ + - Redis 6.0+ + - Node.js 16+ +2. **AI服务配置** + - OpenAI API Key (推荐) + - 智谱AI API Key (可选) + - 通义千问API Key (可选) + - Azure OpenAI配置 (可选) + +### AI模块开发 + +#### 1. 添加新的AI Provider +- 实现AiProvider接口,定义Provider名称和核心方法 +- 添加@Component注解和条件配置注解 +- 实现chat()和streamChat()方法 +- 处理请求参数和响应格式 + +#### 2. 配置新Provider +- 在application.yml中添加Provider配置 +- 设置enabled、api-key、base-url、model等参数 +- 支持环境变量和配置文件两种方式 + +#### 3. LangChain4j扩展开发 +- 注入ChatLanguageModel进行AI对话 +- 实现自定义聊天服务和流式响应 +- 支持记忆管理和上下文处理 +- 集成监控和异常处理机制 + +### 代码生成器使用 + +1. 访问系统管理 -> 代码生成 +2. 选择要生成的数据表 +3. 配置生成信息 +4. 下载生成的代码 + +### 新增业务模块 + +1. 在 `ruoyi-modules` 下创建新模块 +2. 添加模块依赖到主pom.xml +3. 配置模块的Controller、Service、Mapper +4. 添加相应的权限配置 + +### 代码规范 + +- 遵循阿里巴巴Java开发手册 +- 使用统一的代码格式化配置 +- 添加必要的单元测试 +- 更新相关文档 +- AI相关代码需要添加详细的使用说明 + +### 数据库设计 + +- 统一的表前缀和命名规范 +- 合理的索引设计 +- 数据字典维护 +- AI相关表结构设计规范: + - `ai_*` 表前缀用于AI核心功能 + - `chat_*` 表前缀用于聊天相关 + - `knowledge_*` 表前缀用于知识库 + +### API文档规范 + +使用 `@Operation`、`@Tag`、`@Parameter` 等注解标注API信息: + +```java +@Tag(name = "用户管理") +@RestController +public class UserController { + + @Operation(summary = "获取用户信息", description = "根据用户ID获取用户详细信息") + @GetMapping("/{id}") + public R getUser(@Parameter(description = "用户ID") @PathVariable Long id) { + return R.ok(userService.getById(id)); + } +} +``` + +## 🤝 贡献指南 + +我们欢迎所有形式的贡献,包括但不限于: + +- 🐛 **Bug报告**:发现问题请提交Issue +- 💡 **功能建议**:有好的想法请告诉我们 +- 📝 **文档改进**:帮助完善文档 +- 🔧 **代码贡献**:提交Pull Request +- 🤖 **AI Provider集成**:添加新的AI服务提供商 +- 🧠 **AI算法优化**:改进AI服务性能和质量 +- 📚 **知识库功能扩展**:增强RAG和知识管理功能 + +### 贡献流程 + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request + +### AI相关贡献指南 + +#### 新增AI Provider + +1. **实现Provider接口** + - 继承AiProvider接口并实现所有必要方法 + - 定义Provider名称和基本配置 + +2. **添加配置类** + - 创建配置属性类,使用@ConfigurationProperties注解 + - 定义API密钥、基础URL、模型等配置项 + +3. **编写单元测试** + - 为新Provider编写完整的单元测试 + - 测试聊天功能、流式响应、异常处理等 + +4. **更新文档** + - 在 `docs/AI_INTEGRATION_GUIDE.md` 中添加使用说明 + - 更新 `README.md` 中的支持列表 + +#### LangChain4j扩展贡献 + +1. **扩展现有功能** + - 添加新的模型支持 + - 优化对话记忆管理 + - 增强流式响应处理 + +2. **性能优化** + - 连接池优化 + - 缓存策略改进 + - 异步处理优化 + +### 代码规范 + +- 遵循阿里巴巴Java开发手册 +- 使用统一的代码格式化配置 +- 添加必要的单元测试 +- 更新相关文档 +- AI相关代码需要包含详细的JavaDoc注释 +- 提供配置示例和使用说明 + +## 📞 联系我们 + +- **项目地址**:https://gitee.com/pengles/PoJie +- **问题反馈**:https://gitee.com/pengles/PoJie/issues +- **QQ交流群**:待建立 +- **微信交流群**:待建立 + +🌟 **开源精神让我们共同进步,感谢每一位开发者的贡献和分享!** + +## 📄 许可证 + +本项目采用 [MIT 许可证](./LICENSE),详见 LICENSE 文件。 + +--- + +
+ +**如果这个项目对你有帮助,请给个 ⭐ Star 支持一下!** + +Made with ❤️ by PoJie Team + +
diff --git a/UPDATE.md b/UPDATE.md new file mode 100644 index 0000000..91639d1 --- /dev/null +++ b/UPDATE.md @@ -0,0 +1,229 @@ +# 项目升级与优化方案(UPDATE) + +> **版本**: v1.1.0 (2025-01-XX) +> +> **更新日志**: +> - v1.1.0: 新增商城模块完善章节,记录商城系统模块拆分与功能实现进展 +> - v1.0.0: 初始版本,聚焦认证安全与系统优化 + +--- + +## 目标与范围 +- **目标**: 在不改变主业务的前提下,增强认证安全、可扩展性、性能稳定性与可观测能力,同时提升开发者体验与交付效率 +- **范围**: Sys 后台端、App 用户端、MP 公众号、公共组件(认证策略、会话、缓存、日志、CI/CD、文档与测试)、商城系统模块拆分与完善 + +--- + +## 总体原则 +- **最小入侵、增量演进**: 不推倒重来,尽量通过服务层与拦截器/过滤器扩展 +- **枚举化与集中化**: 统一策略路由、Key 规范、限流与加密开关 +- **可观测先行**: 指标、告警、健康检查与日志字段统一,便于稳定运营 +- **先方案后实现**: 每个子项包含明确的改动点、验证方法与风险控制 + +--- + +## 认证与安全(高优先) + +### grantType 枚举化与策略映射 +- **内容**: 用枚举或常量表统一 Sys/App 的 grantType 与策略映射,移除魔法字符串 +- **改动点**: 策略注册与查找入口、客户端配置校验 +- **验证**: 枚举覆盖所有现有策略,错误入参返回一致错误码 + +### 风险登录与强制改密 +- **内容**: 登录成功后比对 IP 归属地变化(日志/邮件告警);密码超期标记 Session,拦截除改密外所有接口 +- **改动点**: 在登录服务增加策略执行方法;新增 WebMvc 拦截器 +- **验证**: 模拟超期与非超期用户行为;确保拦截仅放行改密与登出 + +### 验证码策略(保持现状) +- **内容**: 当前阶段 App 密码策略不启用验证码,保持与现状一致;但保留验证码校验逻辑与开关,后续在前端加密与交互链路完善后可一键开启,与 Sys 保持一致 +- **改动点**: 不改动 App 端验证码逻辑(保留关闭状态);保留校验方法与配置开关(示例:app.auth.captcha.enabled=false) +- **验证**: 无功能改动;后续启用时按开关置为 true 并执行联调(验证码生成/校验、错误次数累加与锁定、生效范围与白名单) +- **准备项**: 前端加密到位、验证码生成与传输链路打通、联调用例与回归清单就绪 + +### 接口加密与防刷 +- **内容**: App 端在前端加密准备就绪后启用 ApiEncrypt;登录/验证码/二维码轮询加限流 +- **改动点**: 控制器方法注解与网关规则 +- **验证**: 压测防刷路径、请求速率下服务稳定性 + +--- + +## App 信息补全(高优先) +- **内容**: 登录后资料未完善仅放行白名单接口(个人信息查询、绑定手机号),其他接口返回 403_INFO_INCOMPLETE +- **改动点**: 新增 App 过滤器 + 配置项白名单;登录成功后写入 Session 标记 +- **验证**: 未完善资料用户访问白名单成功、非白名单拦截;完善后恢复正常 + +--- + +## 会话管理与事件(中优先) +- **内容**: 完善第 6 章会话 API(活跃列表、终止、心跳续期、SSE 事件);令牌 TTL 与 Redis TTL 同步,避免“僵尸会话” +- **改动点**: App 会话服务持久化实现;统一 Key 前缀与 TTL +- **验证**: 多设备并发登录、心跳续期、终止事件推送的端到端验证 + +--- + +## 第三方授权与绑定(中优先) +- **内容**: justauth 登录与绑定流程的鲁棒化(source 与 state 校验、重复绑定幂等);MP 二维码的 scene_str 规范(sys_user:{userId}) +- **改动点**: 社交授权回调控制器、绑定服务的重复校验;MP 配置与二维码接口 +- **验证**: 常见平台的授权成功/失败、重复绑定、解绑与重新绑定 + +--- + +## 数据层与性能(中优先) + +### MyBatis-Plus 警告修复 +- **内容**: 为缺少 @TableId 的实体(如 AppUserRole)补注解,恢复 ById 系列方法可用 +- **验证**: 编译无警告且 CRUD 正常 + +### 索引与热点查询优化 +- **内容**: 用户、角色关联、社交绑定等常查表加必要索引;分页与条件查询走服务层统一封装 +- **验证**: 压测主要查询场景,慢查与扫描下降 + +--- + +## 可观测与告警(中优先) + +### 指标与健康检查 +- **内容**: 暴露登录成功率、锁定次数、验证码命中率、会话活跃数;完善 actuator 健康检查 +- **改动点**: Micrometer 指标埋点;Spring Boot Admin 端点配置 +- **验证**: 仪表板查看与阈值告警验证 + +### 日志与链路追踪 +- **内容**: 统一日志字段(traceId、userId、clientId、grantType);社交授权与二维码流程关键点日志 +- **验证**: 问题复盘路径明确、跨模块调用链可追踪 + +--- + +## 开发者体验与文档(中优先) + +### 文档 +- **内容**: 在登录综合指南中加入响应结构说明与“grantType→策略类→入口路径”对照表;升级与优化指南形成统一入口 +- **验证**: 联调工程师可按文档快速定位与调用 + +### 测试 +- **内容**: 策略单测覆盖验证码开关、停用用户、第三方失败分支;集成测试覆盖 /auth/login、/app/auth/login 的典型入参与响应 +- **验证**: 持续集成跑通、关键路径不回归 + +--- + +## 交付与构建(低优先但必要) + +### CI/CD 稳定性 +- **内容**: 分模块并行构建与独立重试;对 clean 阶段的目标目录锁定增加占用检测;缓存 Maven 仓库 +- **验证**: 全量构建时间缩短、偶发失败模块可重试成功 + +### 配置与密钥管理 +- **内容**: 区分 dev/prod 配置;禁止密钥进入日志;构建衍生文件与敏感清单进入 .gitignore +- **验证**: 审计通过、漏洞扫描无高危项 + +--- + +## 风险与回滚 +- **风险**: + - 拦截策略误拦截、授权流程改动影响兼容 + - 商城模块拆分可能影响现有代码调用(已采用渐进式迁移策略,保留原有代码) +- **回滚**: + - 按模块独立开关与配置回退;拦截器与过滤器具备开关与白名单;社交绑定保留幂等校验 + - 商城模块:保留原有代码在 `ruoyi-mall/src` 目录,新功能在新子模块开发,可平滑回退 + +--- + +## 阶段产出与验证 + +### 阶段 1(高优先):grantType 枚举、风险登录与强制改密、App 信息补全过滤器、验证码一致化 +- **验证**: 编译通过、用例跑通、预览可访问,关键接口行为与文档一致 + +### 阶段 2(中优先):会话 API、第三方授权鲁棒化、数据层优化、指标与健康检查 +- **验证**: 压测与监控数据达标、授权与绑定流程无异常 + +### 阶段 3(持续):测试完善、CI/CD 优化、文档与示例更新 + +### 阶段 4(已完成):商城系统模块拆分与核心功能实现 ✅ +- **验证**: 各子模块功能完整、API接口可用、数据库表结构正确、文档完善 +- **成果**: + - 5 个子模块基础结构完整 + - 商品中心、交易中心、优惠券中心功能完整实现 + - 营销中心活动管理和秒杀功能实现 + - 完整的 API 接口和文档 + +--- + +## 商城系统模块拆分与完善(高优先,已完成核心功能)✅ + +### 模块结构重构 +- **内容**: 按照领域驱动设计原则,将 `ruoyi-mall` 模块拆分为 5 个子模块,实现模块化架构 +- **改动点**: + - 创建子模块目录结构(product、trade、coupon、promotion、statistics) + - 配置各子模块的 Maven 依赖关系 + - 创建各子模块的 AutoConfiguration 类和 spring.factories + - 扩展数据库表结构,支持完整业务场景 +- **验证**: 各子模块可独立编译、自动配置生效、数据库表结构完整 + +### 商品中心模块 (ruoyi-mall-product) ✅ +- **已完成功能**: + - ✅ 商品(SPU)和SKU的完整CRUD操作 + - ✅ 商品分类管理(三级分类树形结构) + - ✅ 品牌管理 + - ✅ 商品上架/下架、浏览量和销量统计 +- **数据库表**: `mall_product`, `mall_sku`, `mall_category`, `mall_brand` +- **API接口**: 管理端和移动端完整的商品、分类、品牌接口 + +### 交易中心模块 (ruoyi-mall-trade) ✅ +- **已完成功能**: + - ✅ 购物车管理(添加、删除、数量修改、选中状态) + - ✅ 订单管理(创建、查询、取消、状态流转) + - ✅ 售后管理(退款、退货、换货申请及审核流程) + - ✅ 订单评价(评价提交、审核、回复) + - ✅ 订单支付集成(与支付中心集成) +- **数据库表**: `mall_cart`, `mall_order`, `mall_order_item`, `mall_after_sale`, `mall_order_evaluate`, `mall_order_coupon` +- **API接口**: 完整的订单、售后、评价管理接口 + +### 优惠券中心模块 (ruoyi-mall-coupon) ✅ +- **已完成功能**: + - ✅ 优惠券管理(满减、折扣、现金券类型) + - ✅ 用户优惠券(领券、使用、退还) + - ✅ 优惠券过期处理(定时任务方法已实现) +- **数据库表**: `mall_coupon`, `mall_coupon_user` +- **API接口**: 完整的优惠券和用户优惠券管理接口 + +### 营销中心模块 (ruoyi-mall-promotion) ⚠️ +- **已完成功能**: + - ✅ 营销活动管理(活动类型、规则配置、时间管理) + - ✅ 秒杀功能(秒杀活动创建、库存管理、状态管理) +- **部分实现**: + - ⚠️ 拼团功能(表结构已创建,业务逻辑待实现) + - ⚠️ 砍价功能(表结构已创建,业务逻辑待实现) +- **数据库表**: `mall_promotion`, `mall_seckill`, `mall_group_buy`, `mall_group_buy_order`, `mall_bargain`, `mall_bargain_record`, `mall_bargain_help` + +### 统计中心模块 (ruoyi-mall-statistics) ⚠️ +- **状态**: 框架已创建,基础结构完善 +- **待实现**: 交易分析、会员分析、商品分析、营销分析等统计功能 + +### 文档完善 ✅ +- ✅ 各子模块 README.md 文档完整 +- ✅ 模块结构说明文档(MODULE_STRUCTURE.md) +- ✅ API接口详细说明 +- ✅ 数据库表结构说明 + +--- + +## 行动清单(优先顺序) + +### 已完成 ✅ +- ✅ 商城模块结构拆分与基础功能实现 +- ✅ 商品中心模块完整实现(商品、SKU、分类、品牌) +- ✅ 交易中心模块完整实现(订单、售后、评价) +- ✅ 优惠券中心模块完整实现 +- ✅ 营销中心模块部分实现(活动管理、秒杀) + +### 进行中 / 待完成 +1. 实现风险登录检测与强制改密拦截(Sys) +2. 统一 App 信息补全过滤器与白名单 +3. 修复 MyBatis-Plus 警告(补充缺失的 @TableId) +4. 统一 grantType 枚举化与策略映射表 +5. App 启用验证码开关,与 Sys 行为一致 +6. 完善会话指标与健康检查,接入告警 +7. 补充集成测试与 Postman 集合,提高联调效率 +8. CI 并行构建与清理增强,提升交付稳定性 +9. 完善营销中心模块(拼团、砍价功能实现) +10. 实现统计中心模块数据分析功能 +11. 商城模块代码迁移(将现有代码迁移到新子模块结构,可选) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..eacda03 --- /dev/null +++ b/pom.xml @@ -0,0 +1,719 @@ + + + 4.0.0 + + org.dromara + PoJie + ${revision} + + PoJie + https://gitee.com/pengles/PoJie + PoJie管理系统 + + + 5.4.1 + 3.4.7 + UTF-8 + UTF-8 + 17 + 3.5.16 + 2.8.14 + 0.15.0 + 1.3.0 + 2.3 + 1.44.0 + 3.5.12 + 3.9.1 + 5.8.40 + 3.4.7 + 3.52.0 + 2.2.7 + 4.3.1 + 1.6.0 + 1.5.0 + 0.2.0 + 1.18.36 + 1.80 + 1.16.7 + + 2.7.0 + + 2.28.22 + + 3.3.4 + + 1.2.83 + + 8.7.2-20250603 + + 1.7.4 + + 4.34.86.ALL + + 4.7.0 + 4.7.0 + 0.2.12 + + 5.2.4 + + 8.0.2 + + + 1.0.1 + 1.3.0-beta9 + + 4.12.0 + + + 3.4.2 + 3.4.0 + 3.14.0 + 3.5.3 + 1.3.0 + + true + + + + + local + + + local + info + ruoyi + 123456 + + + + dev + + + dev + info + ruoyi + 123456 + + + + true + + + + prod + + prod + warn + ruoyi + 123456 + + + + + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + cn.hutool + hutool-bom + ${hutool.version} + pom + import + + + + + org.dromara.warm + warm-flow-mybatis-plus-sb3-starter + ${warm-flow.version} + + + org.dromara.warm + warm-flow-plugin-ui-sb-web + ${warm-flow.version} + + + + + me.zhyd.oauth + JustAuth + ${justauth.version} + + + + + org.dromara + ruoyi-common-bom + ${revision} + pom + import + + + + org.springdoc + springdoc-openapi-starter-webmvc-api + ${springdoc.version} + + + + org.springdoc + springdoc-openapi-starter-common + ${springdoc.version} + + + + com.github.therapi + therapi-runtime-javadoc + ${therapi-javadoc.version} + + + + org.projectlombok + lombok + ${lombok.version} + + + + cn.idev.excel + fastexcel + ${fastexcel.version} + + + + + org.apache.velocity + velocity-engine-core + ${velocity.version} + + + + + cn.dev33 + sa-token-spring-boot3-starter + ${satoken.version} + + + + cn.dev33 + sa-token-jwt + ${satoken.version} + + + cn.hutool + hutool-all + + + + + cn.dev33 + sa-token-core + ${satoken.version} + + + + + com.baomidou + dynamic-datasource-spring-boot3-starter + ${dynamic-ds.version} + + + + org.mybatis + mybatis + ${mybatis.version} + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + + com.baomidou + mybatis-plus-annotation + ${mybatis-plus.version} + + + + + p6spy + p6spy + ${p6spy.version} + + + + + software.amazon.awssdk + s3 + ${aws.sdk.version} + + + + software.amazon.awssdk + s3-transfer-manager + ${aws.sdk.version} + + + + software.amazon.awssdk + netty-nio-client + ${aws.sdk.version} + + + + org.dromara.sms4j + sms4j-spring-boot-starter + ${sms4j.version} + + + + de.codecentric + spring-boot-admin-starter-server + ${spring-boot-admin.version} + + + de.codecentric + spring-boot-admin-starter-client + ${spring-boot-admin.version} + + + + + org.redisson + redisson-spring-boot-starter + ${redisson.version} + + + + com.baomidou + lock4j-redisson-spring-boot-starter + ${lock4j.version} + + + + + com.aizuda + snail-job-client-starter + ${snailjob.version} + + + com.aizuda + snail-job-client-job-core + ${snailjob.version} + + + + + org.bouncycastle + bcprov-jdk15to18 + ${bouncycastle.version} + + + + io.github.linpeilie + mapstruct-plus-spring-boot-starter + ${mapstruct-plus.version} + + + + + org.lionsoul + ip2region + ${ip2region.version} + + + + + com.github.binarywang + weixin-java-miniapp + ${weixin-java-miniapp.version} + + + + + com.github.binarywang + weixin-java-mp + ${weixin-java-mp.version} + + + + + com.github.wechatpay-apiv3 + wechatpay-java + ${wechatpay-java.version} + + + + com.alibaba + fastjson + ${fastjson.version} + + + + + com.alipay.sdk + alipay-sdk-java + ${alipay-sdk.version} + + + + + org.apache.poi + poi + ${poi.version} + + + + org.apache.poi + poi-ooxml + ${poi.version} + + + + org.apache.poi + poi-scratchpad + ${poi.version} + + + + + com.itextpdf + kernel + ${itext.version} + + + + com.itextpdf + layout + ${itext.version} + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.pom.version} + pom + import + + + + + org.springframework.ai + spring-ai-core + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + + + + + dev.langchain4j + langchain4j-community-bom + ${langchain4j.pom.version} + pom + import + + + + + dev.langchain4j + langchain4j + + + dev.langchain4j + langchain4j-spring-boot-starter + + + dev.langchain4j + langchain4j-azure-open-ai + + + dev.langchain4j + langchain4j-zhipu-ai + + + dev.langchain4j + langchain4j-dashscope + + + + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + + + + com.squareup.okhttp3 + okhttp-sse + ${okhttp.version} + + + + org.dromara + ruoyi-ai + ${revision} + + + + org.dromara + ruoyi-system + ${revision} + + + + org.dromara + ruoyi-chat + ${revision} + + + + org.dromara + ruoyi-job + ${revision} + + + + org.dromara + ruoyi-generator + ${revision} + + + + org.dromara + ruoyi-knowledge + ${revision} + + + + + org.dromara + ruoyi-workflow + ${revision} + + + + + org.dromara + ruoyi-mp + ${revision} + + + + + org.dromara + ruoyi-member + ${revision} + + + + + org.dromara + ruoyi-pay + ${revision} + + + + + org.dromara + ruoyi-mall + ${revision} + + + + + + + ruoyi-common + ruoyi-extend + ruoyi-modules + ruoyi-server + + pom + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${java.version} + ${java.version} + ${project.build.sourceEncoding} + + + com.github.therapi + therapi-runtime-javadoc-scribe + ${therapi-javadoc.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + + + io.github.linpeilie + mapstruct-plus-processor + ${mapstruct-plus.version} + + + org.projectlombok + lombok-mapstruct-binding + ${mapstruct-plus.lombok.version} + + + + -parameters + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + -Dfile.encoding=UTF-8 + + ${profiles.active} + + exclude + + + + + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} + + true + resolveCiFriendliesOnly + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + + src/main/resources + + false + + + src/main/resources + + + application* + bootstrap* + banner* + + + true + + + + + + + + + + + + + + + + + + central + Maven Central + https://repo1.maven.org/maven2 + + true + + + + public + huawei nexus + https://mirrors.huaweicloud.com/repository/maven/ + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + + + + public + huawei nexus + https://mirrors.huaweicloud.com/repository/maven/ + + true + + + false + + + + + + diff --git a/ruoyi-common/.DS_Store b/ruoyi-common/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f689641c57a024702ce2ece6b0ed2f52ccc8e405 GIT binary patch literal 8196 zcmeHMTWl0n7(UB-MFr!N#=B1%i98rkCx{`yobIU_8LGME})6g&zVCR#La0 zs7`T!uM-O}9$;CbuS#>u=mCK$f)E2zoyKEAoG>0>S)x>Dkm?M<$_OG9_^Xp%%ok^f zOAO1<0-*)wT0q3^bu7<{6tz#yh~Hi21krq+nYLHzN!xCkv5G`GXIoyOz5Q$Oipr|$ z1xjRLje3hdoE#Zd$HukACCi)~%oQxx@$NVC9^trF>rJ?mJ2I}bJ8z2d;f!k+vyL~e z)n!t4rf8=MW|wUztfMCN)q0t{YumeB&l1y&Bn#$|f*6}KlpOWke4)pmY+Tcdp1Ieg zxvY}eOEY>&iJrcFrTnpHDPdOk0PGR$)qORuq4Y z*m7MRGw9*^T|=y5XlE6ghgM&?T-P5EBRzSON+VEMy+&8}d9(*D6$rMiQ`LPBTaKZ5 z;t@!7<0e%fvQuf(){KZ;r*pHa50|9HQ&&Y=6HJ`qA;CpV7+V?J3uab zjGbW5u~X~}`-GiiU$Jl5PwX=L9Y8fAScn?bV<}qDiaXJPPHaXu2C)Zwk-!)xVBruv z9Klf>!*M*0C-4+b;3S^KOLzsZ;x)X9w{Zq%@eV$~d3=V?@g*+dJA98Ha2bE#Ph8Jc372#$$Mr7;q9#;~6}U7w{rp#_M>4 zSa1q&5eMGIdw3ro;bWY`rvV$jAujxkUuOAt|4jeJZ`bGFr9|GbUFRT4PcvoaL}BZO zT0q?Wlp00+-#7p7|GrRIgcb-b@c*;`Dti;X-L$v)-nt^zj!`~9StKD>mguWc;fLdd pemG8e^$$a;$4FG@!~-l#q#jED_zwXyzte~Je|Z1 + + + org.dromara + PoJie + ${revision} + + 4.0.0 + + + ruoyi-common-bom + ruoyi-common-social + ruoyi-common-core + ruoyi-common-doc + ruoyi-common-excel + ruoyi-common-idempotent + ruoyi-common-job + ruoyi-common-log + ruoyi-common-mail + ruoyi-common-mybatis + ruoyi-common-oss + ruoyi-common-ratelimiter + ruoyi-common-redis + ruoyi-common-satoken + ruoyi-common-security + ruoyi-common-sms + ruoyi-common-web + ruoyi-common-translation + ruoyi-common-json + ruoyi-common-encrypt + ruoyi-common-sensitive + ruoyi-common-websocket + ruoyi-common-sse + + + ruoyi-common + pom + + + common 通用模块 + + + diff --git a/ruoyi-common/ruoyi-common-bom/pom.xml b/ruoyi-common/ruoyi-common-bom/pom.xml new file mode 100644 index 0000000..94a0908 --- /dev/null +++ b/ruoyi-common/ruoyi-common-bom/pom.xml @@ -0,0 +1,178 @@ + + + 4.0.0 + + org.dromara + ruoyi-common-bom + ${revision} + pom + + + ruoyi-common-bom common依赖项 + + + + 5.4.1 + + + + + + + org.dromara + ruoyi-common-core + ${revision} + + + + + org.dromara + ruoyi-common-doc + ${revision} + + + + + org.dromara + ruoyi-common-excel + ${revision} + + + + + org.dromara + ruoyi-common-idempotent + ${revision} + + + + + org.dromara + ruoyi-common-job + ${revision} + + + + + org.dromara + ruoyi-common-log + ${revision} + + + + + org.dromara + ruoyi-common-mail + ${revision} + + + + + org.dromara + ruoyi-common-mybatis + ${revision} + + + + + org.dromara + ruoyi-common-oss + ${revision} + + + + + org.dromara + ruoyi-common-ratelimiter + ${revision} + + + + + org.dromara + ruoyi-common-redis + ${revision} + + + + + org.dromara + ruoyi-common-satoken + ${revision} + + + + + org.dromara + ruoyi-common-security + ${revision} + + + + + org.dromara + ruoyi-common-sms + ${revision} + + + + org.dromara + ruoyi-common-social + ${revision} + + + + + org.dromara + ruoyi-common-web + ${revision} + + + + + org.dromara + ruoyi-common-translation + ${revision} + + + + + org.dromara + ruoyi-common-sensitive + ${revision} + + + + + org.dromara + ruoyi-common-json + ${revision} + + + + + org.dromara + ruoyi-common-encrypt + ${revision} + + + + + org.dromara + ruoyi-common-websocket + ${revision} + + + + + org.dromara + ruoyi-common-sse + ${revision} + + + + + + diff --git a/ruoyi-common/ruoyi-common-core/pom.xml b/ruoyi-common/ruoyi-common-core/pom.xml new file mode 100644 index 0000000..d74f44e --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/pom.xml @@ -0,0 +1,111 @@ + + + + org.dromara + ruoyi-common + ${revision} + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 16 + 16 + + + + + 4.0.0 + + ruoyi-common-core + + + ruoyi-common-core 核心模块 + + + + + + org.springframework + spring-context-support + + + + + org.springframework + spring-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.apache.commons + commons-lang3 + + + + + jakarta.servlet + jakarta.servlet-api + + + + cn.hutool + hutool-core + + + + cn.hutool + hutool-http + + + + cn.hutool + hutool-extra + + + + org.projectlombok + lombok + + + + + org.springframework.boot + spring-boot-configuration-processor + + + + org.springframework.boot + spring-boot-properties-migrator + runtime + + + + io.github.linpeilie + mapstruct-plus-spring-boot-starter + + + + + org.lionsoul + ip2region + + + + + diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ApplicationConfig.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ApplicationConfig.java new file mode 100644 index 0000000..bcf5d41 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ApplicationConfig.java @@ -0,0 +1,17 @@ +package org.dromara.common.core.config; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * 程序注解配置 + * + * @author pengles + */ +@AutoConfiguration +@EnableAspectJAutoProxy +@EnableAsync(proxyTargetClass = true) +public class ApplicationConfig { + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/AsyncConfig.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/AsyncConfig.java new file mode 100644 index 0000000..6427ad5 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/AsyncConfig.java @@ -0,0 +1,52 @@ +package org.dromara.common.core.config; + +import cn.hutool.core.util.ArrayUtil; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.core.task.VirtualThreadTaskExecutor; +import org.springframework.scheduling.annotation.AsyncConfigurer; + +import java.util.Arrays; +import java.util.concurrent.Executor; + +/** + * 异步配置 + *

+ * 如果未使用虚拟线程则生效 + * + * @author pengles + */ +@AutoConfiguration +public class AsyncConfig implements AsyncConfigurer { + + /** + * 自定义 @Async 注解使用系统线程池 + */ + @Override + public Executor getAsyncExecutor() { + if(SpringUtils.isVirtual()) { + return new VirtualThreadTaskExecutor("async-"); + } + return SpringUtils.getBean("scheduledExecutorService"); + } + + /** + * 异步执行异常处理 + */ + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return (throwable, method, objects) -> { + throwable.printStackTrace(); + StringBuilder sb = new StringBuilder(); + sb.append("Exception message - ").append(throwable.getMessage()) + .append(", Method name - ").append(method.getName()); + if (ArrayUtil.isNotEmpty(objects)) { + sb.append(", Parameter value - ").append(Arrays.toString(objects)); + } + throw new ServiceException(sb.toString()); + }; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ThreadPoolConfig.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ThreadPoolConfig.java new file mode 100644 index 0000000..d0cfbaf --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ThreadPoolConfig.java @@ -0,0 +1,87 @@ +package org.dromara.common.core.config; + +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.concurrent.BasicThreadFactory; +import org.dromara.common.core.config.properties.ThreadPoolProperties; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.Threads; +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.core.task.VirtualThreadTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 线程池配置 + * + * @author pengles + **/ +@Slf4j +@AutoConfiguration +@EnableConfigurationProperties(ThreadPoolProperties.class) +public class ThreadPoolConfig { + + /** + * 核心线程数 = cpu 核心数 + 1 + */ + private final int core = Runtime.getRuntime().availableProcessors() + 1; + + private ScheduledExecutorService scheduledExecutorService; + + @Bean(name = "threadPoolTaskExecutor") + @ConditionalOnProperty(prefix = "thread-pool", name = "enabled", havingValue = "true") + public ThreadPoolTaskExecutor threadPoolTaskExecutor(ThreadPoolProperties threadPoolProperties) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(core); + executor.setMaxPoolSize(core * 2); + executor.setQueueCapacity(threadPoolProperties.getQueueCapacity()); + executor.setKeepAliveSeconds(threadPoolProperties.getKeepAliveSeconds()); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + return executor; + } + + /** + * 执行周期性或定时任务 + */ + @Bean(name = "scheduledExecutorService") + protected ScheduledExecutorService scheduledExecutorService() { + // daemon 必须为 true + BasicThreadFactory.Builder builder = new BasicThreadFactory.Builder().daemon(true); + if (SpringUtils.isVirtual()) { + builder.namingPattern("virtual-schedule-pool-%d").wrappedFactory(new VirtualThreadTaskExecutor().getVirtualThreadFactory()); + } else { + builder.namingPattern("schedule-pool-%d"); + } + ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(core, + builder.build(), + new ThreadPoolExecutor.CallerRunsPolicy()) { + @Override + protected void afterExecute(Runnable r, Throwable t) { + super.afterExecute(r, t); + Threads.printException(r, t); + } + }; + this.scheduledExecutorService = scheduledThreadPoolExecutor; + return scheduledThreadPoolExecutor; + } + + /** + * 销毁事件 + */ + @PreDestroy + public void destroy() { + try { + log.info("====关闭后台任务任务线程池===="); + Threads.shutdownAndAwaitTermination(scheduledExecutorService); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ValidatorConfig.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ValidatorConfig.java new file mode 100644 index 0000000..2b8343d --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ValidatorConfig.java @@ -0,0 +1,41 @@ +package org.dromara.common.core.config; + +import jakarta.validation.Validator; +import org.hibernate.validator.HibernateValidator; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import java.util.Properties; + +/** + * 校验框架配置类 + * + * @author pengles + */ +@AutoConfiguration(before = ValidationAutoConfiguration.class) +public class ValidatorConfig { + + /** + * 配置校验框架 快速失败模式 + */ + @Bean + public Validator validator(MessageSource messageSource) { + try (LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean()) { + // 国际化 + factoryBean.setValidationMessageSource(messageSource); + // 设置使用 HibernateValidator 校验器 + factoryBean.setProviderClass(HibernateValidator.class); + Properties properties = new Properties(); + // 设置快速失败模式(fail-fast),即校验过程中一旦遇到失败,立即停止并返回错误 + properties.setProperty("hibernate.validator.fail_fast", "true"); + factoryBean.setValidationProperties(properties); + // 加载配置 + factoryBean.afterPropertiesSet(); + return factoryBean.getValidator(); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/properties/ThreadPoolProperties.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/properties/ThreadPoolProperties.java new file mode 100644 index 0000000..334b242 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/properties/ThreadPoolProperties.java @@ -0,0 +1,30 @@ +package org.dromara.common.core.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 线程池 配置属性 + * + * @author pengles + */ +@Data +@ConfigurationProperties(prefix = "thread-pool") +public class ThreadPoolProperties { + + /** + * 是否开启线程池 + */ + private boolean enabled; + + /** + * 队列最大长度 + */ + private int queueCapacity; + + /** + * 线程池维护线程所允许的空闲时间 + */ + private int keepAliveSeconds; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheConstants.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheConstants.java new file mode 100644 index 0000000..fc246cc --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheConstants.java @@ -0,0 +1,30 @@ +package org.dromara.common.core.constant; + +/** + * 缓存的key 常量 + * + * @author pengles + */ +public interface CacheConstants { + + /** + * 在线用户 redis key + */ + String ONLINE_TOKEN_KEY = "online_tokens:"; + + /** + * 参数管理 cache key + */ + String SYS_CONFIG_KEY = "sys_config:"; + + /** + * 字典管理 cache key + */ + String SYS_DICT_KEY = "sys_dict:"; + + /** + * 登录账户密码错误次数 redis key + */ + String PWD_ERR_CNT_KEY = "pwd_err_cnt:"; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheNames.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheNames.java new file mode 100644 index 0000000..279ebb6 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheNames.java @@ -0,0 +1,83 @@ +package org.dromara.common.core.constant; + +/** + * 缓存组名称常量 + *

+ * key 格式为 cacheNames#ttl#maxIdleTime#maxSize + *

+ * ttl 过期时间 如果设置为0则不过期 默认为0 + * maxIdleTime 最大空闲时间 根据LRU算法清理空闲数据 如果设置为0则不检测 默认为0 + * maxSize 组最大长度 根据LRU算法清理溢出数据 如果设置为0则无限长 默认为0 + *

+ * 例子: test#60s、test#0#60s、test#0#1m#1000、test#1h#0#500 + * + * @author pengles + */ +public interface CacheNames { + + /** + * 演示案例 + */ + String DEMO_CACHE = "demo:cache#60s#10m#20"; + + /** + * 系统配置 + */ + String SYS_CONFIG = "sys_config"; + + /** + * 数据字典 + */ + String SYS_DICT = "sys_dict"; + + /** + * 数据字典类型 + */ + String SYS_DICT_TYPE = "sys_dict_type"; + + /** + * 客户端 + */ + String SYS_CLIENT = GlobalConstants.GLOBAL_REDIS_KEY + "sys_client#30d"; + + /** + * 用户账户 + */ + String SYS_USER_NAME = "sys_user_name#30d"; + + /** + * 用户名称 + */ + String SYS_NICKNAME = "sys_nickname#30d"; + + /** + * 部门 + */ + String SYS_DEPT = "sys_dept#30d"; + + /** + * OSS内容 + */ + String SYS_OSS = "sys_oss#30d"; + + /** + * 角色自定义权限 + */ + String SYS_ROLE_CUSTOM = "sys_role_custom#30d"; + + /** + * 部门及以下权限 + */ + String SYS_DEPT_AND_CHILD = "sys_dept_and_child#30d"; + + /** + * OSS配置 + */ + String SYS_OSS_CONFIG = GlobalConstants.GLOBAL_REDIS_KEY + "sys_oss_config"; + + /** + * 在线用户 + */ + String ONLINE_TOKEN = "online_tokens"; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/Constants.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/Constants.java new file mode 100644 index 0000000..f47654c --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/Constants.java @@ -0,0 +1,76 @@ +package org.dromara.common.core.constant; + +/** + * 通用常量信息 + * + * @author pengles + */ +public interface Constants { + + /** + * UTF-8 字符集 + */ + String UTF8 = "UTF-8"; + + /** + * GBK 字符集 + */ + String GBK = "GBK"; + + /** + * www主域 + */ + String WWW = "www."; + + /** + * http请求 + */ + String HTTP = "http://"; + + /** + * https请求 + */ + String HTTPS = "https://"; + + /** + * 通用成功标识 + */ + String SUCCESS = "0"; + + /** + * 通用失败标识 + */ + String FAIL = "1"; + + /** + * 登录成功 + */ + String LOGIN_SUCCESS = "Success"; + + /** + * 注销 + */ + String LOGOUT = "Logout"; + + /** + * 注册 + */ + String REGISTER = "Register"; + + /** + * 登录失败 + */ + String LOGIN_FAIL = "Error"; + + /** + * 验证码有效期(分钟) + */ + Integer CAPTCHA_EXPIRATION = 2; + + /** + * 顶级父级id + */ + Long TOP_PARENT_ID = 0L; + +} + diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/GlobalConstants.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/GlobalConstants.java new file mode 100644 index 0000000..65868c0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/GlobalConstants.java @@ -0,0 +1,34 @@ +package org.dromara.common.core.constant; + +/** + * 全局的key常量 (业务无关的key) + * + * @author pengles + */ +public interface GlobalConstants { + + /** + * 全局 redis key (业务无关的key) + */ + String GLOBAL_REDIS_KEY = "global:"; + + /** + * 验证码 redis key + */ + String CAPTCHA_CODE_KEY = GLOBAL_REDIS_KEY + "captcha_codes:"; + + /** + * 防重提交 redis key + */ + String REPEAT_SUBMIT_KEY = GLOBAL_REDIS_KEY + "repeat_submit:"; + + /** + * 限流 redis key + */ + String RATE_LIMIT_KEY = GLOBAL_REDIS_KEY + "rate_limit:"; + + /** + * 三方认证 redis key + */ + String SOCIAL_AUTH_CODE_KEY = GLOBAL_REDIS_KEY + "social_auth_codes:"; +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/HttpStatus.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/HttpStatus.java new file mode 100644 index 0000000..70c3383 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/HttpStatus.java @@ -0,0 +1,93 @@ +package org.dromara.common.core.constant; + +/** + * 返回状态码 + * + * @author pengles + */ +public interface HttpStatus { + /** + * 操作成功 + */ + int SUCCESS = 200; + + /** + * 对象创建成功 + */ + int CREATED = 201; + + /** + * 请求已经被接受 + */ + int ACCEPTED = 202; + + /** + * 操作已经执行成功,但是没有返回数据 + */ + int NO_CONTENT = 204; + + /** + * 资源已被移除 + */ + int MOVED_PERM = 301; + + /** + * 重定向 + */ + int SEE_OTHER = 303; + + /** + * 资源没有被修改 + */ + int NOT_MODIFIED = 304; + + /** + * 参数列表错误(缺少,格式不匹配) + */ + int BAD_REQUEST = 400; + + /** + * 未授权 + */ + int UNAUTHORIZED = 401; + + /** + * 访问受限,授权过期 + */ + int FORBIDDEN = 403; + + /** + * 资源,服务未找到 + */ + int NOT_FOUND = 404; + + /** + * 不允许的http方法 + */ + int BAD_METHOD = 405; + + /** + * 资源冲突,或者资源被锁 + */ + int CONFLICT = 409; + + /** + * 不支持的数据,媒体类型 + */ + int UNSUPPORTED_TYPE = 415; + + /** + * 系统内部错误 + */ + int ERROR = 500; + + /** + * 接口未实现 + */ + int NOT_IMPLEMENTED = 501; + + /** + * 系统警告消息 + */ + int WARN = 601; +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/RegexConstants.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/RegexConstants.java new file mode 100644 index 0000000..f1e04f7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/RegexConstants.java @@ -0,0 +1,59 @@ +package org.dromara.common.core.constant; + +import cn.hutool.core.lang.RegexPool; + +/** + * 常用正则表达式字符串 + *

+ * 常用正则表达式集合,更多正则见: https://any86.github.io/any-rule/ + * + * @author Feng + */ +public interface RegexConstants extends RegexPool { + + /** + * 字典类型必须以字母开头,且只能为(小写字母,数字,下滑线) + */ + String DICTIONARY_TYPE = "^[a-z][a-z0-9_]*$"; + + /** + * 权限标识必须符合以下格式: + * 1. 标准格式:xxx:yyy:zzz + * - 第一部分(xxx):只能包含字母、数字和下划线(_),不能使用 `*` + * - 第二部分(yyy):可以包含字母、数字、下划线(_)和 `*` + * - 第三部分(zzz):可以包含字母、数字、下划线(_)和 `*` + * 2. 允许空字符串(""),表示没有权限标识 + */ + String PERMISSION_STRING = "^$|^[a-zA-Z0-9_]+:[a-zA-Z0-9_*]+:[a-zA-Z0-9_*]+$"; + + /** + * 身份证号码(后6位) + */ + String ID_CARD_LAST_6 = "^(([0-2][1-9])|10|20|30|31)\\d{3}[0-9Xx]$"; + + /** + * QQ号码 + */ + String QQ_NUMBER = "^[1-9][0-9]\\d{4,9}$"; + + /** + * 邮政编码 + */ + String POSTAL_CODE = "^[1-9]\\d{5}$"; + + /** + * 注册账号 + */ + String ACCOUNT = "^[a-zA-Z][a-zA-Z0-9_]{4,15}$"; + + /** + * 密码:包含至少8个字符,包括大写字母、小写字母、数字和特殊字符 + */ + String PASSWORD = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$"; + + /** + * 通用状态(0表示正常,1表示停用) + */ + String STATUS = "^[01]$"; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java new file mode 100644 index 0000000..0220b46 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java @@ -0,0 +1,95 @@ +package org.dromara.common.core.constant; + +/** + * 系统常量信息 + * + * @author pengles + */ +public interface SystemConstants { + + /** + * 正常状态 + */ + String NORMAL = "0"; + + /** + * 异常状态 + */ + String DISABLE = "1"; + + /** + * 是否为系统默认(是) + */ + String YES = "Y"; + + /** + * 是否为系统默认(否) + */ + String NO = "N"; + + /** + * 是否菜单外链(是) + */ + String YES_FRAME = "0"; + + /** + * 是否菜单外链(否) + */ + String NO_FRAME = "1"; + + /** + * 菜单类型(目录) + */ + String TYPE_DIR = "M"; + + /** + * 菜单类型(菜单) + */ + String TYPE_MENU = "C"; + + /** + * 菜单类型(按钮) + */ + String TYPE_BUTTON = "F"; + + /** + * Layout组件标识 + */ + String LAYOUT = "Layout"; + + /** + * ParentView组件标识 + */ + String PARENT_VIEW = "ParentView"; + + /** + * InnerLink组件标识 + */ + String INNER_LINK = "InnerLink"; + + /** + * 超级管理员角色 roleKey + */ + String SUPER_ADMIN_ROLE_KEY = "superadmin"; + + /** + * 管理员角色 roleKey (保留 roleKey) + */ + String ADMIN_ROLE_KEY = "admin"; + + /** + * 超级管理员ID + */ + Long SUPER_ADMIN_ID = 1L; + + /** + * 根部门祖级列表 + */ + String ROOT_DEPT_ANCESTORS = "0"; + + /** + * 默认部门 ID + */ + Long DEFAULT_DEPT_ID = 100L; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/UserConstants.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/UserConstants.java new file mode 100644 index 0000000..8323bb8 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/UserConstants.java @@ -0,0 +1,118 @@ +package org.dromara.common.core.constant; + +/** + * 用户常量信息 + * + * @author pengles + */ +public interface UserConstants { + + /** + * 平台内系统用户的唯一标志 + */ + String SYS_USER = "SYS_USER"; + + /** + * App用户类型 + */ + String APP_USER_TYPE = "APP_USER"; + + /** + * 正常状态 + */ + String NORMAL = "0"; + + /** + * 异常状态 + */ + String EXCEPTION = "1"; + + /** + * 用户封禁状态 + */ + String USER_DISABLE = "1"; + + /** + * 角色封禁状态 + */ + String ROLE_DISABLE = "1"; + + /** + * 部门正常状态 + */ + String DEPT_NORMAL = "0"; + + /** + * 部门停用状态 + */ + String DEPT_DISABLE = "1"; + + /** + * 字典正常状态 + */ + String DICT_NORMAL = "0"; + + /** + * 是否为系统默认(是) + */ + String YES = "Y"; + + /** + * 是否菜单外链(是) + */ + String YES_FRAME = "0"; + + /** + * 是否菜单外链(否) + */ + String NO_FRAME = "1"; + + /** + * 菜单类型(目录) + */ + String TYPE_DIR = "M"; + + /** + * 菜单类型(菜单) + */ + String TYPE_MENU = "C"; + + /** + * 菜单类型(按钮) + */ + String TYPE_BUTTON = "F"; + + /** + * Layout组件标识 + */ + String LAYOUT = "Layout"; + + /** + * ParentView组件标识 + */ + String PARENT_VIEW = "ParentView"; + + /** + * InnerLink组件标识 + */ + String INNER_LINK = "InnerLink"; + + /** + * 校验返回结果码 + */ + String UNIQUE = "0"; + String NOT_UNIQUE = "1"; + + /** + * 用户名长度限制 + */ + int USERNAME_MIN_LENGTH = 2; + int USERNAME_MAX_LENGTH = 20; + + /** + * 密码长度限制 + */ + int PASSWORD_MIN_LENGTH = 5; + int PASSWORD_MAX_LENGTH = 20; + +} \ No newline at end of file diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java new file mode 100644 index 0000000..81a287f --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java @@ -0,0 +1,122 @@ +package org.dromara.common.core.domain; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.dromara.common.core.constant.HttpStatus; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 响应信息主体 + * + * @author pengles + */ +@Data +@NoArgsConstructor +public class R implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 成功 + */ + public static final int SUCCESS = 200; + + /** + * 失败 + */ + public static final int FAIL = 500; + + private int code; + + private String msg; + + private T data; + + public static R ok() { + return restResult(null, SUCCESS, "操作成功"); + } + + public static R ok(T data) { + return restResult(data, SUCCESS, "操作成功"); + } + + public static R ok(Boolean success) { + return restResult(null, success ? SUCCESS : FAIL, success ? "操作成功" : "操作失败"); + } + + public static R ok(Boolean success, String msg) { + return restResult(null, success ? SUCCESS : FAIL, msg); + } + + public static R ok(Boolean success, T data) { + return restResult(data, success ? SUCCESS : FAIL, success ? "操作成功" : "操作失败"); + } + + public static R ok(String msg) { + return restResult(null, SUCCESS, msg); + } + + public static R ok(String msg, T data) { + return restResult(data, SUCCESS, msg); + } + + public static R fail() { + return restResult(null, FAIL, "操作失败"); + } + + public static R fail(String msg) { + return restResult(null, FAIL, msg); + } + + public static R fail(T data) { + return restResult(data, FAIL, "操作失败"); + } + + public static R fail(String msg, T data) { + return restResult(data, FAIL, msg); + } + + public static R fail(int code, String msg) { + return restResult(null, code, msg); + } + + /** + * 返回警告消息 + * + * @param msg 返回内容 + * @return 警告消息 + */ + public static R warn(String msg) { + return restResult(null, HttpStatus.WARN, msg); + } + + /** + * 返回警告消息 + * + * @param msg 返回内容 + * @param data 数据对象 + * @return 警告消息 + */ + public static R warn(String msg, T data) { + return restResult(data, HttpStatus.WARN, msg); + } + + private static R restResult(T data, int code, String msg) { + R r = new R<>(); + r.setCode(code); + r.setData(data); + r.setMsg(msg); + return r; + } + + public static Boolean isError(R ret) { + return !isSuccess(ret); + } + + public static Boolean isSuccess(R ret) { + return R.SUCCESS == ret.getCode(); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/CompleteTaskDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/CompleteTaskDTO.java new file mode 100644 index 0000000..2e63f8a --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/CompleteTaskDTO.java @@ -0,0 +1,71 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 办理任务请求对象 + * + * @author may + */ +@Data +public class CompleteTaskDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 任务id + */ + private Long taskId; + + /** + * 附件id + */ + private String fileId; + + /** + * 抄送人员 + */ + private List flowCopyList; + + /** + * 消息类型 + */ + private List messageType; + + /** + * 办理意见 + */ + private String message; + + /** + * 消息通知 + */ + private String notice; + + /** + * 流程变量 + */ + private Map variables; + + /** + * 扩展变量(此处为逗号分隔的ossId) + */ + private String ext; + + public Map getVariables() { + if (variables == null) { + return new HashMap<>(16); + } + variables.entrySet().removeIf(entry -> Objects.isNull(entry.getValue())); + return variables; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DeptDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DeptDTO.java new file mode 100644 index 0000000..7b748b0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DeptDTO.java @@ -0,0 +1,36 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 部门 + * + * @author AprilWind + */ +@Data +@NoArgsConstructor +public class DeptDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 父部门ID + */ + private Long parentId; + + /** + * 部门名称 + */ + private String deptName; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DictDataDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DictDataDTO.java new file mode 100644 index 0000000..dff1a75 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DictDataDTO.java @@ -0,0 +1,41 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 字典数据DTO + * + * @author AprilWind + */ +@Data +@NoArgsConstructor +public class DictDataDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 字典标签 + */ + private String dictLabel; + + /** + * 字典键值 + */ + private String dictValue; + + /** + * 是否默认(Y是 N否) + */ + private String isDefault; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DictTypeDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DictTypeDTO.java new file mode 100644 index 0000000..43ab142 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/DictTypeDTO.java @@ -0,0 +1,41 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 字典类型DTO + * + * @author AprilWind + */ +@Data +@NoArgsConstructor +public class DictTypeDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 字典主键 + */ + private Long dictId; + + /** + * 字典名称 + */ + private String dictName; + + /** + * 字典类型 + */ + private String dictType; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/FlowCopyDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/FlowCopyDTO.java new file mode 100644 index 0000000..2f20b21 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/FlowCopyDTO.java @@ -0,0 +1,30 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + + +/** + * 抄送 + * + * @author may + */ +@Data +public class FlowCopyDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户id + */ + private Long userId; + + /** + * 用户名称 + */ + private String userName; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/OssDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/OssDTO.java new file mode 100644 index 0000000..823af0c --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/OssDTO.java @@ -0,0 +1,46 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * OSS对象 + * + * @author pengles + */ +@Data +@NoArgsConstructor +public class OssDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 对象存储主键 + */ + private Long ossId; + + /** + * 文件名 + */ + private String fileName; + + /** + * 原名 + */ + private String originalName; + + /** + * 文件后缀名 + */ + private String fileSuffix; + + /** + * URL地址 + */ + private String url; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/PostDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/PostDTO.java new file mode 100644 index 0000000..7536ee3 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/PostDTO.java @@ -0,0 +1,46 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 岗位 + * + * @author AprilWind + */ +@Data +@NoArgsConstructor +public class PostDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 岗位ID + */ + private Long postId; + + /** + * 部门id + */ + private Long deptId; + + /** + * 岗位编码 + */ + private String postCode; + + /** + * 岗位名称 + */ + private String postName; + + /** + * 岗位类别编码 + */ + private String postCategory; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/RoleDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/RoleDTO.java new file mode 100644 index 0000000..70e81de --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/RoleDTO.java @@ -0,0 +1,42 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 角色 + * + * @author pengles + */ + +@Data +@NoArgsConstructor +public class RoleDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 角色ID + */ + private Long roleId; + + /** + * 角色名称 + */ + private String roleName; + + /** + * 角色权限 + */ + private String roleKey; + + /** + * 数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限 5:仅本人数据权限 6:部门及以下或本人数据权限) + */ + private String dataScope; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/StartProcessDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/StartProcessDTO.java new file mode 100644 index 0000000..3934ada --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/StartProcessDTO.java @@ -0,0 +1,45 @@ +package org.dromara.common.core.domain.dto; + + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * 启动流程对象 + * + * @author may + */ +@Data +public class StartProcessDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 业务唯一值id + */ + private String businessId; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 流程变量,前端会提交一个元素{'entity': {业务详情数据对象}} + */ + private Map variables; + + public Map getVariables() { + if (variables == null) { + return new HashMap<>(16); + } + variables.entrySet().removeIf(entry -> Objects.isNull(entry.getValue())); + return variables; + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/StartProcessReturnDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/StartProcessReturnDTO.java new file mode 100644 index 0000000..8a4bda3 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/StartProcessReturnDTO.java @@ -0,0 +1,30 @@ +package org.dromara.common.core.domain.dto; + + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 启动流程返回对象 + * + * @author pengles + */ +@Data +public class StartProcessReturnDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 流程实例id + */ + private Long processInstanceId; + + /** + * 任务id + */ + private Long taskId; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/TaskAssigneeDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/TaskAssigneeDTO.java new file mode 100644 index 0000000..85893e1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/TaskAssigneeDTO.java @@ -0,0 +1,101 @@ +package org.dromara.common.core.domain.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 任务受让人 + * + * @author AprilWind + */ +@Data +@NoArgsConstructor +public class TaskAssigneeDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 总大小 + */ + private Long total = 0L; + + /** + * + */ + private List list; + + public TaskAssigneeDTO(Long total, List list) { + this.total = total; + this.list = list; + } + + /** + * 将源列表转换为 TaskHandler 列表 + * + * @param 通用类型 + * @param sourceList 待转换的源列表 + * @param storageId 提取 storageId 的函数 + * @param handlerCode 提取 handlerCode 的函数 + * @param handlerName 提取 handlerName 的函数 + * @param groupName 提取 groupName 的函数 + * @param createTimeMapper 提取 createTime 的函数 + * @return 转换后的 TaskHandler 列表 + */ + public static List convertToHandlerList( + List sourceList, + Function storageId, + Function handlerCode, + Function handlerName, + Function groupName, + Function createTimeMapper) { + return sourceList.stream() + .map(item -> new TaskHandler( + String.valueOf(storageId.apply(item)), + handlerCode.apply(item), + handlerName.apply(item), + groupName != null ? String.valueOf(groupName.apply(item)) : null, + createTimeMapper.apply(item) + )).collect(Collectors.toList()); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class TaskHandler { + + /** + * 主键 + */ + private String storageId; + + /** + * 权限编码 + */ + private String handlerCode; + + /** + * 权限名称 + */ + private String handlerName; + + /** + * 权限分组 + */ + private String groupName; + + /** + * 创建时间 + */ + private Date createTime; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/UserDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/UserDTO.java new file mode 100644 index 0000000..2e5dbc8 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/UserDTO.java @@ -0,0 +1,73 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * 用户 + * + * @author pengles + */ +@Data +@NoArgsConstructor +public class UserDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + private Long userId; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 用户账号 + */ + private String userName; + + /** + * 用户昵称 + */ + private String nickName; + + /** + * 用户类型(sys_user系统用户) + */ + private String userType; + + /** + * 用户邮箱 + */ + private String email; + + /** + * 手机号码 + */ + private String phonenumber; + + /** + * 用户性别(0男 1女 2未知) + */ + private String sex; + + /** + * 帐号状态(0正常 1停用) + */ + private String status; + + /** + * 创建时间 + */ + private Date createTime; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/UserOnlineDTO.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/UserOnlineDTO.java new file mode 100644 index 0000000..b74905c --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/dto/UserOnlineDTO.java @@ -0,0 +1,72 @@ +package org.dromara.common.core.domain.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 当前在线会话 + * + * @author pengles + */ + +@Data +@NoArgsConstructor +public class UserOnlineDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 会话编号 + */ + private String tokenId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 用户名称 + */ + private String userName; + + /** + * 客户端 + */ + private String clientKey; + + /** + * 设备类型 + */ + private String deviceType; + + /** + * 登录IP地址 + */ + private String ipaddr; + + /** + * 登录地址 + */ + private String loginLocation; + + /** + * 浏览器类型 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; + + /** + * 登录时间 + */ + private Long loginTime; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessDeleteEvent.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessDeleteEvent.java new file mode 100644 index 0000000..03e7867 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessDeleteEvent.java @@ -0,0 +1,29 @@ +package org.dromara.common.core.domain.event; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 删除流程监听 + * + * @author AprilWind + */ +@Data +public class ProcessDeleteEvent implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 业务id + */ + private String businessId; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessEvent.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessEvent.java new file mode 100644 index 0000000..1973d21 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessEvent.java @@ -0,0 +1,60 @@ +package org.dromara.common.core.domain.event; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Map; + +/** + * 总体流程监听 + * + * @author may + */ +@Data +public class ProcessEvent implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 业务id + */ + private String businessId; + + /** + * 节点类型(0开始节点 1中间节点 2结束节点 3互斥网关 4并行网关) + */ + private Integer nodeType; + + /** + * 流程节点编码 + */ + private String nodeCode; + + /** + * 流程节点名称 + */ + private String nodeName; + + /** + * 流程状态 + */ + private String status; + + /** + * 办理参数 + */ + private Map params; + + /** + * 当为true时为申请人节点办理 + */ + private Boolean submit; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessTaskEvent.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessTaskEvent.java new file mode 100644 index 0000000..b13f157 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/ProcessTaskEvent.java @@ -0,0 +1,54 @@ +package org.dromara.common.core.domain.event; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 流程任务监听 + * + * @author may + */ +@Data +public class ProcessTaskEvent implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 节点类型(0开始节点 1中间节点 2结束节点 3互斥网关 4并行网关) + */ + private Integer nodeType; + + /** + * 流程节点编码 + */ + private String nodeCode; + + /** + * 流程节点名称 + */ + private String nodeName; + + /** + * 任务id + */ + private Long taskId; + + /** + * 业务id + */ + private String businessId; + + /** + * 流程状态 + */ + private String status; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/EmailLoginBody.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/EmailLoginBody.java new file mode 100644 index 0000000..9dc69a3 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/EmailLoginBody.java @@ -0,0 +1,31 @@ +package org.dromara.common.core.domain.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 邮件登录对象 + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +public class EmailLoginBody extends LoginBody { + + /** + * 邮箱 + */ + @NotBlank(message = "{user.email.not.blank}") + @Email(message = "{user.email.not.valid}") + private String email; + + /** + * 邮箱code + */ + @NotBlank(message = "{email.code.not.blank}") + private String emailCode; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/LoginBody.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/LoginBody.java new file mode 100644 index 0000000..41e5de4 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/LoginBody.java @@ -0,0 +1,43 @@ +package org.dromara.common.core.domain.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 用户登录对象 + * + * @author pengles + */ + +@Data +public class LoginBody implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 客户端id + */ + @NotBlank(message = "{auth.clientid.not.blank}") + private String clientId; + + /** + * 授权类型 + */ + @NotBlank(message = "{auth.grant.type.not.blank}") + private String grantType; + + /** + * 验证码 + */ + private String code; + + /** + * 唯一标识 + */ + private String uuid; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/LoginUser.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/LoginUser.java new file mode 100644 index 0000000..84cf224 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/LoginUser.java @@ -0,0 +1,143 @@ +package org.dromara.common.core.domain.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.dromara.common.core.domain.dto.PostDTO; +import org.dromara.common.core.domain.dto.RoleDTO; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; +import java.util.Set; + +/** + * 登录用户身份权限 + * + * @author pengles + */ +@Data +@NoArgsConstructor +public class LoginUser implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + private Long userId; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 部门类别编码 + */ + private String deptCategory; + + /** + * 部门名 + */ + private String deptName; + + /** + * 用户唯一标识 + */ + private String token; + + /** + * 用户类型 + */ + private String userType; + + /** + * 登录时间 + */ + private Long loginTime; + + /** + * 过期时间 + */ + private Long expireTime; + + /** + * 登录IP地址 + */ + private String ipaddr; + + /** + * 登录地点 + */ + private String loginLocation; + + /** + * 浏览器类型 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; + + /** + * 菜单权限 + */ + private Set menuPermission; + + /** + * 角色权限 + */ + private Set rolePermission; + + /** + * 用户名 + */ + private String username; + + /** + * 用户昵称 + */ + private String nickname; + + /** + * 角色对象 + */ + private List roles; + + /** + * 岗位对象 + */ + private List posts; + + /** + * 数据权限 当前角色ID + */ + private Long roleId; + + /** + * 客户端 + */ + private String clientKey; + + /** + * 设备类型 + */ + private String deviceType; + + /** + * 获取登录id + */ + public String getLoginId() { + if (userType == null) { + throw new IllegalArgumentException("用户类型不能为空"); + } + if (userId == null) { + throw new IllegalArgumentException("用户ID不能为空"); + } + return userType + ":" + userId; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/PasswordLoginBody.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/PasswordLoginBody.java new file mode 100644 index 0000000..40f2aa5 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/PasswordLoginBody.java @@ -0,0 +1,31 @@ +package org.dromara.common.core.domain.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.validator.constraints.Length; + +/** + * 密码登录对象 + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PasswordLoginBody extends LoginBody { + + /** + * 用户名 + */ + @NotBlank(message = "{user.username.not.blank}") + @Length(min = 2, max = 30, message = "{user.username.length.valid}") + private String username; + + /** + * 用户密码 + */ + @NotBlank(message = "{user.password.not.blank}") + @Length(min = 5, max = 30, message = "{user.password.length.valid}") + private String password; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/RegisterBody.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/RegisterBody.java new file mode 100644 index 0000000..d37174a --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/RegisterBody.java @@ -0,0 +1,33 @@ +package org.dromara.common.core.domain.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.validator.constraints.Length; + +/** + * 用户注册对象 + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class RegisterBody extends LoginBody { + + /** + * 用户名 + */ + @NotBlank(message = "{user.username.not.blank}") + @Length(min = 2, max = 30, message = "{user.username.length.valid}") + private String username; + + /** + * 用户密码 + */ + @NotBlank(message = "{user.password.not.blank}") + @Length(min = 5, max = 30, message = "{user.password.length.valid}") + private String password; + + private String userType; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/SmsLoginBody.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/SmsLoginBody.java new file mode 100644 index 0000000..28bff61 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/SmsLoginBody.java @@ -0,0 +1,29 @@ +package org.dromara.common.core.domain.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 短信登录对象 + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +public class SmsLoginBody extends LoginBody { + + /** + * 手机号 + */ + @NotBlank(message = "{user.phonenumber.not.blank}") + private String phonenumber; + + /** + * 短信code + */ + @NotBlank(message = "{sms.code.not.blank}") + private String smsCode; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/SocialLoginBody.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/SocialLoginBody.java new file mode 100644 index 0000000..dabf4a6 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/SocialLoginBody.java @@ -0,0 +1,35 @@ +package org.dromara.common.core.domain.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 三方登录对象 + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +public class SocialLoginBody extends LoginBody { + + /** + * 第三方登录平台 + */ + @NotBlank(message = "{social.source.not.blank}") + private String source; + + /** + * 第三方登录code + */ + @NotBlank(message = "{social.code.not.blank}") + private String socialCode; + + /** + * 第三方登录socialState + */ + @NotBlank(message = "{social.state.not.blank}") + private String socialState; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/TaskAssigneeBody.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/TaskAssigneeBody.java new file mode 100644 index 0000000..0cbed2f --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/TaskAssigneeBody.java @@ -0,0 +1,56 @@ +package org.dromara.common.core.domain.model; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 任务受让人 + * + * @author AprilWind + */ +@Data +@NoArgsConstructor +public class TaskAssigneeBody implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 权限编码 + */ + private String handlerCode; + + /** + * 权限名称 + */ + private String handlerName; + + /** + * 权限分组 + */ + private String groupId; + + /** + * 开始时间 + */ + private String beginTime; + + /** + * 结束时间 + */ + private String endTime; + + /** + * 当前页 + */ + private Integer pageNum = 1; + + /** + * 每页显示条数 + */ + private Integer pageSize = 10; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/XcxLoginBody.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/XcxLoginBody.java new file mode 100644 index 0000000..01fbd53 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/XcxLoginBody.java @@ -0,0 +1,28 @@ +package org.dromara.common.core.domain.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 三方登录对象 + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +public class XcxLoginBody extends LoginBody { + + /** + * 小程序id(多个小程序时使用) + */ + private String appid; + + /** + * 小程序code + */ + @NotBlank(message = "{xcx.code.not.blank}") + private String code; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/XcxLoginUser.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/XcxLoginUser.java new file mode 100644 index 0000000..f741d1c --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/model/XcxLoginUser.java @@ -0,0 +1,32 @@ +package org.dromara.common.core.domain.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serial; + +/** + * 小程序登录用户身份权限 + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class XcxLoginUser extends LoginUser { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * openid + */ + private String openid; + + /** + * unionId + */ + private String unionId; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/BusinessStatusEnum.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/BusinessStatusEnum.java new file mode 100644 index 0000000..c1660ee --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/BusinessStatusEnum.java @@ -0,0 +1,215 @@ +package org.dromara.common.core.enums; + +import cn.hutool.core.util.StrUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 业务状态枚举 + * + * @author may + */ +@Getter +@AllArgsConstructor +public enum BusinessStatusEnum { + + /** + * 已撤销 + */ + CANCEL("cancel", "已撤销"), + + /** + * 草稿 + */ + DRAFT("draft", "草稿"), + + /** + * 待审核 + */ + WAITING("waiting", "待审核"), + + /** + * 已完成 + */ + FINISH("finish", "已完成"), + + /** + * 已作废 + */ + INVALID("invalid", "已作废"), + + /** + * 已退回 + */ + BACK("back", "已退回"), + + /** + * 已终止 + */ + TERMINATION("termination", "已终止"); + + /** + * 状态 + */ + private final String status; + + /** + * 描述 + */ + private final String desc; + + private static final Map STATUS_MAP = Arrays.stream(BusinessStatusEnum.values()) + .collect(Collectors.toConcurrentMap(BusinessStatusEnum::getStatus, Function.identity())); + + /** + * 根据状态获取对应的 BusinessStatusEnum 枚举 + * + * @param status 业务状态码 + * @return 对应的 BusinessStatusEnum 枚举,如果找不到则返回 null + */ + public static BusinessStatusEnum getByStatus(String status) { + // 使用 STATUS_MAP 获取对应的枚举,若找不到则返回 null + return STATUS_MAP.get(status); + } + + /** + * 根据状态获取对应的业务状态描述信息 + * + * @param status 业务状态码 + * @return 返回业务状态描述,若状态码为空或未找到对应的枚举,返回空字符串 + */ + public static String findByStatus(String status) { + if (StringUtils.isBlank(status)) { + return StrUtil.EMPTY; + } + BusinessStatusEnum statusEnum = STATUS_MAP.get(status); + return (statusEnum != null) ? statusEnum.getDesc() : StrUtil.EMPTY; + } + + /** + * 判断是否为指定的状态之一:草稿、已撤销或已退回 + * + * @param status 要检查的状态 + * @return 如果状态为草稿、已撤销或已退回之一,则返回 true;否则返回 false + */ + public static boolean isDraftOrCancelOrBack(String status) { + return DRAFT.status.equals(status) || CANCEL.status.equals(status) || BACK.status.equals(status); + } + + /** + * 判断是否为撤销,退回,作废,终止 + * + * @param status status + * @return 结果 + */ + public static boolean initialState(String status) { + return CANCEL.status.equals(status) || BACK.status.equals(status) || INVALID.status.equals(status) || TERMINATION.status.equals(status); + } + + /** + * 获取运行中的实例状态列表 + * + * @return 包含运行中实例状态的不可变列表 + * (包含 DRAFT、WAITING、BACK 和 CANCEL 状态) + */ + public static List runningStatus() { + return Arrays.asList(DRAFT.status, WAITING.status, BACK.status, CANCEL.status); + } + + /** + * 获取结束实例的状态列表 + * + * @return 包含结束实例状态的不可变列表 + * (包含 FINISH、INVALID 和 TERMINATION 状态) + */ + public static List finishStatus() { + return Arrays.asList(FINISH.status, INVALID.status, TERMINATION.status); + } + + /** + * 启动流程校验 + * + * @param status 状态 + */ + public static void checkStartStatus(String status) { + if (WAITING.getStatus().equals(status)) { + throw new ServiceException("该单据已提交过申请,正在审批中!"); + } else if (FINISH.getStatus().equals(status)) { + throw new ServiceException("该单据已完成申请!"); + } else if (INVALID.getStatus().equals(status)) { + throw new ServiceException("该单据已作废!"); + } else if (TERMINATION.getStatus().equals(status)) { + throw new ServiceException("该单据已终止!"); + } else if (StringUtils.isBlank(status)) { + throw new ServiceException("流程状态为空!"); + } + } + + /** + * 撤销流程校验 + * + * @param status 状态 + */ + public static void checkCancelStatus(String status) { + if (CANCEL.getStatus().equals(status)) { + throw new ServiceException("该单据已撤销!"); + } else if (FINISH.getStatus().equals(status)) { + throw new ServiceException("该单据已完成申请!"); + } else if (INVALID.getStatus().equals(status)) { + throw new ServiceException("该单据已作废!"); + } else if (TERMINATION.getStatus().equals(status)) { + throw new ServiceException("该单据已终止!"); + } else if (BACK.getStatus().equals(status)) { + throw new ServiceException("该单据已退回!"); + } else if (StringUtils.isBlank(status)) { + throw new ServiceException("流程状态为空!"); + } + } + + /** + * 驳回流程校验 + * + * @param status 状态 + */ + public static void checkBackStatus(String status) { + if (BACK.getStatus().equals(status)) { + throw new ServiceException("该单据已退回!"); + } else if (FINISH.getStatus().equals(status)) { + throw new ServiceException("该单据已完成申请!"); + } else if (INVALID.getStatus().equals(status)) { + throw new ServiceException("该单据已作废!"); + } else if (TERMINATION.getStatus().equals(status)) { + throw new ServiceException("该单据已终止!"); + } else if (CANCEL.getStatus().equals(status)) { + throw new ServiceException("该单据已撤销!"); + } else if (StringUtils.isBlank(status)) { + throw new ServiceException("流程状态为空!"); + } + } + + /** + * 作废,终止流程校验 + * + * @param status 状态 + */ + public static void checkInvalidStatus(String status) { + if (FINISH.getStatus().equals(status)) { + throw new ServiceException("该单据已完成申请!"); + } else if (INVALID.getStatus().equals(status)) { + throw new ServiceException("该单据已作废!"); + } else if (TERMINATION.getStatus().equals(status)) { + throw new ServiceException("该单据已终止!"); + } else if (StringUtils.isBlank(status)) { + throw new ServiceException("流程状态为空!"); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/DeviceType.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/DeviceType.java new file mode 100644 index 0000000..0fefa6b --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/DeviceType.java @@ -0,0 +1,39 @@ +package org.dromara.common.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 设备类型 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum DeviceType { + + /** + * pc端 + */ + PC("pc"), + + /** + * app端 + */ + APP("app"), + + /** + * 小程序端 + */ + XCX("xcx"), + + /** + * 第三方社交登录平台 + */ + SOCIAL("social"); + + /** + * 设备标识 + */ + private final String device; +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/FormatsType.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/FormatsType.java new file mode 100644 index 0000000..8d4b6d9 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/FormatsType.java @@ -0,0 +1,146 @@ +package org.dromara.common.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.dromara.common.core.utils.StringUtils; + +/* + * 日期格式 + * "yyyy":4位数的年份,例如:2023年表示为"2023"。 + * "yy":2位数的年份,例如:2023年表示为"23"。 + * "MM":2位数的月份,取值范围为01到12,例如:7月表示为"07"。 + * "M":不带前导零的月份,取值范围为1到12,例如:7月表示为"7"。 + * "dd":2位数的日期,取值范围为01到31,例如:22日表示为"22"。 + * "d":不带前导零的日期,取值范围为1到31,例如:22日表示为"22"。 + * "EEEE":星期的全名,例如:星期三表示为"Wednesday"。 + * "E":星期的缩写,例如:星期三表示为"Wed"。 + * "DDD" 或 "D":一年中的第几天,取值范围为001到366,例如:第200天表示为"200"。 + * 时间格式 + * "HH":24小时制的小时数,取值范围为00到23,例如:下午5点表示为"17"。 + * "hh":12小时制的小时数,取值范围为01到12,例如:下午5点表示为"05"。 + * "mm":分钟数,取值范围为00到59,例如:30分钟表示为"30"。 + * "ss":秒数,取值范围为00到59,例如:45秒表示为"45"。 + * "SSS":毫秒数,取值范围为000到999,例如:123毫秒表示为"123"。 + */ + +/** + * 日期格式与时间格式枚举 + */ +@Getter +@AllArgsConstructor +public enum FormatsType { + + /** + * 例如:2023年表示为"23" + */ + YY("yy"), + + /** + * 例如:2023年表示为"2023" + */ + YYYY("yyyy"), + + /** + * 例例如,2023年7月可以表示为 "2023-07" + */ + YYYY_MM("yyyy-MM"), + + /** + * 例如,日期 "2023年7月22日" 可以表示为 "2023-07-22" + */ + YYYY_MM_DD("yyyy-MM-dd"), + + /** + * 例如,当前时间如果是 "2023年7月22日下午3点30分",则可以表示为 "2023-07-22 15:30" + */ + YYYY_MM_DD_HH_MM("yyyy-MM-dd HH:mm"), + + /** + * 例如,当前时间如果是 "2023年7月22日下午3点30分45秒",则可以表示为 "2023-07-22 15:30:45" + */ + YYYY_MM_DD_HH_MM_SS("yyyy-MM-dd HH:mm:ss"), + + /** + * 例如:下午3点30分45秒,表示为 "15:30:45" + */ + HH_MM_SS("HH:mm:ss"), + + /** + * 例例如,2023年7月可以表示为 "2023/07" + */ + YYYY_MM_SLASH("yyyy/MM"), + + /** + * 例如,日期 "2023年7月22日" 可以表示为 "2023/07/22" + */ + YYYY_MM_DD_SLASH("yyyy/MM/dd"), + + /** + * 例如,当前时间如果是 "2023年7月22日下午3点30分45秒",则可以表示为 "2023/07/22 15:30:45" + */ + YYYY_MM_DD_HH_MM_SLASH("yyyy/MM/dd HH:mm"), + + /** + * 例如,当前时间如果是 "2023年7月22日下午3点30分45秒",则可以表示为 "2023/07/22 15:30:45" + */ + YYYY_MM_DD_HH_MM_SS_SLASH("yyyy/MM/dd HH:mm:ss"), + + /** + * 例例如,2023年7月可以表示为 "2023.07" + */ + YYYY_MM_DOT("yyyy.MM"), + + /** + * 例如,日期 "2023年7月22日" 可以表示为 "2023.07.22" + */ + YYYY_MM_DD_DOT("yyyy.MM.dd"), + + /** + * 例如,当前时间如果是 "2023年7月22日下午3点30分",则可以表示为 "2023.07.22 15:30" + */ + YYYY_MM_DD_HH_MM_DOT("yyyy.MM.dd HH:mm"), + + /** + * 例如,当前时间如果是 "2023年7月22日下午3点30分45秒",则可以表示为 "2023.07.22 15:30:45" + */ + YYYY_MM_DD_HH_MM_SS_DOT("yyyy.MM.dd HH:mm:ss"), + + /** + * 例如,2023年7月可以表示为 "202307" + */ + YYYYMM("yyyyMM"), + + /** + * 例如,2023年7月22日可以表示为 "20230722" + */ + YYYYMMDD("yyyyMMdd"), + + /** + * 例如,2023年7月22日下午3点可以表示为 "2023072215" + */ + YYYYMMDDHH("yyyyMMddHH"), + + /** + * 例如,2023年7月22日下午3点30分可以表示为 "202307221530" + */ + YYYYMMDDHHMM("yyyyMMddHHmm"), + + /** + * 例如,2023年7月22日下午3点30分45秒可以表示为 "20230722153045" + */ + YYYYMMDDHHMMSS("yyyyMMddHHmmss"); + + /** + * 时间格式 + */ + private final String timeFormat; + + public static FormatsType getFormatsType(String str) { + for (FormatsType value : values()) { + if (StringUtils.contains(str, value.getTimeFormat())) { + return value; + } + } + throw new RuntimeException("'FormatsType' not found By " + str); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/LoginType.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/LoginType.java new file mode 100644 index 0000000..af69d08 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/LoginType.java @@ -0,0 +1,44 @@ +package org.dromara.common.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 登录类型 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum LoginType { + + /** + * 密码登录 + */ + PASSWORD("user.password.retry.limit.exceed", "user.password.retry.limit.count"), + + /** + * 短信登录 + */ + SMS("sms.code.retry.limit.exceed", "sms.code.retry.limit.count"), + + /** + * 邮箱登录 + */ + EMAIL("email.code.retry.limit.exceed", "email.code.retry.limit.count"), + + /** + * 小程序登录 + */ + XCX("", ""); + + /** + * 登录重试超出限制提示 + */ + final String retryLimitExceed; + + /** + * 登录重试限制计数提示 + */ + final String retryLimitCount; +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserStatus.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserStatus.java new file mode 100644 index 0000000..e8547e7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserStatus.java @@ -0,0 +1,30 @@ +package org.dromara.common.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 用户状态 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum UserStatus { + /** + * 正常 + */ + OK("0", "正常"), + /** + * 停用 + */ + DISABLE("1", "停用"), + /** + * 删除 + */ + DELETED("2", "删除"); + + private final String code; + private final String info; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserType.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserType.java new file mode 100644 index 0000000..0cc5a4d --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserType.java @@ -0,0 +1,39 @@ +package org.dromara.common.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.dromara.common.core.utils.StringUtils; + +/** + * 用户类型 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum UserType { + + /** + * 后台系统用户 + */ + SYS_USER("sys_user"), + + /** + * 移动客户端用户 + */ + APP_USER("app_user"); + + /** + * 用户类型标识(用于 token、权限识别等) + */ + private final String userType; + + public static UserType getUserType(String str) { + for (UserType value : values()) { + if (StringUtils.contains(str, value.getUserType())) { + return value; + } + } + throw new RuntimeException("'UserType' not found By " + str); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/ServiceException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/ServiceException.java new file mode 100644 index 0000000..27b1022 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/ServiceException.java @@ -0,0 +1,67 @@ +package org.dromara.common.core.exception; + +import cn.hutool.core.text.StrFormatter; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serial; + +/** + * 业务异常(支持占位符 {} ) + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public final class ServiceException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private Integer code; + + /** + * 错误提示 + */ + private String message; + + /** + * 错误明细,内部调试错误 + */ + private String detailMessage; + + public ServiceException(String message) { + this.message = message; + } + + public ServiceException(String message, Integer code) { + this.message = message; + this.code = code; + } + + public ServiceException(String message, Object... args) { + this.message = StrFormatter.format(message, args); + } + + @Override + public String getMessage() { + return message; + } + + public ServiceException setMessage(String message) { + this.message = message; + return this; + } + + public ServiceException setDetailMessage(String detailMessage) { + this.detailMessage = detailMessage; + return this; + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/SseException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/SseException.java new file mode 100644 index 0000000..a76e16d --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/SseException.java @@ -0,0 +1,62 @@ +package org.dromara.common.core.exception; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serial; + +/** + * sse 特制异常 + * + * @author LionLi + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public final class SseException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private Integer code; + + /** + * 错误提示 + */ + private String message; + + /** + * 错误明细,内部调试错误 + */ + private String detailMessage; + + public SseException(String message) { + this.message = message; + } + + public SseException(String message, Integer code) { + this.message = message; + this.code = code; + } + + @Override + public String getMessage() { + return message; + } + + public SseException setMessage(String message) { + this.message = message; + return this; + } + + public SseException setDetailMessage(String detailMessage) { + this.detailMessage = detailMessage; + return this; + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/base/BaseException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/base/BaseException.java new file mode 100644 index 0000000..2b97014 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/base/BaseException.java @@ -0,0 +1,74 @@ +package org.dromara.common.core.exception.base; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.StringUtils; + +import java.io.Serial; + +/** + * 基础异常 + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +public class BaseException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 所属模块 + */ + private String module; + + /** + * 错误码 + */ + private String code; + + /** + * 错误码对应的参数 + */ + private Object[] args; + + /** + * 错误消息 + */ + private String defaultMessage; + + public BaseException(String module, String code, Object[] args) { + this(module, code, args, null); + } + + public BaseException(String module, String defaultMessage) { + this(module, null, null, defaultMessage); + } + + public BaseException(String code, Object[] args) { + this(null, code, args, null); + } + + public BaseException(String defaultMessage) { + this(null, null, null, defaultMessage); + } + + @Override + public String getMessage() { + String message = null; + if (!StringUtils.isEmpty(code)) { + message = MessageUtils.message(code, args); + } + if (message == null) { + message = defaultMessage; + } + return message; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileException.java new file mode 100644 index 0000000..7110be1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileException.java @@ -0,0 +1,21 @@ +package org.dromara.common.core.exception.file; + +import org.dromara.common.core.exception.base.BaseException; + +import java.io.Serial; + +/** + * 文件信息异常类 + * + * @author pengles + */ +public class FileException extends BaseException { + + @Serial + private static final long serialVersionUID = 1L; + + public FileException(String code, Object[] args) { + super("file", code, args, null); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileNameLengthLimitExceededException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileNameLengthLimitExceededException.java new file mode 100644 index 0000000..d6269a1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileNameLengthLimitExceededException.java @@ -0,0 +1,18 @@ +package org.dromara.common.core.exception.file; + +import java.io.Serial; + +/** + * 文件名称超长限制异常类 + * + * @author pengles + */ +public class FileNameLengthLimitExceededException extends FileException { + + @Serial + private static final long serialVersionUID = 1L; + + public FileNameLengthLimitExceededException(int defaultFileNameLength) { + super("upload.filename.exceed.length", new Object[]{defaultFileNameLength}); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileSizeLimitExceededException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileSizeLimitExceededException.java new file mode 100644 index 0000000..e990a92 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/file/FileSizeLimitExceededException.java @@ -0,0 +1,18 @@ +package org.dromara.common.core.exception.file; + +import java.io.Serial; + +/** + * 文件名大小限制异常类 + * + * @author pengles + */ +public class FileSizeLimitExceededException extends FileException { + + @Serial + private static final long serialVersionUID = 1L; + + public FileSizeLimitExceededException(long defaultMaxSize) { + super("upload.exceed.maxSize", new Object[]{defaultMaxSize}); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/CaptchaException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/CaptchaException.java new file mode 100644 index 0000000..af64b41 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/CaptchaException.java @@ -0,0 +1,18 @@ +package org.dromara.common.core.exception.user; + +import java.io.Serial; + +/** + * 验证码错误异常类 + * + * @author pengles + */ +public class CaptchaException extends UserException { + + @Serial + private static final long serialVersionUID = 1L; + + public CaptchaException() { + super("user.jcaptcha.error"); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/CaptchaExpireException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/CaptchaExpireException.java new file mode 100644 index 0000000..6168782 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/CaptchaExpireException.java @@ -0,0 +1,18 @@ +package org.dromara.common.core.exception.user; + +import java.io.Serial; + +/** + * 验证码失效异常类 + * + * @author pengles + */ +public class CaptchaExpireException extends UserException { + + @Serial + private static final long serialVersionUID = 1L; + + public CaptchaExpireException() { + super("user.jcaptcha.expire"); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/UserException.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/UserException.java new file mode 100644 index 0000000..9a806c0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/exception/user/UserException.java @@ -0,0 +1,20 @@ +package org.dromara.common.core.exception.user; + +import org.dromara.common.core.exception.base.BaseException; + +import java.io.Serial; + +/** + * 用户信息异常类 + * + * @author pengles + */ +public class UserException extends BaseException { + + @Serial + private static final long serialVersionUID = 1L; + + public UserException(String code, Object... args) { + super("user", code, args, null); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/factory/RegexPatternPoolFactory.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/factory/RegexPatternPoolFactory.java new file mode 100644 index 0000000..fd907d2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/factory/RegexPatternPoolFactory.java @@ -0,0 +1,52 @@ +package org.dromara.common.core.factory; + +import cn.hutool.core.lang.PatternPool; +import org.dromara.common.core.constant.RegexConstants; + +import java.util.regex.Pattern; + +/** + * 正则表达式模式池工厂 + *

初始化的时候将正则表达式加入缓存池当中

+ *

提高正则表达式的性能,避免重复编译相同的正则表达式

+ * + * @author 21001 + */ +public class RegexPatternPoolFactory extends PatternPool { + + /** + * 字典类型必须以字母开头,且只能为(小写字母,数字,下滑线) + */ + public static final Pattern DICTIONARY_TYPE = get(RegexConstants.DICTIONARY_TYPE); + + /** + * 身份证号码(后6位) + */ + public static final Pattern ID_CARD_LAST_6 = get(RegexConstants.ID_CARD_LAST_6); + + /** + * QQ号码 + */ + public static final Pattern QQ_NUMBER = get(RegexConstants.QQ_NUMBER); + + /** + * 邮政编码 + */ + public static final Pattern POSTAL_CODE = get(RegexConstants.POSTAL_CODE); + + /** + * 注册账号 + */ + public static final Pattern ACCOUNT = get(RegexConstants.ACCOUNT); + + /** + * 密码:包含至少8个字符,包括大写字母、小写字母、数字和特殊字符 + */ + public static final Pattern PASSWORD = get(RegexConstants.PASSWORD); + + /** + * 通用状态(0表示正常,1表示停用) + */ + public static final Pattern STATUS = get(RegexConstants.STATUS); + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/factory/YmlPropertySourceFactory.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/factory/YmlPropertySourceFactory.java new file mode 100644 index 0000000..5135a81 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/factory/YmlPropertySourceFactory.java @@ -0,0 +1,31 @@ +package org.dromara.common.core.factory; + +import org.dromara.common.core.utils.StringUtils; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.support.DefaultPropertySourceFactory; +import org.springframework.core.io.support.EncodedResource; + +import java.io.IOException; + +/** + * yml 配置源工厂 + * + * @author pengles + */ +public class YmlPropertySourceFactory extends DefaultPropertySourceFactory { + + @Override + public PropertySource createPropertySource(String name, EncodedResource resource) throws IOException { + String sourceName = resource.getResource().getFilename(); + if (StringUtils.isNotBlank(sourceName) && StringUtils.endsWithAny(sourceName, ".yml", ".yaml")) { + YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); + factory.setResources(resource.getResource()); + factory.afterPropertiesSet(); + return new PropertiesPropertySource(sourceName, factory.getObject()); + } + return super.createPropertySource(name, resource); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/ConfigService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/ConfigService.java new file mode 100644 index 0000000..37cbbcf --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/ConfigService.java @@ -0,0 +1,18 @@ +package org.dromara.common.core.service; + +/** + * 通用 参数配置服务 + * + * @author pengles + */ +public interface ConfigService { + + /** + * 根据参数 key 获取参数值 + * + * @param configKey 参数 key + * @return 参数值 + */ + String getConfigValue(String configKey); + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/DeptService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/DeptService.java new file mode 100644 index 0000000..f477caf --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/DeptService.java @@ -0,0 +1,37 @@ +package org.dromara.common.core.service; + +import org.dromara.common.core.domain.dto.DeptDTO; + +import java.util.List; + +/** + * 通用 部门服务 + * + * @author pengles + */ +public interface DeptService { + + /** + * 通过部门ID查询部门名称 + * + * @param deptIds 部门ID串逗号分隔 + * @return 部门名称串逗号分隔 + */ + String selectDeptNameByIds(String deptIds); + + /** + * 根据部门ID查询部门负责人 + * + * @param deptId 部门ID,用于指定需要查询的部门 + * @return 返回该部门的负责人ID + */ + Long selectDeptLeaderById(Long deptId); + + /** + * 查询部门 + * + * @return 部门列表 + */ + List selectDeptsByList(); + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/DictService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/DictService.java new file mode 100644 index 0000000..2e67183 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/DictService.java @@ -0,0 +1,87 @@ +package org.dromara.common.core.service; + +import org.dromara.common.core.domain.dto.DictDataDTO; +import org.dromara.common.core.domain.dto.DictTypeDTO; + +import java.util.List; +import java.util.Map; + +/** + * 通用 字典服务 + * + * @author pengles + */ +public interface DictService { + + /** + * 分隔符 + */ + String SEPARATOR = ","; + + /** + * 根据字典类型和字典值获取字典标签 + * + * @param dictType 字典类型 + * @param dictValue 字典值 + * @return 字典标签 + */ + default String getDictLabel(String dictType, String dictValue) { + return getDictLabel(dictType, dictValue, SEPARATOR); + } + + /** + * 根据字典类型和字典标签获取字典值 + * + * @param dictType 字典类型 + * @param dictLabel 字典标签 + * @return 字典值 + */ + default String getDictValue(String dictType, String dictLabel) { + return getDictValue(dictType, dictLabel, SEPARATOR); + } + + /** + * 根据字典类型和字典值获取字典标签 + * + * @param dictType 字典类型 + * @param dictValue 字典值 + * @param separator 分隔符 + * @return 字典标签 + */ + String getDictLabel(String dictType, String dictValue, String separator); + + /** + * 根据字典类型和字典标签获取字典值 + * + * @param dictType 字典类型 + * @param dictLabel 字典标签 + * @param separator 分隔符 + * @return 字典值 + */ + String getDictValue(String dictType, String dictLabel, String separator); + + /** + * 获取字典下所有的字典值与标签 + * + * @param dictType 字典类型 + * @return dictValue为key,dictLabel为值组成的Map + */ + Map getAllDictByDictType(String dictType); + + /** + * 根据字典类型查询详细信息 + * + * @param dictType 字典类型 + * @return 字典类型详细信息 + */ + DictTypeDTO getDictType(String dictType); + + /** + * 根据字典类型查询字典数据列表 + * + * @param dictType 字典类型 + * @return 字典数据列表 + */ + List getDictData(String dictType); + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/OssService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/OssService.java new file mode 100644 index 0000000..703ebf3 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/OssService.java @@ -0,0 +1,29 @@ +package org.dromara.common.core.service; + +import org.dromara.common.core.domain.dto.OssDTO; + +import java.util.List; + +/** + * 通用 OSS服务 + * + * @author pengles + */ +public interface OssService { + + /** + * 通过ossId查询对应的url + * + * @param ossIds ossId串逗号分隔 + * @return url串逗号分隔 + */ + String selectUrlByIds(String ossIds); + + /** + * 通过ossId查询列表 + * + * @param ossIds ossId串逗号分隔 + * @return 列表 + */ + List selectByIds(String ossIds); +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/PermissionService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/PermissionService.java new file mode 100644 index 0000000..893f0a9 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/PermissionService.java @@ -0,0 +1,28 @@ +package org.dromara.common.core.service; + +import java.util.Set; + +/** + * 用户权限处理 + * + * @author pengles + */ +public interface PermissionService { + + /** + * 获取角色数据权限 + * + * @param userId 用户id + * @return 角色权限信息 + */ + Set getRolePermission(Long userId); + + /** + * 获取菜单数据权限 + * + * @param userId 用户id + * @return 菜单权限信息 + */ + Set getMenuPermission(Long userId); + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/PostService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/PostService.java new file mode 100644 index 0000000..41d4e83 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/PostService.java @@ -0,0 +1,10 @@ +package org.dromara.common.core.service; + +/** + * 通用 岗位服务 + * + * @author AprilWind + */ +public interface PostService { + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/RoleService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/RoleService.java new file mode 100644 index 0000000..ba62c82 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/RoleService.java @@ -0,0 +1,10 @@ +package org.dromara.common.core.service; + +/** + * 通用 角色服务 + * + * @author AprilWind + */ +public interface RoleService { + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/TaskAssigneeService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/TaskAssigneeService.java new file mode 100644 index 0000000..1c1f8a0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/TaskAssigneeService.java @@ -0,0 +1,45 @@ +package org.dromara.common.core.service; + +import org.dromara.common.core.domain.dto.TaskAssigneeDTO; +import org.dromara.common.core.domain.model.TaskAssigneeBody; + +/** + * 工作流设计器获取任务执行人 + * + * @author pengles + */ +public interface TaskAssigneeService { + + /** + * 查询角色并返回任务指派的列表,支持分页 + * + * @param taskQuery 查询条件 + * @return 办理人 + */ + TaskAssigneeDTO selectRolesByTaskAssigneeList(TaskAssigneeBody taskQuery); + + /** + * 查询岗位并返回任务指派的列表,支持分页 + * + * @param taskQuery 查询条件 + * @return 办理人 + */ + TaskAssigneeDTO selectPostsByTaskAssigneeList(TaskAssigneeBody taskQuery); + + /** + * 查询部门并返回任务指派的列表,支持分页 + * + * @param taskQuery 查询条件 + * @return 办理人 + */ + TaskAssigneeDTO selectDeptsByTaskAssigneeList(TaskAssigneeBody taskQuery); + + /** + * 查询用户并返回任务指派的列表,支持分页 + * + * @param taskQuery 查询条件 + * @return 办理人 + */ + TaskAssigneeDTO selectUsersByTaskAssigneeList(TaskAssigneeBody taskQuery); + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/UserService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/UserService.java new file mode 100644 index 0000000..991928b --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/UserService.java @@ -0,0 +1,127 @@ +package org.dromara.common.core.service; + +import org.dromara.common.core.domain.dto.UserDTO; + +import java.util.List; +import java.util.Map; + +/** + * 通用 用户服务 + * + * @author pengles + */ +public interface UserService { + + /** + * 通过用户ID查询用户账户 + * + * @param userId 用户ID + * @return 用户账户 + */ + String selectUserNameById(Long userId); + + /** + * 通过用户ID查询用户账户 + * + * @param userId 用户ID + * @return 用户名称 + */ + String selectNicknameById(Long userId); + + /** + * 通过用户ID查询用户账户 + * + * @param userIds 用户ID 多个用逗号隔开 + * @return 用户名称 + */ + String selectNicknameByIds(String userIds); + + /** + * 通过用户ID查询用户手机号 + * + * @param userId 用户id + * @return 用户手机号 + */ + String selectPhonenumberById(Long userId); + + /** + * 通过用户ID查询用户邮箱 + * + * @param userId 用户id + * @return 用户邮箱 + */ + String selectEmailById(Long userId); + + /** + * 通过用户ID查询用户列表 + * + * @param userIds 用户ids + * @return 用户列表 + */ + List selectListByIds(List userIds); + + /** + * 通过角色ID查询用户ID + * + * @param roleIds 角色ids + * @return 用户ids + */ + List selectUserIdsByRoleIds(List roleIds); + + /** + * 通过角色ID查询用户 + * + * @param roleIds 角色ids + * @return 用户 + */ + List selectUsersByRoleIds(List roleIds); + + /** + * 通过部门ID查询用户 + * + * @param deptIds 部门ids + * @return 用户 + */ + List selectUsersByDeptIds(List deptIds); + + /** + * 通过岗位ID查询用户 + * + * @param postIds 岗位ids + * @return 用户 + */ + List selectUsersByPostIds(List postIds); + + /** + * 根据用户 ID 列表查询用户名称映射关系 + * + * @param userIds 用户 ID 列表 + * @return Map,其中 key 为用户 ID,value 为对应的用户名称 + */ + Map selectUserNamesByIds(List userIds); + + /** + * 根据角色 ID 列表查询角色名称映射关系 + * + * @param roleIds 角色 ID 列表 + * @return Map,其中 key 为角色 ID,value 为对应的角色名称 + */ + Map selectRoleNamesByIds(List roleIds); + + /** + * 根据部门 ID 列表查询部门名称映射关系 + * + * @param deptIds 部门 ID 列表 + * @return Map,其中 key 为部门 ID,value 为对应的部门名称 + */ + Map selectDeptNamesByIds(List deptIds); + + /** + * 根据岗位 ID 列表查询岗位名称映射关系 + * + * @param postIds 岗位 ID 列表 + * @return Map,其中 key 为岗位 ID,value 为对应的岗位名称 + */ + Map selectPostNamesByIds(List postIds); + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/WorkflowService.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/WorkflowService.java new file mode 100644 index 0000000..23dedca --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/service/WorkflowService.java @@ -0,0 +1,88 @@ +package org.dromara.common.core.service; + +import org.dromara.common.core.domain.dto.CompleteTaskDTO; +import org.dromara.common.core.domain.dto.StartProcessDTO; +import org.dromara.common.core.domain.dto.StartProcessReturnDTO; + +import java.util.List; +import java.util.Map; + +/** + * 通用 工作流服务 + * + * @author may + */ +public interface WorkflowService { + + /** + * 运行中的实例 删除程实例,删除历史记录,删除业务与流程关联信息 + * + * @param businessIds 业务id + * @return 结果 + */ + boolean deleteInstance(List businessIds); + + /** + * 获取当前流程状态 + * + * @param taskId 任务id + * @return 状态 + */ + String getBusinessStatusByTaskId(Long taskId); + + /** + * 获取当前流程状态 + * + * @param businessId 业务id + * @return 状态 + */ + String getBusinessStatus(String businessId); + + /** + * 设置流程变量 + * + * @param instanceId 流程实例id + * @param variable 流程变量 + */ + void setVariable(Long instanceId, Map variable); + + /** + * 获取流程变量 + * + * @param instanceId 流程实例id + */ + Map instanceVariable(Long instanceId); + + /** + * 按照业务id查询流程实例id + * + * @param businessId 业务id + * @return 结果 + */ + Long getInstanceIdByBusinessId(String businessId); + + /** + * 启动流程 + * + * @param startProcess 参数 + * @return 结果 + */ + StartProcessReturnDTO startWorkFlow(StartProcessDTO startProcess); + + /** + * 办理任务 + * 系统后台发起审批 无用户信息 需要忽略权限 + * completeTask.getVariables().put("ignore", true); + * + * @param completeTask 参数 + */ + boolean completeTask(CompleteTaskDTO completeTask); + + /** + * 办理任务 + * + * @param taskId 任务ID + * @param message 办理意见 + */ + boolean completeTask(Long taskId, String message); +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java new file mode 100644 index 0000000..897554b --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java @@ -0,0 +1,300 @@ +package org.dromara.common.core.utils; + +import org.apache.commons.lang3.time.DateFormatUtils; +import org.dromara.common.core.enums.FormatsType; +import org.dromara.common.core.exception.ServiceException; + +import java.lang.management.ManagementFactory; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.*; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +/** + * 时间工具类 + * + * @author pengles + */ +public class DateUtils extends org.apache.commons.lang3.time.DateUtils { + private static final String[] PARSE_PATTERNS = { + "yyyy-MM-dd", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM", + "yyyy/MM/dd", "yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy/MM", + "yyyy.MM.dd", "yyyy.MM.dd HH:mm:ss", "yyyy.MM.dd HH:mm", "yyyy.MM"}; + + @Deprecated + private DateUtils() { + } + + /** + * 获取当前日期和时间 + * + * @return 当前日期和时间的 Date 对象表示 + */ + public static Date getNowDate() { + return new Date(); + } + + /** + * 获取当前日期的字符串表示,格式为YYYY-MM-DD + * + * @return 当前日期的字符串表示 + */ + public static String getDate() { + return dateTimeNow(FormatsType.YYYY_MM_DD); + } + + /** + * 获取当前日期的字符串表示,格式为yyyyMMdd + * + * @return 当前日期的字符串表示 + */ + public static String getCurrentDate() { + return DateFormatUtils.format(new Date(), FormatsType.YYYYMMDD.getTimeFormat()); + } + + /** + * 获取当前日期的路径格式字符串,格式为"yyyy/MM/dd" + * + * @return 当前日期的路径格式字符串 + */ + public static String datePath() { + Date now = new Date(); + return DateFormatUtils.format(now, FormatsType.YYYY_MM_DD_SLASH.getTimeFormat()); + } + + /** + * 获取当前时间的字符串表示,格式为YYYY-MM-DD HH:MM:SS + * + * @return 当前时间的字符串表示 + */ + public static String getTime() { + return dateTimeNow(FormatsType.YYYY_MM_DD_HH_MM_SS); + } + + /** + * 获取当前时间的字符串表示,格式为 "HH:MM:SS" + * + * @return 当前时间的字符串表示,格式为 "HH:MM:SS" + */ + public static String getTimeWithHourMinuteSecond() { + return dateTimeNow(FormatsType.HH_MM_SS); + } + + /** + * 获取当前日期和时间的字符串表示,格式为YYYYMMDDHHMMSS + * + * @return 当前日期和时间的字符串表示 + */ + public static String dateTimeNow() { + return dateTimeNow(FormatsType.YYYYMMDDHHMMSS); + } + + /** + * 获取当前日期和时间的指定格式的字符串表示 + * + * @param format 日期时间格式,例如"YYYY-MM-DD HH:MM:SS" + * @return 当前日期和时间的字符串表示 + */ + public static String dateTimeNow(final FormatsType format) { + return parseDateToStr(format, new Date()); + } + + /** + * 将指定日期格式化为 YYYY-MM-DD 格式的字符串 + * + * @param date 要格式化的日期对象 + * @return 格式化后的日期字符串 + */ + public static String formatDate(final Date date) { + return parseDateToStr(FormatsType.YYYY_MM_DD, date); + } + + /** + * 将指定日期格式化为 YYYY-MM-DD HH:MM:SS 格式的字符串 + * + * @param date 要格式化的日期对象 + * @return 格式化后的日期时间字符串 + */ + public static String formatDateTime(final Date date) { + return parseDateToStr(FormatsType.YYYY_MM_DD_HH_MM_SS, date); + } + + /** + * 将指定日期按照指定格式进行格式化 + * + * @param format 要使用的日期时间格式,例如"YYYY-MM-DD HH:MM:SS" + * @param date 要格式化的日期对象 + * @return 格式化后的日期时间字符串 + */ + public static String parseDateToStr(final FormatsType format, final Date date) { + return new SimpleDateFormat(format.getTimeFormat()).format(date); + } + + /** + * 将指定格式的日期时间字符串转换为 Date 对象 + * + * @param format 要解析的日期时间格式,例如"YYYY-MM-DD HH:MM:SS" + * @param ts 要解析的日期时间字符串 + * @return 解析后的 Date 对象 + * @throws RuntimeException 如果解析过程中发生异常 + */ + public static Date parseDateTime(final FormatsType format, final String ts) { + try { + return new SimpleDateFormat(format.getTimeFormat()).parse(ts); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + + /** + * 将对象转换为日期对象 + * + * @param str 要转换的对象,通常是字符串 + * @return 转换后的日期对象,如果转换失败或输入为null,则返回null + */ + public static Date parseDate(Object str) { + if (str == null) { + return null; + } + try { + return parseDate(str.toString(), PARSE_PATTERNS); + } catch (ParseException e) { + return null; + } + } + + /** + * 获取服务器启动时间 + * + * @return 服务器启动时间的 Date 对象表示 + */ + public static Date getServerStartDate() { + long time = ManagementFactory.getRuntimeMXBean().getStartTime(); + return new Date(time); + } + + /** + * 计算两个时间之间的时间差,并以指定单位返回(绝对值) + * + * @param start 起始时间 + * @param end 结束时间 + * @param unit 所需返回的时间单位(DAYS、HOURS、MINUTES、SECONDS、MILLISECONDS、MICROSECONDS、NANOSECONDS) + * @return 时间差的绝对值,以指定单位表示 + */ + public static long difference(Date start, Date end, TimeUnit unit) { + // 计算时间差,单位为毫秒,取绝对值避免负数 + long diffInMillis = Math.abs(end.getTime() - start.getTime()); + + // 根据目标单位转换时间差 + return switch (unit) { + case DAYS -> diffInMillis / TimeUnit.DAYS.toMillis(1); + case HOURS -> diffInMillis / TimeUnit.HOURS.toMillis(1); + case MINUTES -> diffInMillis / TimeUnit.MINUTES.toMillis(1); + case SECONDS -> diffInMillis / TimeUnit.SECONDS.toMillis(1); + case MILLISECONDS -> diffInMillis; + case MICROSECONDS -> TimeUnit.MILLISECONDS.toMicros(diffInMillis); + case NANOSECONDS -> TimeUnit.MILLISECONDS.toNanos(diffInMillis); + }; + } + + /** + * 计算两个日期之间的时间差,并以天、小时和分钟的格式返回 + * + * @param endDate 结束日期 + * @param nowDate 当前日期 + * @return 表示时间差的字符串,格式为"天 小时 分钟" + */ + public static String getDatePoor(Date endDate, Date nowDate) { + long diffInMillis = endDate.getTime() - nowDate.getTime(); + long day = TimeUnit.MILLISECONDS.toDays(diffInMillis); + long hour = TimeUnit.MILLISECONDS.toHours(diffInMillis) % 24; + long min = TimeUnit.MILLISECONDS.toMinutes(diffInMillis) % 60; + return String.format("%d天 %d小时 %d分钟", day, hour, min); + } + + /** + * 计算两个时间点的差值(天、小时、分钟、秒),当值为0时不显示该单位 + * + * @param endDate 结束时间 + * @param nowDate 当前时间 + * @return 时间差字符串,格式为 "x天 x小时 x分钟 x秒",若为 0 则不显示 + */ + public static String getTimeDifference(Date endDate, Date nowDate) { + long diffInMillis = endDate.getTime() - nowDate.getTime(); + long day = TimeUnit.MILLISECONDS.toDays(diffInMillis); + long hour = TimeUnit.MILLISECONDS.toHours(diffInMillis) % 24; + long min = TimeUnit.MILLISECONDS.toMinutes(diffInMillis) % 60; + long sec = TimeUnit.MILLISECONDS.toSeconds(diffInMillis) % 60; + // 构建时间差字符串,条件是值不为0才显示 + StringBuilder result = new StringBuilder(); + if (day > 0) { + result.append(String.format("%d天 ", day)); + } + if (hour > 0) { + result.append(String.format("%d小时 ", hour)); + } + if (min > 0) { + result.append(String.format("%d分钟 ", min)); + } + if (sec > 0) { + result.append(String.format("%d秒", sec)); + } + return result.length() > 0 ? result.toString().trim() : "0秒"; + } + + /** + * 将 LocalDateTime 对象转换为 Date 对象 + * + * @param temporalAccessor 要转换的 LocalDateTime 对象 + * @return 转换后的 Date 对象 + */ + public static Date toDate(LocalDateTime temporalAccessor) { + ZonedDateTime zdt = temporalAccessor.atZone(ZoneId.systemDefault()); + return Date.from(zdt.toInstant()); + } + + /** + * 将 LocalDate 对象转换为 Date 对象 + * + * @param temporalAccessor 要转换的 LocalDate 对象 + * @return 转换后的 Date 对象 + */ + public static Date toDate(LocalDate temporalAccessor) { + LocalDateTime localDateTime = LocalDateTime.of(temporalAccessor, LocalTime.of(0, 0, 0)); + ZonedDateTime zdt = localDateTime.atZone(ZoneId.systemDefault()); + return Date.from(zdt.toInstant()); + } + + /** + * 校验日期范围 + * + * @param startDate 开始日期 + * @param endDate 结束日期 + * @param maxValue 最大时间跨度的限制值 + * @param unit 时间跨度的单位,可选择 "DAYS"、"HOURS" 或 "MINUTES" + */ + public static void validateDateRange(Date startDate, Date endDate, int maxValue, TimeUnit unit) { + // 校验结束日期不能早于开始日期 + if (endDate.before(startDate)) { + throw new ServiceException("结束日期不能早于开始日期"); + } + + // 计算时间跨度 + long diffInMillis = endDate.getTime() - startDate.getTime(); + + // 根据单位转换时间跨度 + long diff = switch (unit) { + case DAYS -> TimeUnit.MILLISECONDS.toDays(diffInMillis); + case HOURS -> TimeUnit.MILLISECONDS.toHours(diffInMillis); + case MINUTES -> TimeUnit.MILLISECONDS.toMinutes(diffInMillis); + default -> throw new IllegalArgumentException("不支持的时间单位"); + }; + + // 校验时间跨度不超过最大限制 + if (diff > maxValue) { + throw new ServiceException("最大时间跨度为 " + maxValue + " " + unit.toString().toLowerCase()); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MapstructUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MapstructUtils.java new file mode 100644 index 0000000..8cc77d9 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MapstructUtils.java @@ -0,0 +1,93 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjectUtil; +import io.github.linpeilie.Converter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * Mapstruct 工具类 + *

参考文档:mapstruct-plus

+ * + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MapstructUtils { + + private final static Converter CONVERTER = SpringUtils.getBean(Converter.class); + + /** + * 将 T 类型对象,转换为 desc 类型的对象并返回 + * + * @param source 数据来源实体 + * @param desc 描述对象 转换后的对象 + * @return desc + */ + public static V convert(T source, Class desc) { + if (ObjectUtil.isNull(source)) { + return null; + } + if (ObjectUtil.isNull(desc)) { + return null; + } + return CONVERTER.convert(source, desc); + } + + /** + * 将 T 类型对象,按照配置的映射字段规则,给 desc 类型的对象赋值并返回 desc 对象 + * + * @param source 数据来源实体 + * @param desc 转换后的对象 + * @return desc + */ + public static V convert(T source, V desc) { + if (ObjectUtil.isNull(source)) { + return null; + } + if (ObjectUtil.isNull(desc)) { + return null; + } + return CONVERTER.convert(source, desc); + } + + /** + * 将 T 类型的集合,转换为 desc 类型的集合并返回 + * + * @param sourceList 数据来源实体列表 + * @param desc 描述对象 转换后的对象 + * @return desc + */ + public static List convert(List sourceList, Class desc) { + if (ObjectUtil.isNull(sourceList)) { + return null; + } + if (CollUtil.isEmpty(sourceList)) { + return CollUtil.newArrayList(); + } + return CONVERTER.convert(sourceList, desc); + } + + /** + * 将 Map 转换为 beanClass 类型的集合并返回 + * + * @param map 数据来源 + * @param beanClass bean类 + * @return bean对象 + */ + public static T convert(Map map, Class beanClass) { + if (MapUtil.isEmpty(map)) { + return null; + } + if (ObjectUtil.isNull(beanClass)) { + return null; + } + return CONVERTER.convert(map, beanClass); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MessageUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MessageUtils.java new file mode 100644 index 0000000..17ea377 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/MessageUtils.java @@ -0,0 +1,33 @@ +package org.dromara.common.core.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; + +/** + * 获取i18n资源文件 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MessageUtils { + + private static final MessageSource MESSAGE_SOURCE = SpringUtils.getBean(MessageSource.class); + + /** + * 根据消息键和参数 获取消息 委托给spring messageSource + * + * @param code 消息键 + * @param args 参数 + * @return 获取国际化翻译值 + */ + public static String message(String code, Object... args) { + try { + return MESSAGE_SOURCE.getMessage(code, args, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/NetUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/NetUtils.java new file mode 100644 index 0000000..72fdf40 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/NetUtils.java @@ -0,0 +1,84 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.lang.PatternPool; +import cn.hutool.core.net.NetUtil; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.regex.RegexUtils; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * 增强网络相关工具类 + * + * @author 秋辞未寒 + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class NetUtils extends NetUtil { + + /** + * 判断是否为IPv6地址 + * + * @param ip IP地址 + * @return 是否为IPv6地址 + */ + public static boolean isIPv6(String ip) { + try { + // 判断是否为IPv6地址 + return InetAddress.getByName(ip) instanceof Inet6Address; + } catch (UnknownHostException e) { + return false; + } + } + + /** + * 判断IPv6地址是否为内网地址 + *

+ * 以下地址将归类为本地地址,如有业务场景有需要,请根据需求自行处理: + *
+     * 通配符地址 0:0:0:0:0:0:0:0
+     * 链路本地地址 fe80::/10
+     * 唯一本地地址 fec0::/10
+     * 环回地址 ::1
+     * 
+ * + * @param ip IP地址 + * @return 是否为内网地址 + */ + public static boolean isInnerIPv6(String ip) { + try { + // 判断是否为IPv6地址 + if (InetAddress.getByName(ip) instanceof Inet6Address inet6Address) { + // isAnyLocalAddress 判断是否为通配符地址,通常不会将其视为内网地址,根据业务场景自行处理判断 + // isLinkLocalAddress 判断是否为链路本地地址,通常不算内网地址,是否划分归属于内网需要根据业务场景自行处理判断 + // isLoopbackAddress 判断是否为环回地址,与IPv4的 127.0.0.1 同理,用于表示本机 + // isSiteLocalAddress 判断是否为本地站点地址,IPv6唯一本地地址(Unique Local Addresses,简称ULA) + if (inet6Address.isAnyLocalAddress() + || inet6Address.isLinkLocalAddress() + || inet6Address.isLoopbackAddress() + || inet6Address.isSiteLocalAddress()) { + return true; + } + } + } catch (UnknownHostException e) { + // 注意,isInnerIPv6方法和isIPv6方法的适用范围不同,所以此处不能忽略其异常信息。 + throw new IllegalArgumentException("Invalid IPv6 address!", e); + } + return false; + } + + /** + * 判断是否为IPv4地址 + * + * @param ip IP地址 + * @return 是否为IPv4地址 + */ + public static boolean isIPv4(String ip) { + return RegexUtils.isMatch(PatternPool.IPV4, ip); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ObjectUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ObjectUtils.java new file mode 100644 index 0000000..199fd82 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ObjectUtils.java @@ -0,0 +1,60 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.util.ObjectUtil; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.function.Function; + +/** + * 对象工具类 + * + * @author 秋辞未寒 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ObjectUtils extends ObjectUtil { + + /** + * 如果对象不为空,则获取对象中的某个字段 ObjectUtils.notNullGetter(user, User::getName); + * + * @param obj 对象 + * @param func 获取方法 + * @return 对象字段 + */ + public static E notNullGetter(T obj, Function func) { + if (isNotNull(obj) && isNotNull(func)) { + return func.apply(obj); + } + return null; + } + + /** + * 如果对象不为空,则获取对象中的某个字段,否则返回默认值 + * + * @param obj 对象 + * @param func 获取方法 + * @param defaultValue 默认值 + * @return 对象字段 + */ + public static E notNullGetter(T obj, Function func, E defaultValue) { + if (isNotNull(obj) && isNotNull(func)) { + return func.apply(obj); + } + return defaultValue; + } + + /** + * 如果值不为空,则返回值,否则返回默认值 + * + * @param obj 对象 + * @param defaultValue 默认值 + * @return 对象字段 + */ + public static T notNull(T obj, T defaultValue) { + if (isNotNull(obj)) { + return obj; + } + return defaultValue; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ServletUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ServletUtils.java new file mode 100644 index 0000000..439c2af --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ServletUtils.java @@ -0,0 +1,289 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.convert.Convert; +import cn.hutool.extra.servlet.JakartaServletUtil; +import cn.hutool.http.HttpStatus; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.util.LinkedCaseInsensitiveMap; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.io.IOException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +/** + * 客户端工具类,提供获取请求参数、响应处理、头部信息等常用操作 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ServletUtils extends JakartaServletUtil { + + /** + * 获取指定名称的 String 类型的请求参数 + * + * @param name 参数名 + * @return 参数值 + */ + public static String getParameter(String name) { + return getRequest().getParameter(name); + } + + /** + * 获取指定名称的 String 类型的请求参数,若参数不存在,则返回默认值 + * + * @param name 参数名 + * @param defaultValue 默认值 + * @return 参数值或默认值 + */ + public static String getParameter(String name, String defaultValue) { + return Convert.toStr(getRequest().getParameter(name), defaultValue); + } + + /** + * 获取指定名称的 Integer 类型的请求参数 + * + * @param name 参数名 + * @return 参数值 + */ + public static Integer getParameterToInt(String name) { + return Convert.toInt(getRequest().getParameter(name)); + } + + /** + * 获取指定名称的 Integer 类型的请求参数,若参数不存在,则返回默认值 + * + * @param name 参数名 + * @param defaultValue 默认值 + * @return 参数值或默认值 + */ + public static Integer getParameterToInt(String name, Integer defaultValue) { + return Convert.toInt(getRequest().getParameter(name), defaultValue); + } + + /** + * 获取指定名称的 Boolean 类型的请求参数 + * + * @param name 参数名 + * @return 参数值 + */ + public static Boolean getParameterToBool(String name) { + return Convert.toBool(getRequest().getParameter(name)); + } + + /** + * 获取指定名称的 Boolean 类型的请求参数,若参数不存在,则返回默认值 + * + * @param name 参数名 + * @param defaultValue 默认值 + * @return 参数值或默认值 + */ + public static Boolean getParameterToBool(String name, Boolean defaultValue) { + return Convert.toBool(getRequest().getParameter(name), defaultValue); + } + + /** + * 获取所有请求参数(以 Map 的形式返回) + * + * @param request 请求对象{@link ServletRequest} + * @return 请求参数的 Map,键为参数名,值为参数值数组 + */ + public static Map getParams(ServletRequest request) { + final Map map = request.getParameterMap(); + return Collections.unmodifiableMap(map); + } + + /** + * 获取所有请求参数(以 Map 的形式返回,值为字符串形式的拼接) + * + * @param request 请求对象{@link ServletRequest} + * @return 请求参数的 Map,键为参数名,值为拼接后的字符串 + */ + public static Map getParamMap(ServletRequest request) { + Map params = new HashMap<>(); + for (Map.Entry entry : getParams(request).entrySet()) { + params.put(entry.getKey(), StringUtils.join(entry.getValue(), StringUtils.SEPARATOR)); + } + return params; + } + + /** + * 获取当前 HTTP 请求对象 + * + * @return 当前 HTTP 请求对象 + */ + public static HttpServletRequest getRequest() { + try { + return getRequestAttributes().getRequest(); + } catch (Exception e) { + return null; + } + } + + /** + * 获取当前 HTTP 响应对象 + * + * @return 当前 HTTP 响应对象 + */ + public static HttpServletResponse getResponse() { + try { + return getRequestAttributes().getResponse(); + } catch (Exception e) { + return null; + } + } + + /** + * 获取当前请求的 HttpSession 对象 + *

+ * 如果当前请求已经关联了一个会话(即已经存在有效的 session ID), + * 则返回该会话对象;如果没有关联会话,则会创建一个新的会话对象并返回。 + *

+ * HttpSession 用于存储会话级别的数据,如用户登录信息、购物车内容等, + * 可以在多个请求之间共享会话数据 + * + * @return 当前请求的 HttpSession 对象 + */ + public static HttpSession getSession() { + return getRequest().getSession(); + } + + /** + * 获取当前请求的请求属性 + * + * @return {@link ServletRequestAttributes} 请求属性对象 + */ + public static ServletRequestAttributes getRequestAttributes() { + try { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + return (ServletRequestAttributes) attributes; + } catch (Exception e) { + return null; + } + } + + /** + * 获取指定请求头的值,如果头部为空则返回空字符串 + * + * @param request 请求对象 + * @param name 头部名称 + * @return 头部值 + */ + public static String getHeader(HttpServletRequest request, String name) { + String value = request.getHeader(name); + if (StringUtils.isEmpty(value)) { + return StringUtils.EMPTY; + } + return urlDecode(value); + } + + /** + * 获取所有请求头的 Map,键为头部名称,值为头部值 + * + * @param request 请求对象 + * @return 请求头的 Map + */ + public static Map getHeaders(HttpServletRequest request) { + Map map = new LinkedCaseInsensitiveMap<>(); + Enumeration enumeration = request.getHeaderNames(); + if (enumeration != null) { + while (enumeration.hasMoreElements()) { + String key = enumeration.nextElement(); + String value = request.getHeader(key); + map.put(key, value); + } + } + return map; + } + + /** + * 将字符串渲染到客户端(以 JSON 格式返回) + * + * @param response 渲染对象 + * @param string 待渲染的字符串 + */ + public static void renderString(HttpServletResponse response, String string) { + try { + response.setStatus(HttpStatus.HTTP_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); + response.getWriter().print(string); + } catch (IOException e) { + e.printStackTrace(); + } + } + + /** + * 判断当前请求是否为 Ajax 异步请求 + * + * @param request 请求对象 + * @return 是否为 Ajax 请求 + */ + public static boolean isAjaxRequest(HttpServletRequest request) { + + // 判断 Accept 头部是否包含 application/json + String accept = request.getHeader("accept"); + if (accept != null && accept.contains(MediaType.APPLICATION_JSON_VALUE)) { + return true; + } + + // 判断 X-Requested-With 头部是否包含 XMLHttpRequest + String xRequestedWith = request.getHeader("X-Requested-With"); + if (xRequestedWith != null && xRequestedWith.contains("XMLHttpRequest")) { + return true; + } + + // 判断 URI 后缀是否为 .json 或 .xml + String uri = request.getRequestURI(); + if (StringUtils.equalsAnyIgnoreCase(uri, ".json", ".xml")) { + return true; + } + + // 判断请求参数 __ajax 是否为 json 或 xml + String ajax = request.getParameter("__ajax"); + return StringUtils.equalsAnyIgnoreCase(ajax, "json", "xml"); + } + + /** + * 获取客户端 IP 地址 + * + * @return 客户端 IP 地址 + */ + public static String getClientIP() { + return getClientIP(getRequest()); + } + + /** + * 对内容进行 URL 编码 + * + * @param str 内容 + * @return 编码后的内容 + */ + public static String urlEncode(String str) { + return URLEncoder.encode(str, StandardCharsets.UTF_8); + } + + /** + * 对内容进行 URL 解码 + * + * @param str 内容 + * @return 解码后的内容 + */ + public static String urlDecode(String str) { + return URLDecoder.decode(str, StandardCharsets.UTF_8); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/SpringUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/SpringUtils.java new file mode 100644 index 0000000..f831e3c --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/SpringUtils.java @@ -0,0 +1,67 @@ +package org.dromara.common.core.utils; + +import cn.hutool.extra.spring.SpringUtil; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.context.ApplicationContext; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * spring工具类 + * + * @author pengles + */ +@Component +public final class SpringUtils extends SpringUtil { + + /** + * 如果BeanFactory包含一个与所给名称匹配的bean定义,则返回true + */ + public static boolean containsBean(String name) { + return getBeanFactory().containsBean(name); + } + + /** + * 判断以给定名字注册的bean定义是一个singleton还是一个prototype。 + * 如果与给定名字相应的bean定义没有被找到,将会抛出一个异常(NoSuchBeanDefinitionException) + */ + public static boolean isSingleton(String name) throws NoSuchBeanDefinitionException { + return getBeanFactory().isSingleton(name); + } + + /** + * @return Class 注册对象的类型 + */ + public static Class getType(String name) throws NoSuchBeanDefinitionException { + return getBeanFactory().getType(name); + } + + /** + * 如果给定的bean名字在bean定义中有别名,则返回这些别名 + */ + public static String[] getAliases(String name) throws NoSuchBeanDefinitionException { + return getBeanFactory().getAliases(name); + } + + /** + * 获取aop代理对象 + */ + @SuppressWarnings("unchecked") + public static T getAopProxy(T invoker) { + return (T) getBean(invoker.getClass()); + } + + + /** + * 获取spring上下文 + */ + public static ApplicationContext context() { + return getApplicationContext(); + } + + public static boolean isVirtual() { + return Threading.VIRTUAL.isActive(getBean(Environment.class)); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StreamUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StreamUtils.java new file mode 100644 index 0000000..859e045 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StreamUtils.java @@ -0,0 +1,282 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.*; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * stream 流工具类 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StreamUtils { + + /** + * 将collection过滤 + * + * @param collection 需要转化的集合 + * @param function 过滤方法 + * @return 过滤后的list + */ + public static List filter(Collection collection, Predicate function) { + if (CollUtil.isEmpty(collection)) { + return CollUtil.newArrayList(); + } + // 注意此处不要使用 .toList() 新语法 因为返回的是不可变List 会导致序列化问题 + return collection.stream().filter(function).collect(Collectors.toList()); + } + + /** + * 找到流中满足条件的第一个元素 + * + * @param collection 需要查询的集合 + * @param function 过滤方法 + * @return 找到符合条件的第一个元素,没有则返回null + */ + public static E findFirst(Collection collection, Predicate function) { + if (CollUtil.isEmpty(collection)) { + return null; + } + return collection.stream().filter(function).findFirst().orElse(null); + } + + /** + * 找到流中任意一个满足条件的元素 + * + * @param collection 需要查询的集合 + * @param function 过滤方法 + * @return 找到符合条件的任意一个元素,没有则返回null + */ + public static Optional findAny(Collection collection, Predicate function) { + if (CollUtil.isEmpty(collection)) { + return Optional.empty(); + } + return collection.stream().filter(function).findAny(); + } + + /** + * 将collection拼接 + * + * @param collection 需要转化的集合 + * @param function 拼接方法 + * @return 拼接后的list + */ + public static String join(Collection collection, Function function) { + return join(collection, function, StringUtils.SEPARATOR); + } + + /** + * 将collection拼接 + * + * @param collection 需要转化的集合 + * @param function 拼接方法 + * @param delimiter 拼接符 + * @return 拼接后的list + */ + public static String join(Collection collection, Function function, CharSequence delimiter) { + if (CollUtil.isEmpty(collection)) { + return StringUtils.EMPTY; + } + return collection.stream().map(function).filter(Objects::nonNull).collect(Collectors.joining(delimiter)); + } + + /** + * 将collection排序 + * + * @param collection 需要转化的集合 + * @param comparing 排序方法 + * @return 排序后的list + */ + public static List sorted(Collection collection, Comparator comparing) { + if (CollUtil.isEmpty(collection)) { + return CollUtil.newArrayList(); + } + // 注意此处不要使用 .toList() 新语法 因为返回的是不可变List 会导致序列化问题 + return collection.stream().filter(Objects::nonNull).sorted(comparing).collect(Collectors.toList()); + } + + /** + * 将collection转化为类型不变的map
+ * {@code Collection ----> Map} + * + * @param collection 需要转化的集合 + * @param key V类型转化为K类型的lambda方法 + * @param collection中的泛型 + * @param map中的key类型 + * @return 转化后的map + */ + public static Map toIdentityMap(Collection collection, Function key) { + if (CollUtil.isEmpty(collection)) { + return MapUtil.newHashMap(); + } + return collection.stream().filter(Objects::nonNull).collect(Collectors.toMap(key, Function.identity(), (l, r) -> l)); + } + + /** + * 将Collection转化为map(value类型与collection的泛型不同)
+ * {@code Collection -----> Map } + * + * @param collection 需要转化的集合 + * @param key E类型转化为K类型的lambda方法 + * @param value E类型转化为V类型的lambda方法 + * @param collection中的泛型 + * @param map中的key类型 + * @param map中的value类型 + * @return 转化后的map + */ + public static Map toMap(Collection collection, Function key, Function value) { + if (CollUtil.isEmpty(collection)) { + return MapUtil.newHashMap(); + } + return collection.stream().filter(Objects::nonNull).collect(Collectors.toMap(key, value, (l, r) -> l)); + } + + /** + * 将collection按照规则(比如有相同的班级id)分类成map
+ * {@code Collection -------> Map> } + * + * @param collection 需要分类的集合 + * @param key 分类的规则 + * @param collection中的泛型 + * @param map中的key类型 + * @return 分类后的map + */ + public static Map> groupByKey(Collection collection, Function key) { + if (CollUtil.isEmpty(collection)) { + return MapUtil.newHashMap(); + } + return collection + .stream().filter(Objects::nonNull) + .collect(Collectors.groupingBy(key, LinkedHashMap::new, Collectors.toList())); + } + + /** + * 将collection按照两个规则(比如有相同的年级id,班级id)分类成双层map
+ * {@code Collection ---> Map>> } + * + * @param collection 需要分类的集合 + * @param key1 第一个分类的规则 + * @param key2 第二个分类的规则 + * @param 集合元素类型 + * @param 第一个map中的key类型 + * @param 第二个map中的key类型 + * @return 分类后的map + */ + public static Map>> groupBy2Key(Collection collection, Function key1, Function key2) { + if (CollUtil.isEmpty(collection)) { + return MapUtil.newHashMap(); + } + return collection + .stream().filter(Objects::nonNull) + .collect(Collectors.groupingBy(key1, LinkedHashMap::new, Collectors.groupingBy(key2, LinkedHashMap::new, Collectors.toList()))); + } + + /** + * 将collection按照两个规则(比如有相同的年级id,班级id)分类成双层map
+ * {@code Collection ---> Map> } + * + * @param collection 需要分类的集合 + * @param key1 第一个分类的规则 + * @param key2 第二个分类的规则 + * @param 第一个map中的key类型 + * @param 第二个map中的key类型 + * @param collection中的泛型 + * @return 分类后的map + */ + public static Map> group2Map(Collection collection, Function key1, Function key2) { + if (CollUtil.isEmpty(collection) || key1 == null || key2 == null) { + return MapUtil.newHashMap(); + } + return collection + .stream().filter(Objects::nonNull) + .collect(Collectors.groupingBy(key1, LinkedHashMap::new, Collectors.toMap(key2, Function.identity(), (l, r) -> l))); + } + + /** + * 将collection转化为List集合,但是两者的泛型不同
+ * {@code Collection ------> List } + * + * @param collection 需要转化的集合 + * @param function collection中的泛型转化为list泛型的lambda表达式 + * @param collection中的泛型 + * @param List中的泛型 + * @return 转化后的list + */ + public static List toList(Collection collection, Function function) { + if (CollUtil.isEmpty(collection)) { + return CollUtil.newArrayList(); + } + return collection + .stream() + .map(function) + .filter(Objects::nonNull) + // 注意此处不要使用 .toList() 新语法 因为返回的是不可变List 会导致序列化问题 + .collect(Collectors.toList()); + } + + /** + * 将collection转化为Set集合,但是两者的泛型不同
+ * {@code Collection ------> Set } + * + * @param collection 需要转化的集合 + * @param function collection中的泛型转化为set泛型的lambda表达式 + * @param collection中的泛型 + * @param Set中的泛型 + * @return 转化后的Set + */ + public static Set toSet(Collection collection, Function function) { + if (CollUtil.isEmpty(collection) || function == null) { + return CollUtil.newHashSet(); + } + return collection + .stream() + .map(function) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + + /** + * 合并两个相同key类型的map + * + * @param map1 第一个需要合并的 map + * @param map2 第二个需要合并的 map + * @param merge 合并的lambda,将key value1 value2合并成最终的类型,注意value可能为空的情况 + * @param map中的key类型 + * @param 第一个 map的value类型 + * @param 第二个 map的value类型 + * @param 最终map的value类型 + * @return 合并后的map + */ + public static Map merge(Map map1, Map map2, BiFunction merge) { + if (MapUtil.isEmpty(map1) && MapUtil.isEmpty(map2)) { + return MapUtil.newHashMap(); + } else if (MapUtil.isEmpty(map1)) { + map1 = MapUtil.newHashMap(); + } else if (MapUtil.isEmpty(map2)) { + map2 = MapUtil.newHashMap(); + } + Set key = new HashSet<>(); + key.addAll(map1.keySet()); + key.addAll(map2.keySet()); + Map map = new HashMap<>(); + for (K t : key) { + X x = map1.get(t); + Y y = map2.get(t); + V z = merge.apply(x, y); + if (z != null) { + map.put(t, z); + } + } + return map; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StringUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StringUtils.java new file mode 100644 index 0000000..2637119 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StringUtils.java @@ -0,0 +1,365 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.Validator; +import cn.hutool.core.util.StrUtil; +import org.springframework.util.AntPathMatcher; + +import java.nio.charset.Charset; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 字符串工具类 + * + * @author pengles + */ +public class StringUtils extends org.apache.commons.lang3.StringUtils { + + public static final String SEPARATOR = ","; + + public static final String SLASH = "/"; + + @Deprecated + private StringUtils() { + } + + /** + * 获取参数不为空值 + * + * @param str defaultValue 要判断的value + * @return value 返回值 + */ + public static String blankToDefault(String str, String defaultValue) { + return StrUtil.blankToDefault(str, defaultValue); + } + + /** + * * 判断一个字符串是否为空串 + * + * @param str String + * @return true:为空 false:非空 + */ + public static boolean isEmpty(String str) { + return StrUtil.isEmpty(str); + } + + /** + * * 判断一个字符串是否为非空串 + * + * @param str String + * @return true:非空串 false:空串 + */ + public static boolean isNotEmpty(String str) { + return !isEmpty(str); + } + + /** + * 去空格 + */ + public static String trim(String str) { + return StrUtil.trim(str); + } + + /** + * 截取字符串 + * + * @param str 字符串 + * @param start 开始 + * @return 结果 + */ + public static String substring(final String str, int start) { + return substring(str, start, str.length()); + } + + /** + * 截取字符串 + * + * @param str 字符串 + * @param start 开始 + * @param end 结束 + * @return 结果 + */ + public static String substring(final String str, int start, int end) { + return StrUtil.sub(str, start, end); + } + + /** + * 格式化文本, {} 表示占位符
+ * 此方法只是简单将占位符 {} 按照顺序替换为参数
+ * 如果想输出 {} 使用 \\转义 { 即可,如果想输出 {} 之前的 \ 使用双转义符 \\\\ 即可
+ * 例:
+ * 通常使用:format("this is {} for {}", "a", "b") -> this is a for b
+ * 转义{}: format("this is \\{} for {}", "a", "b") -> this is {} for a
+ * 转义\: format("this is \\\\{} for {}", "a", "b") -> this is \a for b
+ * + * @param template 文本模板,被替换的部分用 {} 表示 + * @param params 参数值 + * @return 格式化后的文本 + */ + public static String format(String template, Object... params) { + return StrUtil.format(template, params); + } + + /** + * 是否为http(s)://开头 + * + * @param link 链接 + * @return 结果 + */ + public static boolean ishttp(String link) { + return Validator.isUrl(link); + } + + /** + * 字符串转set + * + * @param str 字符串 + * @param sep 分隔符 + * @return set集合 + */ + public static Set str2Set(String str, String sep) { + return new HashSet<>(str2List(str, sep, true, false)); + } + + /** + * 字符串转list + * + * @param str 字符串 + * @param sep 分隔符 + * @param filterBlank 过滤纯空白 + * @param trim 去掉首尾空白 + * @return list集合 + */ + public static List str2List(String str, String sep, boolean filterBlank, boolean trim) { + List list = new ArrayList<>(); + if (isEmpty(str)) { + return list; + } + + // 过滤空白字符串 + if (filterBlank && isBlank(str)) { + return list; + } + String[] split = str.split(sep); + for (String string : split) { + if (filterBlank && isBlank(string)) { + continue; + } + if (trim) { + string = trim(string); + } + list.add(string); + } + + return list; + } + + /** + * 查找指定字符串是否包含指定字符串列表中的任意一个字符串同时串忽略大小写 + * + * @param cs 指定字符串 + * @param searchCharSequences 需要检查的字符串数组 + * @return 是否包含任意一个字符串 + */ + public static boolean containsAnyIgnoreCase(CharSequence cs, CharSequence... searchCharSequences) { + return StrUtil.containsAnyIgnoreCase(cs, searchCharSequences); + } + + /** + * 驼峰转下划线命名 + */ + public static String toUnderScoreCase(String str) { + return StrUtil.toUnderlineCase(str); + } + + /** + * 是否包含字符串 + * + * @param str 验证字符串 + * @param strs 字符串组 + * @return 包含返回true + */ + public static boolean inStringIgnoreCase(String str, String... strs) { + return StrUtil.equalsAnyIgnoreCase(str, strs); + } + + /** + * 将下划线大写方式命名的字符串转换为驼峰式。如果转换前的下划线大写方式命名的字符串为空,则返回空字符串。 例如:HELLO_WORLD->HelloWorld + * + * @param name 转换前的下划线大写方式命名的字符串 + * @return 转换后的驼峰式命名的字符串 + */ + public static String convertToCamelCase(String name) { + return StrUtil.upperFirst(StrUtil.toCamelCase(name)); + } + + /** + * 驼峰式命名法 例如:user_name->userName + */ + public static String toCamelCase(String s) { + return StrUtil.toCamelCase(s); + } + + /** + * 查找指定字符串是否匹配指定字符串列表中的任意一个字符串 + * + * @param str 指定字符串 + * @param strs 需要检查的字符串数组 + * @return 是否匹配 + */ + public static boolean matches(String str, List strs) { + if (isEmpty(str) || CollUtil.isEmpty(strs)) { + return false; + } + for (String pattern : strs) { + if (isMatch(pattern, str)) { + return true; + } + } + return false; + } + + /** + * 判断url是否与规则配置: + * ? 表示单个字符; + * * 表示一层路径内的任意字符串,不可跨层级; + * ** 表示任意层路径; + * + * @param pattern 匹配规则 + * @param url 需要匹配的url + */ + public static boolean isMatch(String pattern, String url) { + AntPathMatcher matcher = new AntPathMatcher(); + return matcher.match(pattern, url); + } + + /** + * 数字左边补齐0,使之达到指定长度。注意,如果数字转换为字符串后,长度大于size,则只保留 最后size个字符。 + * + * @param num 数字对象 + * @param size 字符串指定长度 + * @return 返回数字的字符串格式,该字符串为指定长度。 + */ + public static String padl(final Number num, final int size) { + return padl(num.toString(), size, '0'); + } + + /** + * 字符串左补齐。如果原始字符串s长度大于size,则只保留最后size个字符。 + * + * @param s 原始字符串 + * @param size 字符串指定长度 + * @param c 用于补齐的字符 + * @return 返回指定长度的字符串,由原字符串左补齐或截取得到。 + */ + public static String padl(final String s, final int size, final char c) { + final StringBuilder sb = new StringBuilder(size); + if (s != null) { + final int len = s.length(); + if (s.length() <= size) { + sb.append(String.valueOf(c).repeat(size - len)); + sb.append(s); + } else { + return s.substring(len - size, len); + } + } else { + sb.append(String.valueOf(c).repeat(Math.max(0, size))); + } + return sb.toString(); + } + + /** + * 切分字符串(分隔符默认逗号) + * + * @param str 被切分的字符串 + * @return 分割后的数据列表 + */ + public static List splitList(String str) { + return splitTo(str, Convert::toStr); + } + + /** + * 切分字符串 + * + * @param str 被切分的字符串 + * @param separator 分隔符 + * @return 分割后的数据列表 + */ + public static List splitList(String str, String separator) { + return splitTo(str, separator, Convert::toStr); + } + + /** + * 切分字符串自定义转换(分隔符默认逗号) + * + * @param str 被切分的字符串 + * @param mapper 自定义转换 + * @return 分割后的数据列表 + */ + public static List splitTo(String str, Function mapper) { + return splitTo(str, SEPARATOR, mapper); + } + + /** + * 切分字符串自定义转换 + * + * @param str 被切分的字符串 + * @param separator 分隔符 + * @param mapper 自定义转换 + * @return 分割后的数据列表 + */ + public static List splitTo(String str, String separator, Function mapper) { + if (isBlank(str)) { + return new ArrayList<>(0); + } + return StrUtil.split(str, separator) + .stream() + .filter(Objects::nonNull) + .map(mapper) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * 不区分大小写检查 CharSequence 是否以指定的前缀开头。 + * + * @param str 要检查的 CharSequence 可能为 null + * @param prefixs 要查找的前缀可能为 null + * @return 是否包含 + */ + public static boolean startWithAnyIgnoreCase(CharSequence str, CharSequence... prefixs) { + // 判断是否是以指定字符串开头 + for (CharSequence prefix : prefixs) { + if (StringUtils.startsWithIgnoreCase(str, prefix)) { + return true; + } + } + return false; + } + + /** + * 将字符串从源字符集转换为目标字符集 + * + * @param input 原始字符串 + * @param fromCharset 源字符集 + * @param toCharset 目标字符集 + * @return 转换后的字符串 + */ + public static String convert(String input, Charset fromCharset, Charset toCharset) { + if (isBlank(input)) { + return input; + } + try { + // 从源字符集获取字节 + byte[] bytes = input.getBytes(fromCharset); + // 使用目标字符集解码 + return new String(bytes, toCharset); + } catch (Exception e) { + return input; + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/Threads.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/Threads.java new file mode 100644 index 0000000..69d54be --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/Threads.java @@ -0,0 +1,63 @@ +package org.dromara.common.core.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.*; + +/** + * 线程相关工具类. + * + * @author pengles + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class Threads { + /** + * 停止线程池 + * 先使用shutdown, 停止接收新任务并尝试完成所有已存在任务. + * 如果超时, 则调用shutdownNow, 取消在workQueue中Pending的任务,并中断所有阻塞函数. + * 如果仍然超時,則強制退出. + * 另对在shutdown时线程本身被调用中断做了处理. + */ + public static void shutdownAndAwaitTermination(ExecutorService pool) { + if (pool != null && !pool.isShutdown()) { + pool.shutdown(); + try { + if (!pool.awaitTermination(120, TimeUnit.SECONDS)) { + pool.shutdownNow(); + if (!pool.awaitTermination(120, TimeUnit.SECONDS)) { + log.info("Pool did not terminate"); + } + } + } catch (InterruptedException ie) { + pool.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + + /** + * 打印线程异常信息 + */ + public static void printException(Runnable r, Throwable t) { + if (t == null && r instanceof Future) { + try { + Future future = (Future) r; + if (future.isDone()) { + future.get(); + } + } catch (CancellationException ce) { + t = ce; + } catch (ExecutionException ee) { + t = ee.getCause(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + if (t != null) { + log.error(t.getMessage(), t); + } + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/TreeBuildUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/TreeBuildUtils.java new file mode 100644 index 0000000..c07dae9 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/TreeBuildUtils.java @@ -0,0 +1,123 @@ +package org.dromara.common.core.utils; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.tree.Tree; +import cn.hutool.core.lang.tree.TreeNodeConfig; +import cn.hutool.core.lang.tree.TreeUtil; +import cn.hutool.core.lang.tree.parser.NodeParser; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.reflect.ReflectUtils; + +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 扩展 hutool TreeUtil 封装系统树构建 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TreeBuildUtils extends TreeUtil { + + /** + * 根据前端定制差异化字段 + */ + public static final TreeNodeConfig DEFAULT_CONFIG = TreeNodeConfig.DEFAULT_CONFIG.setNameKey("label"); + + /** + * 构建树形结构 + * + * @param 输入节点的类型 + * @param 节点ID的类型 + * @param list 节点列表,其中包含了要构建树形结构的所有节点 + * @param nodeParser 解析器,用于将输入节点转换为树节点 + * @return 构建好的树形结构列表 + */ + public static List> build(List list, NodeParser nodeParser) { + if (CollUtil.isEmpty(list)) { + return CollUtil.newArrayList(); + } + K k = ReflectUtils.invokeGetter(list.get(0), "parentId"); + return TreeUtil.build(list, k, DEFAULT_CONFIG, nodeParser); + } + + /** + * 构建树形结构 + * + * @param 输入节点的类型 + * @param 节点ID的类型 + * @param parentId 顶级节点 + * @param list 节点列表,其中包含了要构建树形结构的所有节点 + * @param nodeParser 解析器,用于将输入节点转换为树节点 + * @return 构建好的树形结构列表 + */ + public static List> build(List list, K parentId, NodeParser nodeParser) { + if (CollUtil.isEmpty(list)) { + return CollUtil.newArrayList(); + } + return TreeUtil.build(list, parentId, DEFAULT_CONFIG, nodeParser); + } + + /** + * 构建多根节点的树结构(支持多个顶级节点) + * + * @param list 原始数据列表 + * @param getId 获取节点 ID 的方法引用,例如:node -> node.getId() + * @param getParentId 获取节点父级 ID 的方法引用,例如:node -> node.getParentId() + * @param parser 树节点属性映射器,用于将原始节点 T 转为 Tree 节点 + * @param 原始数据类型(如实体类、DTO 等) + * @param 节点 ID 类型(如 Long、String) + * @return 构建完成的树形结构(可能包含多个顶级根节点) + */ + public static List> buildMultiRoot(List list, Function getId, Function getParentId, NodeParser parser) { + if (CollUtil.isEmpty(list)) { + return CollUtil.newArrayList(); + } + + Set rootParentIds = StreamUtils.toSet(list, getParentId); + rootParentIds.removeAll(StreamUtils.toSet(list, getId)); + + // 构建每一个根 parentId 下的树,并合并成最终结果列表 + return rootParentIds.stream() + .flatMap(rootParentId -> TreeUtil.build(list, rootParentId, parser).stream()) + .collect(Collectors.toList()); + } + + /** + * 获取节点列表中所有节点的叶子节点 + * + * @param 节点ID的类型 + * @param nodes 节点列表 + * @return 包含所有叶子节点的列表 + */ + public static List> getLeafNodes(List> nodes) { + if (CollUtil.isEmpty(nodes)) { + return CollUtil.newArrayList(); + } + return nodes.stream() + .flatMap(TreeBuildUtils::extractLeafNodes) + .collect(Collectors.toList()); + } + + /** + * 获取指定节点下的所有叶子节点 + * + * @param 节点ID的类型 + * @param node 要查找叶子节点的根节点 + * @return 包含所有叶子节点的列表 + */ + private static Stream> extractLeafNodes(Tree node) { + if (!node.hasChild()) { + return Stream.of(node); + } else { + // 递归调用,获取所有子节点的叶子节点 + return node.getChildren().stream() + .flatMap(TreeBuildUtils::extractLeafNodes); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ValidatorUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ValidatorUtils.java new file mode 100644 index 0000000..66db70e --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ValidatorUtils.java @@ -0,0 +1,35 @@ +package org.dromara.common.core.utils; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Set; + +/** + * Validator 校验框架工具 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ValidatorUtils { + + private static final Validator VALID = SpringUtils.getBean(Validator.class); + + /** + * 对给定对象进行参数校验,并根据指定的校验组进行校验 + * + * @param object 要进行校验的对象 + * @param groups 校验组 + * @throws ConstraintViolationException 如果校验不通过,则抛出参数校验异常 + */ + public static void validate(T object, Class... groups) { + Set> validate = VALID.validate(object, groups); + if (!validate.isEmpty()) { + throw new ConstraintViolationException("参数校验异常", validate); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/file/FileUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/file/FileUtils.java new file mode 100644 index 0000000..48cffad --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/file/FileUtils.java @@ -0,0 +1,43 @@ +package org.dromara.common.core.utils.file; + +import cn.hutool.core.io.FileUtil; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * 文件处理工具类 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FileUtils extends FileUtil { + + /** + * 下载文件名重新编码 + * + * @param response 响应对象 + * @param realFileName 真实文件名 + */ + public static void setAttachmentResponseHeader(HttpServletResponse response, String realFileName) { + String percentEncodedFileName = percentEncode(realFileName); + String contentDispositionValue = "attachment; filename=%s;filename*=utf-8''%s".formatted(percentEncodedFileName, percentEncodedFileName); + response.addHeader("Access-Control-Expose-Headers", "Content-Disposition,download-filename"); + response.setHeader("Content-disposition", contentDispositionValue); + response.setHeader("download-filename", percentEncodedFileName); + } + + /** + * 百分号编码工具方法 + * + * @param s 需要百分号编码的字符串 + * @return 百分号编码后的字符串 + */ + public static String percentEncode(String s) { + String encode = URLEncoder.encode(s, StandardCharsets.UTF_8); + return encode.replaceAll("\\+", "%20"); + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/file/MimeTypeUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/file/MimeTypeUtils.java new file mode 100644 index 0000000..4f61ded --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/file/MimeTypeUtils.java @@ -0,0 +1,40 @@ +package org.dromara.common.core.utils.file; + +/** + * 媒体类型工具类 + * + * @author pengles + */ +public class MimeTypeUtils { + public static final String IMAGE_PNG = "image/png"; + + public static final String IMAGE_JPG = "image/jpg"; + + public static final String IMAGE_JPEG = "image/jpeg"; + + public static final String IMAGE_BMP = "image/bmp"; + + public static final String IMAGE_GIF = "image/gif"; + + public static final String[] IMAGE_EXTENSION = {"bmp", "gif", "jpg", "jpeg", "png"}; + + public static final String[] FLASH_EXTENSION = {"swf", "flv"}; + + public static final String[] MEDIA_EXTENSION = {"swf", "flv", "mp3", "wav", "wma", "wmv", "mid", "avi", "mpg", + "asf", "rm", "rmvb"}; + + public static final String[] VIDEO_EXTENSION = {"mp4", "avi", "rmvb"}; + + public static final String[] DEFAULT_ALLOWED_EXTENSION = { + // 图片 + "bmp", "gif", "jpg", "jpeg", "png", + // word excel powerpoint + "doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt", + // 压缩文件 + "rar", "zip", "gz", "bz2", + // 视频格式 + "mp4", "avi", "rmvb", + // pdf + "pdf"}; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java new file mode 100644 index 0000000..72027fe --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java @@ -0,0 +1,70 @@ +package org.dromara.common.core.utils.ip; + +import cn.hutool.http.HtmlUtil; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.NetUtils; +import org.dromara.common.core.utils.StringUtils; + +/** + * 获取地址类 + * + * @author pengles + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AddressUtils { + + // 未知IP + public static final String UNKNOWN_IP = "XX XX"; + // 内网地址 + public static final String LOCAL_ADDRESS = "内网IP"; + // 未知地址 + public static final String UNKNOWN_ADDRESS = "未知"; + + public static String getRealAddressByIP(String ip) { + // 处理空串并过滤HTML标签 + ip = HtmlUtil.cleanHtmlTag(StringUtils.blankToDefault(ip,"")); + // 判断是否为IPv4 + if (NetUtils.isIPv4(ip)) { + return resolverIPv4Region(ip); + } + // 判断是否为IPv6 + if (NetUtils.isIPv6(ip)) { + return resolverIPv6Region(ip); + } + // 如果不是IPv4或IPv6,则返回未知IP + return UNKNOWN_IP; + } + + /** + * 根据IPv4地址查询IP归属行政区域 + * @param ip ipv4地址 + * @return 归属行政区域 + */ + private static String resolverIPv4Region(String ip){ + // 内网不查询 + if (NetUtils.isInnerIP(ip)) { + return LOCAL_ADDRESS; + } + return RegionUtils.getCityInfo(ip); + } + + /** + * 根据IPv6地址查询IP归属行政区域 + * @param ip ipv6地址 + * @return 归属行政区域 + */ + private static String resolverIPv6Region(String ip){ + // 内网不查询 + if (NetUtils.isInnerIPv6(ip)) { + return LOCAL_ADDRESS; + } + log.warn("ip2region不支持IPV6地址解析:{}", ip); + // 不支持IPv6,不再进行没有必要的IP地址信息的解析,直接返回 + // 如有需要,可自行实现IPv6地址信息解析逻辑,并在这里返回 + return UNKNOWN_ADDRESS; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java new file mode 100644 index 0000000..c9e8678 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java @@ -0,0 +1,51 @@ +package org.dromara.common.core.utils.ip; + +import cn.hutool.core.io.resource.NoResourceException; +import cn.hutool.core.io.resource.ResourceUtil; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; +import org.lionsoul.ip2region.xdb.Searcher; + +/** + * 根据ip地址定位工具类,离线方式 + * 参考地址:集成 ip2region 实现离线IP地址定位库 + * + * @author lishuyan + */ +@Slf4j +public class RegionUtils { + + // IP地址库文件名称 + public static final String IP_XDB_FILENAME = "ip2region.xdb"; + + private static final Searcher SEARCHER; + + static { + try { + // 1、将 ip2region 数据库文件 xdb 从 ClassPath 加载到内存。 + // 2、基于加载到内存的 xdb 数据创建一个 Searcher 查询对象。 + SEARCHER = Searcher.newWithBuffer(ResourceUtil.readBytes(IP_XDB_FILENAME)); + log.info("RegionUtils初始化成功,加载IP地址库数据成功!"); + } catch (NoResourceException e) { + throw new ServiceException("RegionUtils初始化失败,原因:IP地址库数据不存在!"); + } catch (Exception e) { + throw new ServiceException("RegionUtils初始化失败,原因:" + e.getMessage()); + } + } + + /** + * 根据IP地址离线获取城市 + */ + public static String getCityInfo(String ip) { + try { + // 3、执行查询 + String region = SEARCHER.search(StringUtils.trim(ip)); + return region.replace("0|", "").replace("|0", ""); + } catch (Exception e) { + log.error("IP地址离线获取城市异常 {}", ip); + return "未知"; + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/reflect/ReflectUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/reflect/ReflectUtils.java new file mode 100644 index 0000000..fcfcef7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/reflect/ReflectUtils.java @@ -0,0 +1,56 @@ +package org.dromara.common.core.utils.reflect; + +import cn.hutool.core.util.ReflectUtil; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.StringUtils; + +import java.lang.reflect.Method; + +/** + * 反射工具类. 提供调用getter/setter方法, 访问私有变量, 调用私有方法, 获取泛型类型Class, 被AOP过的真实类等工具函数. + * + * @author pengles + */ +@SuppressWarnings("rawtypes") +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ReflectUtils extends ReflectUtil { + + private static final String SETTER_PREFIX = "set"; + + private static final String GETTER_PREFIX = "get"; + + /** + * 调用Getter方法. + * 支持多级,如:对象名.对象名.方法 + */ + @SuppressWarnings("unchecked") + public static E invokeGetter(Object obj, String propertyName) { + Object object = obj; + for (String name : StringUtils.split(propertyName, ".")) { + String getterMethodName = GETTER_PREFIX + StringUtils.capitalize(name); + object = invoke(object, getterMethodName); + } + return (E) object; + } + + /** + * 调用Setter方法, 仅匹配方法名。 + * 支持多级,如:对象名.对象名.方法 + */ + public static void invokeSetter(Object obj, String propertyName, E value) { + Object object = obj; + String[] names = StringUtils.split(propertyName, "."); + for (int i = 0; i < names.length; i++) { + if (i < names.length - 1) { + String getterMethodName = GETTER_PREFIX + StringUtils.capitalize(names[i]); + object = invoke(object, getterMethodName); + } else { + String setterMethodName = SETTER_PREFIX + StringUtils.capitalize(names[i]); + Method method = getMethodByName(object.getClass(), setterMethodName); + invoke(object, method, value); + } + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexUtils.java new file mode 100644 index 0000000..6dde129 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexUtils.java @@ -0,0 +1,31 @@ +package org.dromara.common.core.utils.regex; + + +import cn.hutool.core.util.ReUtil; +import org.dromara.common.core.constant.RegexConstants; + +/** + * 正则相关工具类 + * + * @author Feng + */ +public final class RegexUtils extends ReUtil { + + /** + * 从输入字符串中提取匹配的部分,如果没有匹配则返回默认值 + * + * @param input 要提取的输入字符串 + * @param regex 用于匹配的正则表达式,可以使用 {@link RegexConstants} 中定义的常量 + * @param defaultInput 如果没有匹配时返回的默认值 + * @return 如果找到匹配的部分,则返回匹配的部分,否则返回默认值 + */ + public static String extractFromString(String input, String regex, String defaultInput) { + try { + String str = ReUtil.get(regex, input, 1); + return str == null ? defaultInput : str; + } catch (Exception e) { + return defaultInput; + } + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexValidator.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexValidator.java new file mode 100644 index 0000000..c0dda20 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexValidator.java @@ -0,0 +1,105 @@ +package org.dromara.common.core.utils.regex; + +import cn.hutool.core.exceptions.ValidateException; +import cn.hutool.core.lang.Validator; +import org.dromara.common.core.factory.RegexPatternPoolFactory; + +import java.util.regex.Pattern; + +/** + * 正则字段校验器 + * 主要验证字段非空、是否为满足指定格式等 + * + * @author Feng + */ +public class RegexValidator extends Validator { + + /** + * 字典类型必须以字母开头,且只能为(小写字母,数字,下滑线) + */ + public static final Pattern DICTIONARY_TYPE = RegexPatternPoolFactory.DICTIONARY_TYPE; + + /** + * 身份证号码(后6位) + */ + public static final Pattern ID_CARD_LAST_6 = RegexPatternPoolFactory.ID_CARD_LAST_6; + + /** + * QQ号码 + */ + public static final Pattern QQ_NUMBER = RegexPatternPoolFactory.QQ_NUMBER; + + /** + * 邮政编码 + */ + public static final Pattern POSTAL_CODE = RegexPatternPoolFactory.POSTAL_CODE; + + /** + * 注册账号 + */ + public static final Pattern ACCOUNT = RegexPatternPoolFactory.ACCOUNT; + + /** + * 密码:包含至少8个字符,包括大写字母、小写字母、数字和特殊字符 + */ + public static final Pattern PASSWORD = RegexPatternPoolFactory.PASSWORD; + + /** + * 通用状态(0表示正常,1表示停用) + */ + public static final Pattern STATUS = RegexPatternPoolFactory.STATUS; + + + /** + * 检查输入的账号是否匹配预定义的规则 + * + * @param value 要验证的账号 + * @return 如果账号符合规则,返回 true;否则,返回 false。 + */ + public static boolean isAccount(CharSequence value) { + return isMatchRegex(ACCOUNT, value); + } + + /** + * 验证输入的账号是否符合规则,如果不符合,则抛出 ValidateException 异常 + * + * @param value 要验证的账号 + * @param errorMsg 验证失败时抛出的异常消息 + * @param CharSequence 的子类型 + * @return 如果验证通过,返回输入的账号 + * @throws ValidateException 如果验证失败 + */ + public static T validateAccount(T value, String errorMsg) throws ValidateException { + if (!isAccount(value)) { + throw new ValidateException(errorMsg); + } + return value; + } + + /** + * 检查输入的状态是否匹配预定义的规则 + * + * @param value 要验证的状态 + * @return 如果状态符合规则,返回 true;否则,返回 false。 + */ + public static boolean isStatus(CharSequence value) { + return isMatchRegex(STATUS, value); + } + + /** + * 验证输入的状态是否符合规则,如果不符合,则抛出 ValidateException 异常 + * + * @param value 要验证的状态 + * @param errorMsg 验证失败时抛出的异常消息 + * @param CharSequence 的子类型 + * @return 如果验证通过,返回输入的状态 + * @throws ValidateException 如果验证失败 + */ + public static T validateStatus(T value, String errorMsg) throws ValidateException { + if (!isStatus(value)) { + throw new ValidateException(errorMsg); + } + return value; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java new file mode 100644 index 0000000..5c83c6b --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/sql/SqlUtil.java @@ -0,0 +1,56 @@ +package org.dromara.common.core.utils.sql; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.StringUtils; + +/** + * sql操作工具类 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SqlUtil { + + /** + * 定义常用的 sql关键字 + */ + public static String SQL_REGEX = "\u000B|and |extractvalue|updatexml|sleep|exec |insert |select |delete |update |drop |count |chr |mid |master |truncate |char |declare |or |union |like |+|/*|user()"; + + /** + * 仅支持字母、数字、下划线、空格、逗号、小数点(支持多个字段排序) + */ + public static final String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+"; + + /** + * 检查字符,防止注入绕过 + */ + public static String escapeOrderBySql(String value) { + if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value)) { + throw new IllegalArgumentException("参数不符合规范,不能进行查询"); + } + return value; + } + + /** + * 验证 order by 语法是否符合规范 + */ + public static boolean isValidOrderBySql(String value) { + return value.matches(SQL_PATTERN); + } + + /** + * SQL关键字检查 + */ + public static void filterKeyword(String value) { + if (StringUtils.isEmpty(value)) { + return; + } + String[] sqlKeywords = StringUtils.split(SQL_REGEX, "\\|"); + for (String sqlKeyword : sqlKeywords) { + if (StringUtils.indexOfIgnoreCase(value, sqlKeyword) > -1) { + throw new IllegalArgumentException("参数存在SQL注入风险"); + } + } + } +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/AddGroup.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/AddGroup.java new file mode 100644 index 0000000..4512276 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/AddGroup.java @@ -0,0 +1,9 @@ +package org.dromara.common.core.validate; + +/** + * 校验分组 add + * + * @author pengles + */ +public interface AddGroup { +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/EditGroup.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/EditGroup.java new file mode 100644 index 0000000..365df79 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/EditGroup.java @@ -0,0 +1,9 @@ +package org.dromara.common.core.validate; + +/** + * 校验分组 edit + * + * @author pengles + */ +public interface EditGroup { +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/QueryGroup.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/QueryGroup.java new file mode 100644 index 0000000..f1367b4 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/QueryGroup.java @@ -0,0 +1,9 @@ +package org.dromara.common.core.validate; + +/** + * 校验分组 query + * + * @author pengles + */ +public interface QueryGroup { +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPattern.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPattern.java new file mode 100644 index 0000000..73fc4c4 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPattern.java @@ -0,0 +1,40 @@ +package org.dromara.common.core.validate.dicts; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 字典项校验注解 + * + * @author AprilWind + */ +@Constraint(validatedBy = DictPatternValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface DictPattern { + + /** + * 字典类型,如 "sys_user_sex" + */ + String dictType(); + + /** + * 分隔符 + */ + String separator(); + + /** + * 默认校验失败提示信息 + */ + String message() default "字典值无效"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPatternValidator.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPatternValidator.java new file mode 100644 index 0000000..558a343 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/dicts/DictPatternValidator.java @@ -0,0 +1,55 @@ +package org.dromara.common.core.validate.dicts; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.dromara.common.core.service.DictService; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; + +/** + * 自定义字典值校验器 + * + * @author AprilWind + */ +public class DictPatternValidator implements ConstraintValidator { + + /** + * 字典类型 + */ + private String dictType; + + /** + * 分隔符 + */ + private String separator = ","; + + /** + * 初始化校验器,提取注解上的字典类型 + * + * @param annotation 注解实例 + */ + @Override + public void initialize(DictPattern annotation) { + this.dictType = annotation.dictType(); + if (StringUtils.isNotBlank(annotation.separator())) { + this.separator = annotation.separator(); + } + } + + /** + * 校验字段值是否为指定字典类型中的合法值 + * + * @param value 被校验的字段值 + * @param context 校验上下文(可用于构建错误信息) + * @return true 表示校验通过(合法字典值),false 表示不通过 + */ + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (StringUtils.isBlank(dictType) || StringUtils.isBlank(value)) { + return false; + } + String dictLabel = SpringUtils.getBean(DictService.class).getDictLabel(dictType, value, separator); + return StringUtils.isNotBlank(dictLabel); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPattern.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPattern.java new file mode 100644 index 0000000..b1ae504 --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPattern.java @@ -0,0 +1,51 @@ +package org.dromara.common.core.validate.enumd; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * 自定义枚举校验 + * + * @author 秋辞未寒 + * @date 2024-12-09 + */ +@Documented +@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) +@Retention(RUNTIME) +@Repeatable(EnumPattern.List.class) // 允许在同一元素上多次使用该注解 +@Constraint(validatedBy = {EnumPatternValidator.class}) +public @interface EnumPattern { + + /** + * 需要校验的枚举类型 + */ + Class> type(); + + /** + * 枚举类型校验值字段名称 + * 需确保该字段实现了 getter 方法 + */ + String fieldName(); + + String message() default "输入值不在枚举范围内"; + + Class[] groups() default {}; + + Class[] payload() default {}; + + @Documented + @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) + @Retention(RUNTIME) + @interface List { + EnumPattern[] value(); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPatternValidator.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPatternValidator.java new file mode 100644 index 0000000..e63f44a --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/validate/enumd/EnumPatternValidator.java @@ -0,0 +1,37 @@ +package org.dromara.common.core.validate.enumd; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.reflect.ReflectUtils; + +/** + * 自定义枚举校验注解实现 + * + * @author 秋辞未寒 + * @date 2024-12-09 + */ +public class EnumPatternValidator implements ConstraintValidator { + + private EnumPattern annotation; + + @Override + public void initialize(EnumPattern annotation) { + ConstraintValidator.super.initialize(annotation); + this.annotation = annotation; + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { + if (StringUtils.isNotBlank(value)) { + String fieldName = annotation.fieldName(); + for (Object e : annotation.type().getEnumConstants()) { + if (value.equals(ReflectUtils.invokeGetter(e, fieldName))) { + return true; + } + } + } + return false; + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/xss/Xss.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/xss/Xss.java new file mode 100644 index 0000000..8f75ade --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/xss/Xss.java @@ -0,0 +1,27 @@ +package org.dromara.common.core.xss; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 自定义xss校验注解 + * + * @author pengles + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER}) +@Constraint(validatedBy = {XssValidator.class}) +public @interface Xss { + + String message() default "不允许任何脚本运行"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/xss/XssValidator.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/xss/XssValidator.java new file mode 100644 index 0000000..155759f --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/xss/XssValidator.java @@ -0,0 +1,20 @@ +package org.dromara.common.core.xss; + +import cn.hutool.core.util.ReUtil; +import cn.hutool.http.HtmlUtil; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * 自定义xss校验注解实现 + * + * @author pengles + */ +public class XssValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { + return !ReUtil.contains(HtmlUtil.RE_HTML_MARK, value); + } + +} diff --git a/ruoyi-common/ruoyi-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..43c7fcf --- /dev/null +++ b/ruoyi-common/ruoyi-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,5 @@ +org.dromara.common.core.config.ApplicationConfig +org.dromara.common.core.config.AsyncConfig +org.dromara.common.core.config.ThreadPoolConfig +org.dromara.common.core.config.ValidatorConfig +org.dromara.common.core.utils.SpringUtils diff --git a/ruoyi-common/ruoyi-common-doc/pom.xml b/ruoyi-common/ruoyi-common-doc/pom.xml new file mode 100644 index 0000000..bd50bf7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-doc/pom.xml @@ -0,0 +1,52 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-doc + + + ruoyi-common-doc 系统接口 + + + + + org.dromara + ruoyi-common-core + + + + org.springdoc + springdoc-openapi-starter-webmvc-api + + + org.springdoc + springdoc-openapi-starter-common + + + + + + org.springdoc + springdoc-openapi-starter-common + + + + com.github.therapi + therapi-runtime-javadoc + + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + + + diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/SpringDocConfig.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/SpringDocConfig.java new file mode 100644 index 0000000..8dee85d --- /dev/null +++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/SpringDocConfig.java @@ -0,0 +1,126 @@ +package org.dromara.common.doc.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.doc.config.properties.SpringDocProperties; +import org.dromara.common.doc.handler.OpenApiHandler; +import org.springdoc.core.configuration.SpringDocConfiguration; +import org.springdoc.core.customizers.OpenApiBuilderCustomizer; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.customizers.ServerBaseUrlCustomizer; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.providers.JavadocProvider; +import org.springdoc.core.service.OpenAPIService; +import org.springdoc.core.service.SecurityService; +import org.springdoc.core.utils.PropertyResolverUtils; +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.ServerProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * 接口文档配置 + * + * @author pengles + */ +@RequiredArgsConstructor +@AutoConfiguration(before = SpringDocConfiguration.class) +@EnableConfigurationProperties(SpringDocProperties.class) +@ConditionalOnProperty(name = "springdoc.api-docs.enabled", havingValue = "true", matchIfMissing = true) +public class SpringDocConfig { + + private final ServerProperties serverProperties; + + @Bean + @ConditionalOnMissingBean(OpenAPI.class) + public OpenAPI openApi(SpringDocProperties properties) { + OpenAPI openApi = new OpenAPI(); + // 文档基本信息 + SpringDocProperties.InfoProperties infoProperties = properties.getInfo(); + Info info = convertInfo(infoProperties); + openApi.info(info); + // 扩展文档信息 + openApi.externalDocs(properties.getExternalDocs()); + openApi.tags(properties.getTags()); + openApi.paths(properties.getPaths()); + openApi.components(properties.getComponents()); + Set keySet = properties.getComponents().getSecuritySchemes().keySet(); + List list = new ArrayList<>(); + SecurityRequirement securityRequirement = new SecurityRequirement(); + keySet.forEach(securityRequirement::addList); + list.add(securityRequirement); + openApi.security(list); + + return openApi; + } + + private Info convertInfo(SpringDocProperties.InfoProperties infoProperties) { + Info info = new Info(); + info.setTitle(infoProperties.getTitle()); + info.setDescription(infoProperties.getDescription()); + info.setContact(infoProperties.getContact()); + info.setLicense(infoProperties.getLicense()); + info.setVersion(infoProperties.getVersion()); + return info; + } + + /** + * 自定义 openapi 处理器 + */ + @Bean + public OpenAPIService openApiBuilder(Optional openAPI, + SecurityService securityParser, + SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils, + Optional> openApiBuilderCustomisers, + Optional> serverBaseUrlCustomisers, Optional javadocProvider) { + return new OpenApiHandler(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomisers, serverBaseUrlCustomisers, javadocProvider); + } + + /** + * 对已经生成好的 OpenApi 进行自定义操作 + */ + @Bean + public OpenApiCustomizer openApiCustomizer() { + String contextPath = serverProperties.getServlet().getContextPath(); + String finalContextPath; + if (StringUtils.isBlank(contextPath) || "/".equals(contextPath)) { + finalContextPath = ""; + } else { + finalContextPath = contextPath; + } + // 对所有路径增加前置上下文路径 + return openApi -> { + Paths oldPaths = openApi.getPaths(); + if (oldPaths instanceof PlusPaths) { + return; + } + PlusPaths newPaths = new PlusPaths(); + oldPaths.forEach((k, v) -> newPaths.addPathItem(finalContextPath + k, v)); + openApi.setPaths(newPaths); + }; + } + + /** + * 单独使用一个类便于判断 解决springdoc路径拼接重复问题 + * + * @author pengles + */ + static class PlusPaths extends Paths { + + public PlusPaths() { + super(); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/properties/SpringDocProperties.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/properties/SpringDocProperties.java new file mode 100644 index 0000000..bc4089c --- /dev/null +++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/properties/SpringDocProperties.java @@ -0,0 +1,94 @@ +package org.dromara.common.doc.config.properties; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.ExternalDocumentation; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.tags.Tag; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +import java.util.List; + +/** + * swagger 配置属性 + * + * @author pengles + */ +@Data +@ConfigurationProperties(prefix = "springdoc") +public class SpringDocProperties { + + /** + * 文档基本信息 + */ + @NestedConfigurationProperty + private InfoProperties info = new InfoProperties(); + + /** + * 扩展文档地址 + */ + @NestedConfigurationProperty + private ExternalDocumentation externalDocs; + + /** + * 标签 + */ + private List tags = null; + + /** + * 路径 + */ + @NestedConfigurationProperty + private Paths paths = null; + + /** + * 组件 + */ + @NestedConfigurationProperty + private Components components = null; + + /** + *

+ * 文档的基础属性信息 + *

+ * + * @see io.swagger.v3.oas.models.info.Info + * + * 为了 springboot 自动生产配置提示信息,所以这里复制一个类出来 + */ + @Data + public static class InfoProperties { + + /** + * 标题 + */ + private String title = null; + + /** + * 描述 + */ + private String description = null; + + /** + * 联系人信息 + */ + @NestedConfigurationProperty + private Contact contact = null; + + /** + * 许可证 + */ + @NestedConfigurationProperty + private License license = null; + + /** + * 版本 + */ + private String version = null; + + } + +} diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/handler/OpenApiHandler.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/handler/OpenApiHandler.java new file mode 100644 index 0000000..56b7369 --- /dev/null +++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/handler/OpenApiHandler.java @@ -0,0 +1,253 @@ +package org.dromara.common.doc.handler; + +import cn.hutool.core.io.IoUtil; +import io.swagger.v3.core.jackson.TypeNameResolver; +import io.swagger.v3.core.util.AnnotationsUtils; +import io.swagger.v3.oas.annotations.tags.Tags; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.springdoc.core.customizers.OpenApiBuilderCustomizer; +import org.springdoc.core.customizers.ServerBaseUrlCustomizer; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.providers.JavadocProvider; +import org.springdoc.core.service.OpenAPIService; +import org.springdoc.core.service.SecurityService; +import org.springdoc.core.utils.PropertyResolverUtils; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.web.method.HandlerMethod; + +import java.io.StringReader; +import java.lang.reflect.Method; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 自定义 openapi 处理器 + * 对源码功能进行修改 增强使用 + */ +@Slf4j +@SuppressWarnings("all") +public class OpenApiHandler extends OpenAPIService { + + /** + * The Basic error controller. + */ + private static Class basicErrorController; + + /** + * The Security parser. + */ + private final SecurityService securityParser; + + /** + * The Mappings map. + */ + private final Map mappingsMap = new HashMap<>(); + + /** + * The Springdoc tags. + */ + private final Map springdocTags = new HashMap<>(); + + /** + * The Open api builder customisers. + */ + private final Optional> openApiBuilderCustomisers; + + /** + * The server base URL customisers. + */ + private final Optional> serverBaseUrlCustomizers; + + /** + * The Spring doc config properties. + */ + private final SpringDocConfigProperties springDocConfigProperties; + + /** + * The Cached open api map. + */ + private final Map cachedOpenAPI = new HashMap<>(); + + /** + * The Property resolver utils. + */ + private final PropertyResolverUtils propertyResolverUtils; + + /** + * The javadoc provider. + */ + private final Optional javadocProvider; + + /** + * The Context. + */ + private ApplicationContext context; + + /** + * The Open api. + */ + private OpenAPI openAPI; + + /** + * The Is servers present. + */ + private boolean isServersPresent; + + /** + * The Server base url. + */ + private String serverBaseUrl; + + /** + * Instantiates a new Open api builder. + * + * @param openAPI the open api + * @param securityParser the security parser + * @param springDocConfigProperties the spring doc config properties + * @param propertyResolverUtils the property resolver utils + * @param openApiBuilderCustomizers the open api builder customisers + * @param serverBaseUrlCustomizers the server base url customizers + * @param javadocProvider the javadoc provider + */ + public OpenApiHandler(Optional openAPI, SecurityService securityParser, + SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils, + Optional> openApiBuilderCustomizers, + Optional> serverBaseUrlCustomizers, + Optional javadocProvider) { + super(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomizers, serverBaseUrlCustomizers, javadocProvider); + if (openAPI.isPresent()) { + this.openAPI = openAPI.get(); + if (this.openAPI.getComponents() == null) + this.openAPI.setComponents(new Components()); + if (this.openAPI.getPaths() == null) + this.openAPI.setPaths(new Paths()); + if (!CollectionUtils.isEmpty(this.openAPI.getServers())) + this.isServersPresent = true; + } + this.propertyResolverUtils = propertyResolverUtils; + this.securityParser = securityParser; + this.springDocConfigProperties = springDocConfigProperties; + this.openApiBuilderCustomisers = openApiBuilderCustomizers; + this.serverBaseUrlCustomizers = serverBaseUrlCustomizers; + this.javadocProvider = javadocProvider; + if (springDocConfigProperties.isUseFqn()) + TypeNameResolver.std.setUseFqn(true); + } + + @Override + public Operation buildTags(HandlerMethod handlerMethod, Operation operation, OpenAPI openAPI, Locale locale) { + + Set tags = new HashSet<>(); + Set tagsStr = new HashSet<>(); + + buildTagsFromMethod(handlerMethod.getMethod(), tags, tagsStr, locale); + buildTagsFromClass(handlerMethod.getBeanType(), tags, tagsStr, locale); + + if (!CollectionUtils.isEmpty(tagsStr)) + tagsStr = tagsStr.stream() + .map(str -> propertyResolverUtils.resolve(str, locale)) + .collect(Collectors.toSet()); + + if (springdocTags.containsKey(handlerMethod)) { + io.swagger.v3.oas.models.tags.Tag tag = springdocTags.get(handlerMethod); + tagsStr.add(tag.getName()); + if (openAPI.getTags() == null || !openAPI.getTags().contains(tag)) { + openAPI.addTagsItem(tag); + } + } + + if (!CollectionUtils.isEmpty(tagsStr)) { + if (CollectionUtils.isEmpty(operation.getTags())) + operation.setTags(new ArrayList<>(tagsStr)); + else { + Set operationTagsSet = new HashSet<>(operation.getTags()); + operationTagsSet.addAll(tagsStr); + operation.getTags().clear(); + operation.getTags().addAll(operationTagsSet); + } + } + + if (isAutoTagClasses(operation)) { + + + if (javadocProvider.isPresent()) { + String description = javadocProvider.get().getClassJavadoc(handlerMethod.getBeanType()); + if (StringUtils.isNotBlank(description)) { + io.swagger.v3.oas.models.tags.Tag tag = new io.swagger.v3.oas.models.tags.Tag(); + + // 自定义部分 修改使用java注释当tag名 + List list = IoUtil.readLines(new StringReader(description), new ArrayList<>()); + // tag.setName(tagAutoName); + tag.setName(list.get(0)); + operation.addTagsItem(list.get(0)); + + tag.setDescription(description); + if (openAPI.getTags() == null || !openAPI.getTags().contains(tag)) { + openAPI.addTagsItem(tag); + } + } + } else { + String tagAutoName = splitCamelCase(handlerMethod.getBeanType().getSimpleName()); + operation.addTagsItem(tagAutoName); + } + } + + if (!CollectionUtils.isEmpty(tags)) { + // Existing tags + List openApiTags = openAPI.getTags(); + if (!CollectionUtils.isEmpty(openApiTags)) + tags.addAll(openApiTags); + openAPI.setTags(new ArrayList<>(tags)); + } + + // Handle SecurityRequirement at operation level + io.swagger.v3.oas.annotations.security.SecurityRequirement[] securityRequirements = securityParser + .getSecurityRequirements(handlerMethod); + if (securityRequirements != null) { + if (securityRequirements.length == 0) + operation.setSecurity(Collections.emptyList()); + else + securityParser.buildSecurityRequirement(securityRequirements, operation); + } + + return operation; + } + + private void buildTagsFromMethod(Method method, Set tags, Set tagsStr, Locale locale) { + // method tags + Set tagsSet = AnnotatedElementUtils + .findAllMergedAnnotations(method, Tags.class); + Set methodTags = tagsSet.stream() + .flatMap(x -> Stream.of(x.value())).collect(Collectors.toSet()); + methodTags.addAll(AnnotatedElementUtils.findAllMergedAnnotations(method, io.swagger.v3.oas.annotations.tags.Tag.class)); + if (!CollectionUtils.isEmpty(methodTags)) { + tagsStr.addAll(StreamUtils.toSet(methodTags, tag -> propertyResolverUtils.resolve(tag.name(), locale))); + List allTags = new ArrayList<>(methodTags); + addTags(allTags, tags, locale); + } + } + + private void addTags(List sourceTags, Set tags, Locale locale) { + Optional> optionalTagSet = AnnotationsUtils + .getTags(sourceTags.toArray(new io.swagger.v3.oas.annotations.tags.Tag[0]), true); + optionalTagSet.ifPresent(tagsSet -> { + tagsSet.forEach(tag -> { + tag.name(propertyResolverUtils.resolve(tag.getName(), locale)); + tag.description(propertyResolverUtils.resolve(tag.getDescription(), locale)); + if (tags.stream().noneMatch(t -> t.getName().equals(tag.getName()))) + tags.add(tag); + }); + }); + } + +} diff --git a/ruoyi-common/ruoyi-common-doc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-doc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..fe11e76 --- /dev/null +++ b/ruoyi-common/ruoyi-common-doc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.common.doc.config.SpringDocConfig diff --git a/ruoyi-common/ruoyi-common-encrypt/pom.xml b/ruoyi-common/ruoyi-common-encrypt/pom.xml new file mode 100644 index 0000000..ed4910e --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/pom.xml @@ -0,0 +1,54 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-encrypt + + + ruoyi-common-encrypt 数据加解密模块 + + + + + + org.dromara + ruoyi-common-core + + + + org.bouncycastle + bcprov-jdk15to18 + + + + cn.hutool + hutool-crypto + + + + org.springframework + spring-webmvc + + + + com.baomidou + mybatis-plus-spring-boot3-starter + true + + + org.mybatis + mybatis-spring + + + + + + + diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/ApiEncrypt.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/ApiEncrypt.java new file mode 100644 index 0000000..803c253 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/ApiEncrypt.java @@ -0,0 +1,20 @@ +package org.dromara.common.encrypt.annotation; + +import java.lang.annotation.*; + +/** + * 强制加密注解 + * + * @author pengles + */ +@Documented +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiEncrypt { + + /** + * 响应加密忽略,默认不加密,为 true 时加密 + */ + boolean response() default false; + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/EncryptField.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/EncryptField.java new file mode 100644 index 0000000..b6943b6 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/annotation/EncryptField.java @@ -0,0 +1,44 @@ +package org.dromara.common.encrypt.annotation; + +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; + +import java.lang.annotation.*; + +/** + * 字段加密注解 + * + * @author pengles + */ +@Documented +@Inherited +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface EncryptField { + + /** + * 加密算法 + */ + AlgorithmType algorithm() default AlgorithmType.DEFAULT; + + /** + * 秘钥。AES、SM4需要 + */ + String password() default ""; + + /** + * 公钥。RSA、SM2需要 + */ + String publicKey() default ""; + + /** + * 私钥。RSA、SM2需要 + */ + String privateKey() default ""; + + /** + * 编码方式。对加密算法为BASE64的不起作用 + */ + EncodeType encode() default EncodeType.DEFAULT; + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/config/ApiDecryptAutoConfiguration.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/config/ApiDecryptAutoConfiguration.java new file mode 100644 index 0000000..098f6bc --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/config/ApiDecryptAutoConfiguration.java @@ -0,0 +1,32 @@ +package org.dromara.common.encrypt.config; + +import jakarta.servlet.DispatcherType; +import org.dromara.common.encrypt.filter.CryptoFilter; +import org.dromara.common.encrypt.properties.ApiDecryptProperties; +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; + +/** + * api 解密自动配置 + * + * @author wdhcr + */ +@AutoConfiguration +@EnableConfigurationProperties(ApiDecryptProperties.class) +@ConditionalOnProperty(value = "api-decrypt.enabled", havingValue = "true") +public class ApiDecryptAutoConfiguration { + + @Bean + public FilterRegistrationBean cryptoFilterRegistration(ApiDecryptProperties properties) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setDispatcherTypes(DispatcherType.REQUEST); + registration.setFilter(new CryptoFilter(properties)); + registration.addUrlPatterns("/*"); + registration.setName("cryptoFilter"); + registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE); + return registration; + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/config/EncryptorAutoConfiguration.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/config/EncryptorAutoConfiguration.java new file mode 100644 index 0000000..cbb0e4e --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/config/EncryptorAutoConfiguration.java @@ -0,0 +1,49 @@ +package org.dromara.common.encrypt.config; + +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusProperties; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.encrypt.core.EncryptorManager; +import org.dromara.common.encrypt.interceptor.MybatisDecryptInterceptor; +import org.dromara.common.encrypt.interceptor.MybatisEncryptInterceptor; +import org.dromara.common.encrypt.properties.EncryptorProperties; +import org.springframework.beans.factory.annotation.Autowired; +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; + +/** + * 加解密配置 + * + * @author pengles + * @version 4.6.0 + */ +@AutoConfiguration(after = MybatisPlusAutoConfiguration.class) +@EnableConfigurationProperties(EncryptorProperties.class) +@ConditionalOnProperty(value = "mybatis-encryptor.enable", havingValue = "true") +@Slf4j +public class EncryptorAutoConfiguration { + + @Autowired + private EncryptorProperties properties; + + @Bean + public EncryptorManager encryptorManager(MybatisPlusProperties mybatisPlusProperties) { + return new EncryptorManager(mybatisPlusProperties.getTypeAliasesPackage()); + } + + @Bean + public MybatisEncryptInterceptor mybatisEncryptInterceptor(EncryptorManager encryptorManager) { + return new MybatisEncryptInterceptor(encryptorManager, properties); + } + + @Bean + public MybatisDecryptInterceptor mybatisDecryptInterceptor(EncryptorManager encryptorManager) { + return new MybatisDecryptInterceptor(encryptorManager, properties); + } + +} + + + diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/EncryptContext.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/EncryptContext.java new file mode 100644 index 0000000..fd21ed1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/EncryptContext.java @@ -0,0 +1,41 @@ +package org.dromara.common.encrypt.core; + +import lombok.Data; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; + +/** + * 加密上下文 用于encryptor传递必要的参数。 + * + * @author pengles + * @version 4.6.0 + */ +@Data +public class EncryptContext { + + /** + * 默认算法 + */ + private AlgorithmType algorithm; + + /** + * 安全秘钥 + */ + private String password; + + /** + * 公钥 + */ + private String publicKey; + + /** + * 私钥 + */ + private String privateKey; + + /** + * 编码方式,base64/hex + */ + private EncodeType encode; + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/EncryptorManager.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/EncryptorManager.java new file mode 100644 index 0000000..f22f1bc --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/EncryptorManager.java @@ -0,0 +1,159 @@ +package org.dromara.common.encrypt.core; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ReflectUtil; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.io.Resources; +import org.dromara.common.core.utils.ObjectUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.encrypt.annotation.EncryptField; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.ClassMetadata; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.util.ClassUtils; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 加密管理类 + * + * @author pengles + * @version 4.6.0 + */ +@Slf4j +@NoArgsConstructor +public class EncryptorManager { + + /** + * 缓存加密器 + */ + Map encryptorMap = new ConcurrentHashMap<>(); + + /** + * 类加密字段缓存 + */ + Map, Set> fieldCache = new ConcurrentHashMap<>(); + + /** + * 构造方法传入类加密字段缓存 + * + * @param typeAliasesPackage 实体类包 + */ + public EncryptorManager(String typeAliasesPackage) { + scanEncryptClasses(typeAliasesPackage); + } + + + /** + * 获取类加密字段缓存 + */ + public Set getFieldCache(Class sourceClazz) { + return ObjectUtils.notNullGetter(fieldCache, f -> f.get(sourceClazz)); + } + + /** + * 注册加密执行者到缓存 + * + * @param encryptContext 加密执行者需要的相关配置参数 + */ + public IEncryptor registAndGetEncryptor(EncryptContext encryptContext) { + int key = encryptContext.hashCode(); + if (encryptorMap.containsKey(key)) { + return encryptorMap.get(key); + } + IEncryptor encryptor = ReflectUtil.newInstance(encryptContext.getAlgorithm().getClazz(), encryptContext); + encryptorMap.put(key, encryptor); + return encryptor; + } + + /** + * 移除缓存中的加密执行者 + * + * @param encryptContext 加密执行者需要的相关配置参数 + */ + public void removeEncryptor(EncryptContext encryptContext) { + this.encryptorMap.remove(encryptContext.hashCode()); + } + + /** + * 根据配置进行加密。会进行本地缓存对应的算法和对应的秘钥信息。 + * + * @param value 待加密的值 + * @param encryptContext 加密相关的配置信息 + */ + public String encrypt(String value, EncryptContext encryptContext) { + IEncryptor encryptor = this.registAndGetEncryptor(encryptContext); + return encryptor.encrypt(value, encryptContext.getEncode()); + } + + /** + * 根据配置进行解密 + * + * @param value 待解密的值 + * @param encryptContext 加密相关的配置信息 + */ + public String decrypt(String value, EncryptContext encryptContext) { + IEncryptor encryptor = this.registAndGetEncryptor(encryptContext); + return encryptor.decrypt(value); + } + + /** + * 通过 typeAliasesPackage 设置的扫描包 扫描缓存实体 + */ + private void scanEncryptClasses(String typeAliasesPackage) { + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + CachingMetadataReaderFactory factory = new CachingMetadataReaderFactory(); + String[] packagePatternArray = StringUtils.splitPreserveAllTokens(typeAliasesPackage, ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS); + String classpath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX; + try { + for (String packagePattern : packagePatternArray) { + String path = ClassUtils.convertClassNameToResourcePath(packagePattern); + Resource[] resources = resolver.getResources(classpath + path + "/*.class"); + for (Resource resource : resources) { + ClassMetadata classMetadata = factory.getMetadataReader(resource).getClassMetadata(); + Class clazz = Resources.classForName(classMetadata.getClassName()); + Set encryptFieldSet = getEncryptFieldSetFromClazz(clazz); + if (CollUtil.isNotEmpty(encryptFieldSet)) { + fieldCache.put(clazz, encryptFieldSet); + } + } + } + } catch (Exception e) { + log.error("初始化数据安全缓存时出错:{}", e.getMessage()); + } + } + + /** + * 获得一个类的加密字段集合 + */ + private Set getEncryptFieldSetFromClazz(Class clazz) { + Set fieldSet = new HashSet<>(); + // 判断clazz如果是接口,内部类,匿名类就直接返回 + if (clazz.isInterface() || clazz.isMemberClass() || clazz.isAnonymousClass()) { + return fieldSet; + } + while (clazz != null) { + Field[] fields = clazz.getDeclaredFields(); + fieldSet.addAll(Arrays.asList(fields)); + clazz = clazz.getSuperclass(); + } + fieldSet = fieldSet.stream().filter(field -> + field.isAnnotationPresent(EncryptField.class) && field.getType() == String.class) + .collect(Collectors.toSet()); + for (Field field : fieldSet) { + field.setAccessible(true); + } + return fieldSet; + } + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/IEncryptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/IEncryptor.java new file mode 100644 index 0000000..a56232b --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/IEncryptor.java @@ -0,0 +1,35 @@ +package org.dromara.common.encrypt.core; + +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; + +/** + * 加解者 + * + * @author pengles + * @version 4.6.0 + */ +public interface IEncryptor { + + /** + * 获得当前算法 + */ + AlgorithmType algorithm(); + + /** + * 加密 + * + * @param value 待加密字符串 + * @param encodeType 加密后的编码格式 + * @return 加密后的字符串 + */ + String encrypt(String value, EncodeType encodeType); + + /** + * 解密 + * + * @param value 待加密字符串 + * @return 解密后的字符串 + */ + String decrypt(String value); +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/AbstractEncryptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/AbstractEncryptor.java new file mode 100644 index 0000000..8c31d86 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/AbstractEncryptor.java @@ -0,0 +1,18 @@ +package org.dromara.common.encrypt.core.encryptor; + +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.core.IEncryptor; + +/** + * 所有加密执行者的基类 + * + * @author pengles + * @version 4.6.0 + */ +public abstract class AbstractEncryptor implements IEncryptor { + + public AbstractEncryptor(EncryptContext context) { + // 用户配置校验与配置注入 + } + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/AesEncryptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/AesEncryptor.java new file mode 100644 index 0000000..4347e05 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/AesEncryptor.java @@ -0,0 +1,55 @@ +package org.dromara.common.encrypt.core.encryptor; + +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; +import org.dromara.common.encrypt.utils.EncryptUtils; + +/** + * AES算法实现 + * + * @author pengles + * @version 4.6.0 + */ +public class AesEncryptor extends AbstractEncryptor { + + private final EncryptContext context; + + public AesEncryptor(EncryptContext context) { + super(context); + this.context = context; + } + + /** + * 获得当前算法 + */ + @Override + public AlgorithmType algorithm() { + return AlgorithmType.AES; + } + + /** + * 加密 + * + * @param value 待加密字符串 + * @param encodeType 加密后的编码格式 + */ + @Override + public String encrypt(String value, EncodeType encodeType) { + if (encodeType == EncodeType.HEX) { + return EncryptUtils.encryptByAesHex(value, context.getPassword()); + } else { + return EncryptUtils.encryptByAes(value, context.getPassword()); + } + } + + /** + * 解密 + * + * @param value 待加密字符串 + */ + @Override + public String decrypt(String value) { + return EncryptUtils.decryptByAes(value, context.getPassword()); + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Base64Encryptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Base64Encryptor.java new file mode 100644 index 0000000..670dd62 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Base64Encryptor.java @@ -0,0 +1,48 @@ +package org.dromara.common.encrypt.core.encryptor; + +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; +import org.dromara.common.encrypt.utils.EncryptUtils; + +/** + * Base64算法实现 + * + * @author pengles + * @version 4.6.0 + */ +public class Base64Encryptor extends AbstractEncryptor { + + public Base64Encryptor(EncryptContext context) { + super(context); + } + + /** + * 获得当前算法 + */ + @Override + public AlgorithmType algorithm() { + return AlgorithmType.BASE64; + } + + /** + * 加密 + * + * @param value 待加密字符串 + * @param encodeType 加密后的编码格式 + */ + @Override + public String encrypt(String value, EncodeType encodeType) { + return EncryptUtils.encryptByBase64(value); + } + + /** + * 解密 + * + * @param value 待加密字符串 + */ + @Override + public String decrypt(String value) { + return EncryptUtils.decryptByBase64(value); + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/RsaEncryptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/RsaEncryptor.java new file mode 100644 index 0000000..8a0f3db --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/RsaEncryptor.java @@ -0,0 +1,62 @@ +package org.dromara.common.encrypt.core.encryptor; + +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; +import org.dromara.common.encrypt.utils.EncryptUtils; + + +/** + * RSA算法实现 + * + * @author pengles + * @version 4.6.0 + */ +public class RsaEncryptor extends AbstractEncryptor { + + private final EncryptContext context; + + public RsaEncryptor(EncryptContext context) { + super(context); + String privateKey = context.getPrivateKey(); + String publicKey = context.getPublicKey(); + if (StringUtils.isAnyEmpty(privateKey, publicKey)) { + throw new IllegalArgumentException("RSA公私钥均需要提供,公钥加密,私钥解密。"); + } + this.context = context; + } + + /** + * 获得当前算法 + */ + @Override + public AlgorithmType algorithm() { + return AlgorithmType.RSA; + } + + /** + * 加密 + * + * @param value 待加密字符串 + * @param encodeType 加密后的编码格式 + */ + @Override + public String encrypt(String value, EncodeType encodeType) { + if (encodeType == EncodeType.HEX) { + return EncryptUtils.encryptByRsaHex(value, context.getPublicKey()); + } else { + return EncryptUtils.encryptByRsa(value, context.getPublicKey()); + } + } + + /** + * 解密 + * + * @param value 待加密字符串 + */ + @Override + public String decrypt(String value) { + return EncryptUtils.decryptByRsa(value, context.getPrivateKey()); + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Sm2Encryptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Sm2Encryptor.java new file mode 100644 index 0000000..e3e86f6 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Sm2Encryptor.java @@ -0,0 +1,61 @@ +package org.dromara.common.encrypt.core.encryptor; + +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; +import org.dromara.common.encrypt.utils.EncryptUtils; + +/** + * sm2算法实现 + * + * @author pengles + * @version 4.6.0 + */ +public class Sm2Encryptor extends AbstractEncryptor { + + private final EncryptContext context; + + public Sm2Encryptor(EncryptContext context) { + super(context); + String privateKey = context.getPrivateKey(); + String publicKey = context.getPublicKey(); + if (StringUtils.isAnyEmpty(privateKey, publicKey)) { + throw new IllegalArgumentException("SM2公私钥均需要提供,公钥加密,私钥解密。"); + } + this.context = context; + } + + /** + * 获得当前算法 + */ + @Override + public AlgorithmType algorithm() { + return AlgorithmType.SM2; + } + + /** + * 加密 + * + * @param value 待加密字符串 + * @param encodeType 加密后的编码格式 + */ + @Override + public String encrypt(String value, EncodeType encodeType) { + if (encodeType == EncodeType.HEX) { + return EncryptUtils.encryptBySm2Hex(value, context.getPublicKey()); + } else { + return EncryptUtils.encryptBySm2(value, context.getPublicKey()); + } + } + + /** + * 解密 + * + * @param value 待加密字符串 + */ + @Override + public String decrypt(String value) { + return EncryptUtils.decryptBySm2(value, context.getPrivateKey()); + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Sm4Encryptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Sm4Encryptor.java new file mode 100644 index 0000000..28a870d --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/core/encryptor/Sm4Encryptor.java @@ -0,0 +1,55 @@ +package org.dromara.common.encrypt.core.encryptor; + +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; +import org.dromara.common.encrypt.utils.EncryptUtils; + +/** + * sm4算法实现 + * + * @author pengles + * @version 4.6.0 + */ +public class Sm4Encryptor extends AbstractEncryptor { + + private final EncryptContext context; + + public Sm4Encryptor(EncryptContext context) { + super(context); + this.context = context; + } + + /** + * 获得当前算法 + */ + @Override + public AlgorithmType algorithm() { + return AlgorithmType.SM4; + } + + /** + * 加密 + * + * @param value 待加密字符串 + * @param encodeType 加密后的编码格式 + */ + @Override + public String encrypt(String value, EncodeType encodeType) { + if (encodeType == EncodeType.HEX) { + return EncryptUtils.encryptBySm4Hex(value, context.getPassword()); + } else { + return EncryptUtils.encryptBySm4(value, context.getPassword()); + } + } + + /** + * 解密 + * + * @param value 待加密字符串 + */ + @Override + public String decrypt(String value) { + return EncryptUtils.decryptBySm4(value, context.getPassword()); + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/enumd/AlgorithmType.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/enumd/AlgorithmType.java new file mode 100644 index 0000000..1fc221e --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/enumd/AlgorithmType.java @@ -0,0 +1,48 @@ +package org.dromara.common.encrypt.enumd; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.dromara.common.encrypt.core.encryptor.*; + +/** + * 算法名称 + * + * @author pengles + * @version 4.6.0 + */ +@Getter +@AllArgsConstructor +public enum AlgorithmType { + + /** + * 默认走yml配置 + */ + DEFAULT(null), + + /** + * base64 + */ + BASE64(Base64Encryptor.class), + + /** + * aes + */ + AES(AesEncryptor.class), + + /** + * rsa + */ + RSA(RsaEncryptor.class), + + /** + * sm2 + */ + SM2(Sm2Encryptor.class), + + /** + * sm4 + */ + SM4(Sm4Encryptor.class); + + private final Class clazz; +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/enumd/EncodeType.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/enumd/EncodeType.java new file mode 100644 index 0000000..ee1466f --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/enumd/EncodeType.java @@ -0,0 +1,26 @@ +package org.dromara.common.encrypt.enumd; + +/** + * 编码类型 + * + * @author pengles + * @version 4.6.0 + */ +public enum EncodeType { + + /** + * 默认使用yml配置 + */ + DEFAULT, + + /** + * base64编码 + */ + BASE64, + + /** + * 16进制编码 + */ + HEX; + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/CryptoFilter.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/CryptoFilter.java new file mode 100644 index 0000000..79d58da --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/CryptoFilter.java @@ -0,0 +1,110 @@ +package org.dromara.common.encrypt.filter; + +import cn.hutool.core.util.ObjectUtil; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.encrypt.annotation.ApiEncrypt; +import org.dromara.common.encrypt.properties.ApiDecryptProperties; +import org.springframework.http.HttpMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerExecutionChain; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.io.IOException; + + +/** + * Crypto 过滤器 + * + * @author wdhcr + */ +public class CryptoFilter implements Filter { + private final ApiDecryptProperties properties; + + public CryptoFilter(ApiDecryptProperties properties) { + this.properties = properties; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest servletRequest = (HttpServletRequest) request; + HttpServletResponse servletResponse = (HttpServletResponse) response; + // 获取加密注解 + ApiEncrypt apiEncrypt = this.getApiEncryptAnnotation(servletRequest); + boolean responseFlag = apiEncrypt != null && apiEncrypt.response(); + ServletRequest requestWrapper = null; + ServletResponse responseWrapper = null; + EncryptResponseBodyWrapper responseBodyWrapper = null; + + // 是否为 put 或者 post 请求 + if (HttpMethod.PUT.matches(servletRequest.getMethod()) || HttpMethod.POST.matches(servletRequest.getMethod())) { + // 是否存在加密标头 + String headerValue = servletRequest.getHeader(properties.getHeaderFlag()); + if (StringUtils.isNotBlank(headerValue)) { + // 请求解密 + requestWrapper = new DecryptRequestBodyWrapper(servletRequest, properties.getPrivateKey(), properties.getHeaderFlag()); + } else { + // 是否有注解,有就报错,没有放行 + if (ObjectUtil.isNotNull(apiEncrypt)) { + HandlerExceptionResolver exceptionResolver = SpringUtils.getBean("handlerExceptionResolver", HandlerExceptionResolver.class); + exceptionResolver.resolveException( + servletRequest, servletResponse, null, + new ServiceException("没有访问权限,请联系管理员授权", HttpStatus.FORBIDDEN)); + return; + } + } + } + + // 判断是否响应加密 + if (responseFlag) { + responseBodyWrapper = new EncryptResponseBodyWrapper(servletResponse); + responseWrapper = responseBodyWrapper; + } + + chain.doFilter( + ObjectUtil.defaultIfNull(requestWrapper, request), + ObjectUtil.defaultIfNull(responseWrapper, response)); + + if (responseFlag) { + servletResponse.reset(); + // 对原始内容加密 + String encryptContent = responseBodyWrapper.getEncryptContent( + servletResponse, properties.getPublicKey(), properties.getHeaderFlag()); + // 对加密后的内容写出 + servletResponse.getWriter().write(encryptContent); + } + } + + /** + * 获取 ApiEncrypt 注解 + */ + private ApiEncrypt getApiEncryptAnnotation(HttpServletRequest servletRequest) { + RequestMappingHandlerMapping handlerMapping = SpringUtils.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class); + // 获取注解 + try { + HandlerExecutionChain mappingHandler = handlerMapping.getHandler(servletRequest); + if (ObjectUtil.isNotNull(mappingHandler)) { + Object handler = mappingHandler.getHandler(); + if (ObjectUtil.isNotNull(handler)) { + // 从handler获取注解 + if (handler instanceof HandlerMethod handlerMethod) { + return handlerMethod.getMethodAnnotation(ApiEncrypt.class); + } + } + } + } catch (Exception e) { + return null; + } + return null; + } + + @Override + public void destroy() { + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/DecryptRequestBodyWrapper.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/DecryptRequestBodyWrapper.java new file mode 100644 index 0000000..98f4bc7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/DecryptRequestBodyWrapper.java @@ -0,0 +1,94 @@ +package org.dromara.common.encrypt.filter; + +import cn.hutool.core.io.IoUtil; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.encrypt.utils.EncryptUtils; +import org.springframework.http.MediaType; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * 解密请求参数工具类 + * + * @author wdhcr + */ +public class DecryptRequestBodyWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public DecryptRequestBodyWrapper(HttpServletRequest request, String privateKey, String headerFlag) throws IOException { + super(request); + // 获取 AES 密码 采用 RSA 加密 + String headerRsa = request.getHeader(headerFlag); + String decryptAes = EncryptUtils.decryptByRsa(headerRsa, privateKey); + // 解密 AES 密码 + String aesPassword = EncryptUtils.decryptByBase64(decryptAes); + request.setCharacterEncoding(Constants.UTF8); + byte[] readBytes = IoUtil.readBytes(request.getInputStream(), false); + String requestBody = new String(readBytes, StandardCharsets.UTF_8); + // 解密 body 采用 AES 加密 + String decryptBody = EncryptUtils.decryptByAes(requestBody, aesPassword); + body = decryptBody.getBytes(StandardCharsets.UTF_8); + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream())); + } + + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + + @Override + public String getContentType() { + return MediaType.APPLICATION_JSON_VALUE; + } + + + @Override + public ServletInputStream getInputStream() { + final ByteArrayInputStream bais = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public int read() { + return bais.read(); + } + + @Override + public int available() { + return body.length; + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setReadListener(ReadListener readListener) { + + } + }; + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/EncryptResponseBodyWrapper.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/EncryptResponseBodyWrapper.java new file mode 100644 index 0000000..41309f2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/EncryptResponseBodyWrapper.java @@ -0,0 +1,126 @@ +package org.dromara.common.encrypt.filter; + +import cn.hutool.core.util.RandomUtil; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import org.dromara.common.encrypt.utils.EncryptUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +/** + * 加密响应参数包装类 + * + * @author pengles + */ +public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper { + + private final ByteArrayOutputStream byteArrayOutputStream; + private final ServletOutputStream servletOutputStream; + private final PrintWriter printWriter; + + public EncryptResponseBodyWrapper(HttpServletResponse response) throws IOException { + super(response); + this.byteArrayOutputStream = new ByteArrayOutputStream(); + this.servletOutputStream = this.getOutputStream(); + this.printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream)); + } + + @Override + public PrintWriter getWriter() { + return printWriter; + } + + @Override + public void flushBuffer() throws IOException { + if (servletOutputStream != null) { + servletOutputStream.flush(); + } + if (printWriter != null) { + printWriter.flush(); + } + } + + @Override + public void reset() { + byteArrayOutputStream.reset(); + } + + public byte[] getResponseData() throws IOException { + flushBuffer(); + return byteArrayOutputStream.toByteArray(); + } + + public String getContent() throws IOException { + flushBuffer(); + return byteArrayOutputStream.toString(); + } + + /** + * 获取加密内容 + * + * @param servletResponse response + * @param publicKey RSA公钥 (用于加密 AES 秘钥) + * @param headerFlag 请求头标志 + * @return 加密内容 + * @throws IOException + */ + public String getEncryptContent(HttpServletResponse servletResponse, String publicKey, String headerFlag) throws IOException { + // 生成秘钥 + String aesPassword = RandomUtil.randomString(32); + // 秘钥使用 Base64 编码 + String encryptAes = EncryptUtils.encryptByBase64(aesPassword); + // Rsa 公钥加密 Base64 编码 + String encryptPassword = EncryptUtils.encryptByRsa(encryptAes, publicKey); + + // 设置响应头 + // vue版本需要设置 + servletResponse.addHeader("Access-Control-Expose-Headers", headerFlag); + servletResponse.setHeader("Access-Control-Allow-Origin", "*"); + servletResponse.setHeader("Access-Control-Allow-Methods", "*"); + servletResponse.setHeader(headerFlag, encryptPassword); + servletResponse.setCharacterEncoding(StandardCharsets.UTF_8.toString()); + + + // 获取原始内容 + String originalBody = this.getContent(); + // 对内容进行加密 + return EncryptUtils.encryptByAes(originalBody, aesPassword); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + return new ServletOutputStream() { + @Override + public boolean isReady() { + return false; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + + } + + @Override + public void write(int b) throws IOException { + byteArrayOutputStream.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + byteArrayOutputStream.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + byteArrayOutputStream.write(b, off, len); + } + }; + } + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/interceptor/MybatisDecryptInterceptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/interceptor/MybatisDecryptInterceptor.java new file mode 100644 index 0000000..9d3216d --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/interceptor/MybatisDecryptInterceptor.java @@ -0,0 +1,132 @@ +package org.dromara.common.encrypt.interceptor; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.executor.parameter.ParameterHandler; +import org.apache.ibatis.executor.resultset.ResultSetHandler; +import org.apache.ibatis.plugin.*; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.encrypt.annotation.EncryptField; +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.core.EncryptorManager; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; +import org.dromara.common.encrypt.properties.EncryptorProperties; + +import java.lang.reflect.Field; +import java.sql.Statement; +import java.util.*; + +/** + * 出参解密拦截器 + * + * @author pengles + * @version 4.6.0 + */ +@Slf4j +@Intercepts({@Signature( + type = ResultSetHandler.class, + method = "handleResultSets", + args = {Statement.class}) +}) +@AllArgsConstructor +public class MybatisDecryptInterceptor implements Interceptor { + + private final EncryptorManager encryptorManager; + private final EncryptorProperties defaultProperties; + + @Override + public Object intercept(Invocation invocation) throws Throwable { + // 开始进行参数解密 + ResultSetHandler resultSetHandler = (ResultSetHandler) invocation.getTarget(); + Field parameterHandlerField = resultSetHandler.getClass().getDeclaredField("parameterHandler"); + parameterHandlerField.setAccessible(true); + Object target = parameterHandlerField.get(resultSetHandler); + if (target instanceof ParameterHandler parameterHandler) { + Object parameterObject = parameterHandler.getParameterObject(); + if (ObjectUtil.isNotNull(parameterObject) && !(parameterObject instanceof String)) { + this.decryptHandler(parameterObject); + } + } + // 获取执行mysql执行结果 + Object result = invocation.proceed(); + if (result == null) { + return null; + } + this.decryptHandler(result); + return result; + } + + /** + * 解密对象 + * + * @param sourceObject 待加密对象 + */ + private void decryptHandler(Object sourceObject) { + if (ObjectUtil.isNull(sourceObject)) { + return; + } + if (sourceObject instanceof Map map) { + new HashSet<>(map.values()).forEach(this::decryptHandler); + return; + } + if (sourceObject instanceof List list) { + if(CollUtil.isEmpty(list)) { + return; + } + // 判断第一个元素是否含有注解。如果没有直接返回,提高效率 + Object firstItem = list.get(0); + if (ObjectUtil.isNull(firstItem) || CollUtil.isEmpty(encryptorManager.getFieldCache(firstItem.getClass()))) { + return; + } + list.forEach(this::decryptHandler); + return; + } + // 不在缓存中的类,就是没有加密注解的类(当然也有可能是typeAliasesPackage写错) + Set fields = encryptorManager.getFieldCache(sourceObject.getClass()); + if(ObjectUtil.isNull(fields)){ + return; + } + try { + for (Field field : fields) { + field.set(sourceObject, this.decryptField(Convert.toStr(field.get(sourceObject)), field)); + } + } catch (Exception e) { + log.error("处理解密字段时出错", e); + } + } + + /** + * 字段值进行加密。通过字段的批注注册新的加密算法 + * + * @param value 待加密的值 + * @param field 待加密字段 + * @return 加密后结果 + */ + private String decryptField(String value, Field field) { + if (ObjectUtil.isNull(value)) { + return null; + } + EncryptField encryptField = field.getAnnotation(EncryptField.class); + EncryptContext encryptContext = new EncryptContext(); + encryptContext.setAlgorithm(encryptField.algorithm() == AlgorithmType.DEFAULT ? defaultProperties.getAlgorithm() : encryptField.algorithm()); + encryptContext.setEncode(encryptField.encode() == EncodeType.DEFAULT ? defaultProperties.getEncode() : encryptField.encode()); + encryptContext.setPassword(StringUtils.isBlank(encryptField.password()) ? defaultProperties.getPassword() : encryptField.password()); + encryptContext.setPrivateKey(StringUtils.isBlank(encryptField.privateKey()) ? defaultProperties.getPrivateKey() : encryptField.privateKey()); + encryptContext.setPublicKey(StringUtils.isBlank(encryptField.publicKey()) ? defaultProperties.getPublicKey() : encryptField.publicKey()); + return this.encryptorManager.decrypt(value, encryptContext); + } + + @Override + public Object plugin(Object target) { + return Plugin.wrap(target, this); + } + + @Override + public void setProperties(Properties properties) { + + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/interceptor/MybatisEncryptInterceptor.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/interceptor/MybatisEncryptInterceptor.java new file mode 100644 index 0000000..dece1ca --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/interceptor/MybatisEncryptInterceptor.java @@ -0,0 +1,124 @@ +package org.dromara.common.encrypt.interceptor; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.executor.parameter.ParameterHandler; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.plugin.Intercepts; +import org.apache.ibatis.plugin.Invocation; +import org.apache.ibatis.plugin.Signature; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.encrypt.annotation.EncryptField; +import org.dromara.common.encrypt.core.EncryptContext; +import org.dromara.common.encrypt.core.EncryptorManager; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; +import org.dromara.common.encrypt.properties.EncryptorProperties; + +import java.lang.reflect.Field; +import java.sql.PreparedStatement; +import java.util.*; + +/** + * 入参加密拦截器 + * + * @author pengles + * @version 4.6.0 + */ +@Slf4j +@Intercepts({@Signature( + type = ParameterHandler.class, + method = "setParameters", + args = {PreparedStatement.class}) +}) +@AllArgsConstructor +public class MybatisEncryptInterceptor implements Interceptor { + + private final EncryptorManager encryptorManager; + private final EncryptorProperties defaultProperties; + + @Override + public Object intercept(Invocation invocation) throws Throwable { + return invocation; + } + + @Override + public Object plugin(Object target) { + if (target instanceof ParameterHandler parameterHandler) { + // 进行加密操作 + Object parameterObject = parameterHandler.getParameterObject(); + if (ObjectUtil.isNotNull(parameterObject) && !(parameterObject instanceof String)) { + this.encryptHandler(parameterObject); + } + } + return target; + } + + /** + * 加密对象 + * + * @param sourceObject 待加密对象 + */ + private void encryptHandler(Object sourceObject) { + if (ObjectUtil.isNull(sourceObject)) { + return; + } + if (sourceObject instanceof Map map) { + new HashSet<>(map.values()).forEach(this::encryptHandler); + return; + } + if (sourceObject instanceof List list) { + if(CollUtil.isEmpty(list)) { + return; + } + // 判断第一个元素是否含有注解。如果没有直接返回,提高效率 + Object firstItem = list.get(0); + if (ObjectUtil.isNull(firstItem) || CollUtil.isEmpty(encryptorManager.getFieldCache(firstItem.getClass()))) { + return; + } + list.forEach(this::encryptHandler); + return; + } + // 不在缓存中的类,就是没有加密注解的类(当然也有可能是typeAliasesPackage写错) + Set fields = encryptorManager.getFieldCache(sourceObject.getClass()); + if(ObjectUtil.isNull(fields)){ + return; + } + try { + for (Field field : fields) { + field.set(sourceObject, this.encryptField(Convert.toStr(field.get(sourceObject)), field)); + } + } catch (Exception e) { + log.error("处理加密字段时出错", e); + } + } + + /** + * 字段值进行加密。通过字段的批注注册新的加密算法 + * + * @param value 待加密的值 + * @param field 待加密字段 + * @return 加密后结果 + */ + private String encryptField(String value, Field field) { + if (ObjectUtil.isNull(value)) { + return null; + } + EncryptField encryptField = field.getAnnotation(EncryptField.class); + EncryptContext encryptContext = new EncryptContext(); + encryptContext.setAlgorithm(encryptField.algorithm() == AlgorithmType.DEFAULT ? defaultProperties.getAlgorithm() : encryptField.algorithm()); + encryptContext.setEncode(encryptField.encode() == EncodeType.DEFAULT ? defaultProperties.getEncode() : encryptField.encode()); + encryptContext.setPassword(StringUtils.isBlank(encryptField.password()) ? defaultProperties.getPassword() : encryptField.password()); + encryptContext.setPrivateKey(StringUtils.isBlank(encryptField.privateKey()) ? defaultProperties.getPrivateKey() : encryptField.privateKey()); + encryptContext.setPublicKey(StringUtils.isBlank(encryptField.publicKey()) ? defaultProperties.getPublicKey() : encryptField.publicKey()); + return this.encryptorManager.encrypt(value, encryptContext); + } + + + @Override + public void setProperties(Properties properties) { + } +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/properties/ApiDecryptProperties.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/properties/ApiDecryptProperties.java new file mode 100644 index 0000000..6aadb3e --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/properties/ApiDecryptProperties.java @@ -0,0 +1,34 @@ +package org.dromara.common.encrypt.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * api解密属性配置类 + * @author wdhcr + */ +@Data +@ConfigurationProperties(prefix = "api-decrypt") +public class ApiDecryptProperties { + + /** + * 加密开关 + */ + private Boolean enabled; + + /** + * 头部标识 + */ + private String headerFlag; + + /** + * 响应加密公钥 + */ + private String publicKey; + + /** + * 请求解密私钥 + */ + private String privateKey; + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/properties/EncryptorProperties.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/properties/EncryptorProperties.java new file mode 100644 index 0000000..2633710 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/properties/EncryptorProperties.java @@ -0,0 +1,48 @@ +package org.dromara.common.encrypt.properties; + +import lombok.Data; +import org.dromara.common.encrypt.enumd.AlgorithmType; +import org.dromara.common.encrypt.enumd.EncodeType; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 加解密属性配置类 + * + * @author pengles + * @version 4.6.0 + */ +@Data +@ConfigurationProperties(prefix = "mybatis-encryptor") +public class EncryptorProperties { + + /** + * 过滤开关 + */ + private Boolean enable; + + /** + * 默认算法 + */ + private AlgorithmType algorithm; + + /** + * 安全秘钥 + */ + private String password; + + /** + * 公钥 + */ + private String publicKey; + + /** + * 私钥 + */ + private String privateKey; + + /** + * 编码方式,base64/hex + */ + private EncodeType encode; + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/utils/EncryptUtils.java b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/utils/EncryptUtils.java new file mode 100644 index 0000000..1a11f49 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/utils/EncryptUtils.java @@ -0,0 +1,313 @@ +package org.dromara.common.encrypt.utils; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.SmUtil; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.asymmetric.RSA; +import cn.hutool.crypto.asymmetric.SM2; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * 安全相关工具类 + * + * @author pengles + */ +public class EncryptUtils { + + /** + * 公钥 + */ + public static final String PUBLIC_KEY = "publicKey"; + + /** + * 私钥 + */ + public static final String PRIVATE_KEY = "privateKey"; + + /** + * Base64加密 + * + * @param data 待加密数据 + * @return 加密后字符串 + */ + public static String encryptByBase64(String data) { + return Base64.encode(data, StandardCharsets.UTF_8); + } + + /** + * Base64解密 + * + * @param data 待解密数据 + * @return 解密后字符串 + */ + public static String decryptByBase64(String data) { + return Base64.decodeStr(data, StandardCharsets.UTF_8); + } + + /** + * AES加密 + * + * @param data 待加密数据 + * @param password 秘钥字符串 + * @return 加密后字符串, 采用Base64编码 + */ + public static String encryptByAes(String data, String password) { + if (StrUtil.isBlank(password)) { + throw new IllegalArgumentException("AES需要传入秘钥信息"); + } + // aes算法的秘钥要求是16位、24位、32位 + int[] array = {16, 24, 32}; + if (!ArrayUtil.contains(array, password.length())) { + throw new IllegalArgumentException("AES秘钥长度要求为16位、24位、32位"); + } + return SecureUtil.aes(password.getBytes(StandardCharsets.UTF_8)).encryptBase64(data, StandardCharsets.UTF_8); + } + + /** + * AES加密 + * + * @param data 待加密数据 + * @param password 秘钥字符串 + * @return 加密后字符串, 采用Hex编码 + */ + public static String encryptByAesHex(String data, String password) { + if (StrUtil.isBlank(password)) { + throw new IllegalArgumentException("AES需要传入秘钥信息"); + } + // aes算法的秘钥要求是16位、24位、32位 + int[] array = {16, 24, 32}; + if (!ArrayUtil.contains(array, password.length())) { + throw new IllegalArgumentException("AES秘钥长度要求为16位、24位、32位"); + } + return SecureUtil.aes(password.getBytes(StandardCharsets.UTF_8)).encryptHex(data, StandardCharsets.UTF_8); + } + + /** + * AES解密 + * + * @param data 待解密数据 + * @param password 秘钥字符串 + * @return 解密后字符串 + */ + public static String decryptByAes(String data, String password) { + if (StrUtil.isBlank(password)) { + throw new IllegalArgumentException("AES需要传入秘钥信息"); + } + // aes算法的秘钥要求是16位、24位、32位 + int[] array = {16, 24, 32}; + if (!ArrayUtil.contains(array, password.length())) { + throw new IllegalArgumentException("AES秘钥长度要求为16位、24位、32位"); + } + return SecureUtil.aes(password.getBytes(StandardCharsets.UTF_8)).decryptStr(data, StandardCharsets.UTF_8); + } + + /** + * SM4加密(Base64编码) + * + * @param data 待加密数据 + * @param password 秘钥字符串 + * @return 加密后字符串, 采用Base64编码 + */ + public static String encryptBySm4(String data, String password) { + if (StrUtil.isBlank(password)) { + throw new IllegalArgumentException("SM4需要传入秘钥信息"); + } + // sm4算法的秘钥要求是16位长度 + int sm4PasswordLength = 16; + if (sm4PasswordLength != password.length()) { + throw new IllegalArgumentException("SM4秘钥长度要求为16位"); + } + return SmUtil.sm4(password.getBytes(StandardCharsets.UTF_8)).encryptBase64(data, StandardCharsets.UTF_8); + } + + /** + * SM4加密(Hex编码) + * + * @param data 待加密数据 + * @param password 秘钥字符串 + * @return 加密后字符串, 采用Hex编码 + */ + public static String encryptBySm4Hex(String data, String password) { + if (StrUtil.isBlank(password)) { + throw new IllegalArgumentException("SM4需要传入秘钥信息"); + } + // sm4算法的秘钥要求是16位长度 + int sm4PasswordLength = 16; + if (sm4PasswordLength != password.length()) { + throw new IllegalArgumentException("SM4秘钥长度要求为16位"); + } + return SmUtil.sm4(password.getBytes(StandardCharsets.UTF_8)).encryptHex(data, StandardCharsets.UTF_8); + } + + /** + * sm4解密 + * + * @param data 待解密数据(可以是Base64或Hex编码) + * @param password 秘钥字符串 + * @return 解密后字符串 + */ + public static String decryptBySm4(String data, String password) { + if (StrUtil.isBlank(password)) { + throw new IllegalArgumentException("SM4需要传入秘钥信息"); + } + // sm4算法的秘钥要求是16位长度 + int sm4PasswordLength = 16; + if (sm4PasswordLength != password.length()) { + throw new IllegalArgumentException("SM4秘钥长度要求为16位"); + } + return SmUtil.sm4(password.getBytes(StandardCharsets.UTF_8)).decryptStr(data, StandardCharsets.UTF_8); + } + + /** + * 产生sm2加解密需要的公钥和私钥 + * + * @return 公私钥Map + */ + public static Map generateSm2Key() { + Map keyMap = new HashMap<>(2); + SM2 sm2 = SmUtil.sm2(); + keyMap.put(PRIVATE_KEY, sm2.getPrivateKeyBase64()); + keyMap.put(PUBLIC_KEY, sm2.getPublicKeyBase64()); + return keyMap; + } + + /** + * sm2公钥加密 + * + * @param data 待加密数据 + * @param publicKey 公钥 + * @return 加密后字符串, 采用Base64编码 + */ + public static String encryptBySm2(String data, String publicKey) { + if (StrUtil.isBlank(publicKey)) { + throw new IllegalArgumentException("SM2需要传入公钥进行加密"); + } + SM2 sm2 = SmUtil.sm2(null, publicKey); + return sm2.encryptBase64(data, StandardCharsets.UTF_8, KeyType.PublicKey); + } + + /** + * sm2公钥加密 + * + * @param data 待加密数据 + * @param publicKey 公钥 + * @return 加密后字符串, 采用Hex编码 + */ + public static String encryptBySm2Hex(String data, String publicKey) { + if (StrUtil.isBlank(publicKey)) { + throw new IllegalArgumentException("SM2需要传入公钥进行加密"); + } + SM2 sm2 = SmUtil.sm2(null, publicKey); + return sm2.encryptHex(data, StandardCharsets.UTF_8, KeyType.PublicKey); + } + + /** + * sm2私钥解密 + * + * @param data 待解密数据 + * @param privateKey 私钥 + * @return 解密后字符串 + */ + public static String decryptBySm2(String data, String privateKey) { + if (StrUtil.isBlank(privateKey)) { + throw new IllegalArgumentException("SM2需要传入私钥进行解密"); + } + SM2 sm2 = SmUtil.sm2(privateKey, null); + return sm2.decryptStr(data, KeyType.PrivateKey, StandardCharsets.UTF_8); + } + + /** + * 产生RSA加解密需要的公钥和私钥 + * + * @return 公私钥Map + */ + public static Map generateRsaKey() { + Map keyMap = new HashMap<>(2); + RSA rsa = SecureUtil.rsa(); + keyMap.put(PRIVATE_KEY, rsa.getPrivateKeyBase64()); + keyMap.put(PUBLIC_KEY, rsa.getPublicKeyBase64()); + return keyMap; + } + + /** + * rsa公钥加密 + * + * @param data 待加密数据 + * @param publicKey 公钥 + * @return 加密后字符串, 采用Base64编码 + */ + public static String encryptByRsa(String data, String publicKey) { + if (StrUtil.isBlank(publicKey)) { + throw new IllegalArgumentException("RSA需要传入公钥进行加密"); + } + RSA rsa = SecureUtil.rsa(null, publicKey); + return rsa.encryptBase64(data, StandardCharsets.UTF_8, KeyType.PublicKey); + } + + /** + * rsa公钥加密 + * + * @param data 待加密数据 + * @param publicKey 公钥 + * @return 加密后字符串, 采用Hex编码 + */ + public static String encryptByRsaHex(String data, String publicKey) { + if (StrUtil.isBlank(publicKey)) { + throw new IllegalArgumentException("RSA需要传入公钥进行加密"); + } + RSA rsa = SecureUtil.rsa(null, publicKey); + return rsa.encryptHex(data, StandardCharsets.UTF_8, KeyType.PublicKey); + } + + /** + * rsa私钥解密 + * + * @param data 待解密数据 + * @param privateKey 私钥 + * @return 解密后字符串 + */ + public static String decryptByRsa(String data, String privateKey) { + if (StrUtil.isBlank(privateKey)) { + throw new IllegalArgumentException("RSA需要传入私钥进行解密"); + } + RSA rsa = SecureUtil.rsa(privateKey, null); + return rsa.decryptStr(data, KeyType.PrivateKey, StandardCharsets.UTF_8); + } + + /** + * md5加密 + * + * @param data 待加密数据 + * @return 加密后字符串, 采用Hex编码 + */ + public static String encryptByMd5(String data) { + return SecureUtil.md5(data); + } + + /** + * sha256加密 + * + * @param data 待加密数据 + * @return 加密后字符串, 采用Hex编码 + */ + public static String encryptBySha256(String data) { + return SecureUtil.sha256(data); + } + + /** + * sm3加密 + * + * @param data 待加密数据 + * @return 加密后字符串, 采用Hex编码 + */ + public static String encryptBySm3(String data) { + return SmUtil.sm3(data); + } + +} diff --git a/ruoyi-common/ruoyi-common-encrypt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-encrypt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..132cf29 --- /dev/null +++ b/ruoyi-common/ruoyi-common-encrypt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +org.dromara.common.encrypt.config.EncryptorAutoConfiguration +org.dromara.common.encrypt.config.ApiDecryptAutoConfiguration + diff --git a/ruoyi-common/ruoyi-common-excel/pom.xml b/ruoyi-common/ruoyi-common-excel/pom.xml new file mode 100644 index 0000000..47ba528 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/pom.xml @@ -0,0 +1,30 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-excel + + + ruoyi-common-excel + + + + + org.dromara + ruoyi-common-json + + + + cn.idev.excel + fastexcel + + + + diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/CellMerge.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/CellMerge.java new file mode 100644 index 0000000..9509c59 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/CellMerge.java @@ -0,0 +1,29 @@ +package org.dromara.common.excel.annotation; + +import org.dromara.common.excel.core.CellMergeStrategy; + +import java.lang.annotation.*; + +/** + * excel 列单元格合并(合并列相同项) + * + * 需搭配 {@link CellMergeStrategy} 策略使用 + * + * @author pengles + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface CellMerge { + + /** + * col index + */ + int index() default -1; + + /** + * 合并需要依赖的其他字段名称 + */ + String[] mergeBy() default {}; + +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelDictFormat.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelDictFormat.java new file mode 100644 index 0000000..7b2bb04 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelDictFormat.java @@ -0,0 +1,32 @@ +package org.dromara.common.excel.annotation; + +import org.dromara.common.core.utils.StringUtils; + +import java.lang.annotation.*; + +/** + * 字典格式化 + * + * @author pengles + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface ExcelDictFormat { + + /** + * 如果是字典类型,请设置字典的type值 (如: sys_user_sex) + */ + String dictType() default ""; + + /** + * 读取内容转表达式 (如: 0=男,1=女,2=未知) + */ + String readConverterExp() default ""; + + /** + * 分隔符,读取字符串组内容 + */ + String separator() default StringUtils.SEPARATOR; + +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelEnumFormat.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelEnumFormat.java new file mode 100644 index 0000000..290379d --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelEnumFormat.java @@ -0,0 +1,30 @@ +package org.dromara.common.excel.annotation; + +import java.lang.annotation.*; + +/** + * 枚举格式化 + * + * @author Liang + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface ExcelEnumFormat { + + /** + * 字典枚举类型 + */ + Class> enumClass(); + + /** + * 字典枚举类中对应的code属性名称,默认为code + */ + String codeField() default "code"; + + /** + * 字典枚举类中对应的text属性名称,默认为text + */ + String textField() default "text"; + +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelNotation.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelNotation.java new file mode 100644 index 0000000..ed42371 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelNotation.java @@ -0,0 +1,20 @@ +package org.dromara.common.excel.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 批注 此注解仅用于单表头 不支持多层级表头 + * @author guzhouyanyu + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExcelNotation { + + /** + * 批注内容 + */ + String value() default ""; +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelRequired.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelRequired.java new file mode 100644 index 0000000..ca8083b --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelRequired.java @@ -0,0 +1,22 @@ +package org.dromara.common.excel.annotation; + +import org.apache.poi.ss.usermodel.IndexedColors; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 是否必填 此注解仅用于单表头 不支持多层级表头 + * @author guzhouyanyu + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExcelRequired { + + /** + * 字体颜色 + */ + IndexedColors fontColor() default IndexedColors.RED; +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelBigNumberConvert.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelBigNumberConvert.java new file mode 100644 index 0000000..9332d4e --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelBigNumberConvert.java @@ -0,0 +1,52 @@ +package org.dromara.common.excel.convert; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.metadata.property.ExcelContentProperty; +import lombok.extern.slf4j.Slf4j; + +import java.math.BigDecimal; + +/** + * 大数值转换 + * Excel 数值长度位15位 大于15位的数值转换位字符串 + * + * @author pengles + */ +@Slf4j +public class ExcelBigNumberConvert implements Converter { + + @Override + public Class supportJavaTypeKey() { + return Long.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return CellDataTypeEnum.STRING; + } + + @Override + public Long convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { + return Convert.toLong(cellData.getData()); + } + + @Override + public WriteCellData convertToExcelData(Long object, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { + if (ObjectUtil.isNotNull(object)) { + String str = Convert.toStr(object); + if (str.length() > 15) { + return new WriteCellData<>(str); + } + } + WriteCellData cellData = new WriteCellData<>(new BigDecimal(object)); + cellData.setType(CellDataTypeEnum.NUMBER); + return cellData; + } + +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelDictConvert.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelDictConvert.java new file mode 100644 index 0000000..d6788cd --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelDictConvert.java @@ -0,0 +1,73 @@ +package org.dromara.common.excel.convert; + +import cn.hutool.core.annotation.AnnotationUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.metadata.property.ExcelContentProperty; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.core.service.DictService; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.excel.utils.ExcelUtil; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Field; + +/** + * 字典格式化转换处理 + * + * @author pengles + */ +@Slf4j +public class ExcelDictConvert implements Converter { + + @Override + public Class supportJavaTypeKey() { + return Object.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return null; + } + + @Override + public Object convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { + ExcelDictFormat anno = getAnnotation(contentProperty.getField()); + String type = anno.dictType(); + String label = cellData.getStringValue(); + String value; + if (StringUtils.isBlank(type)) { + value = ExcelUtil.reverseByExp(label, anno.readConverterExp(), anno.separator()); + } else { + value = SpringUtils.getBean(DictService.class).getDictValue(type, label, anno.separator()); + } + return Convert.convert(contentProperty.getField().getType(), value); + } + + @Override + public WriteCellData convertToExcelData(Object object, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { + if (ObjectUtil.isNull(object)) { + return new WriteCellData<>(""); + } + ExcelDictFormat anno = getAnnotation(contentProperty.getField()); + String type = anno.dictType(); + String value = Convert.toStr(object); + String label; + if (StringUtils.isBlank(type)) { + label = ExcelUtil.convertByExp(value, anno.readConverterExp(), anno.separator()); + } else { + label = SpringUtils.getBean(DictService.class).getDictLabel(type, value, anno.separator()); + } + return new WriteCellData<>(label); + } + + private ExcelDictFormat getAnnotation(Field field) { + return AnnotationUtil.getAnnotation(field, ExcelDictFormat.class); + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelEnumConvert.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelEnumConvert.java new file mode 100644 index 0000000..5723e61 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/convert/ExcelEnumConvert.java @@ -0,0 +1,87 @@ +package org.dromara.common.excel.convert; + +import cn.hutool.core.annotation.AnnotationUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.metadata.property.ExcelContentProperty; +import org.dromara.common.core.utils.reflect.ReflectUtils; +import org.dromara.common.excel.annotation.ExcelEnumFormat; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +/** + * 枚举格式化转换处理 + * + * @author Liang + */ +@Slf4j +public class ExcelEnumConvert implements Converter { + + @Override + public Class supportJavaTypeKey() { + return Object.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return null; + } + + @Override + public Object convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { + cellData.checkEmpty(); + // Excel中填入的是枚举中指定的描述 + Object textValue = switch (cellData.getType()) { + case STRING, DIRECT_STRING, RICH_TEXT_STRING -> cellData.getStringValue(); + case NUMBER -> cellData.getNumberValue(); + case BOOLEAN -> cellData.getBooleanValue(); + default -> throw new IllegalArgumentException("单元格类型异常!"); + }; + // 如果是空值 + if (ObjectUtil.isNull(textValue)) { + return null; + } + Map enumCodeToTextMap = beforeConvert(contentProperty); + // 从Java输出至Excel是code转text + // 因此从Excel转Java应该将text与code对调 + Map enumTextToCodeMap = new HashMap<>(); + enumCodeToTextMap.forEach((key, value) -> enumTextToCodeMap.put(value, key)); + // 应该从text -> code中查找 + Object codeValue = enumTextToCodeMap.get(textValue); + return Convert.convert(contentProperty.getField().getType(), codeValue); + } + + @Override + public WriteCellData convertToExcelData(Object object, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { + if (ObjectUtil.isNull(object)) { + return new WriteCellData<>(""); + } + Map enumValueMap = beforeConvert(contentProperty); + String value = Convert.toStr(enumValueMap.get(object), ""); + return new WriteCellData<>(value); + } + + private Map beforeConvert(ExcelContentProperty contentProperty) { + ExcelEnumFormat anno = getAnnotation(contentProperty.getField()); + Map enumValueMap = new HashMap<>(); + Enum[] enumConstants = anno.enumClass().getEnumConstants(); + for (Enum enumConstant : enumConstants) { + Object codeValue = ReflectUtils.invokeGetter(enumConstant, anno.codeField()); + String textValue = ReflectUtils.invokeGetter(enumConstant, anno.textField()); + enumValueMap.put(codeValue, textValue); + } + return enumValueMap; + } + + private ExcelEnumFormat getAnnotation(Field field) { + return AnnotationUtil.getAnnotation(field, ExcelEnumFormat.class); + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeStrategy.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeStrategy.java new file mode 100644 index 0000000..e8fef12 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeStrategy.java @@ -0,0 +1,163 @@ +package org.dromara.common.excel.core; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.metadata.Head; +import cn.idev.excel.write.handler.WorkbookWriteHandler; +import cn.idev.excel.write.handler.context.WorkbookWriteHandlerContext; +import cn.idev.excel.write.merge.AbstractMergeStrategy; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.util.CellRangeAddress; +import org.dromara.common.core.utils.reflect.ReflectUtils; +import org.dromara.common.excel.annotation.CellMerge; + +import java.lang.reflect.Field; +import java.util.*; + +/** + * 列值重复合并策略 + * + * @author pengles + */ +@Slf4j +public class CellMergeStrategy extends AbstractMergeStrategy implements WorkbookWriteHandler { + + private final List cellList; + private final boolean hasTitle; + private int rowIndex; + + public CellMergeStrategy(List list, boolean hasTitle) { + this.hasTitle = hasTitle; + // 行合并开始下标 + this.rowIndex = hasTitle ? 1 : 0; + this.cellList = handle(list, hasTitle); + } + + @Override + protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) { + //单元格写入了,遍历合并区域,如果该Cell在区域内,但非首行,则清空 + final int rowIndex = cell.getRowIndex(); + if (CollUtil.isNotEmpty(cellList)){ + for (CellRangeAddress cellAddresses : cellList) { + final int firstRow = cellAddresses.getFirstRow(); + if (cellAddresses.isInRange(cell) && rowIndex != firstRow){ + cell.setBlank(); + } + } + } + } + + @Override + public void afterWorkbookDispose(final WorkbookWriteHandlerContext context) { + //当前表格写完后,统一写入 + if (CollUtil.isNotEmpty(cellList)){ + for (CellRangeAddress item : cellList) { + context.getWriteContext().writeSheetHolder().getSheet().addMergedRegion(item); + } + } + } + + @SneakyThrows + private List handle(List list, boolean hasTitle) { + List cellList = new ArrayList<>(); + if (CollUtil.isEmpty(list)) { + return cellList; + } + Field[] fields = ReflectUtils.getFields(list.get(0).getClass(), field -> !"serialVersionUID".equals(field.getName())); + + // 有注解的字段 + List mergeFields = new ArrayList<>(); + List mergeFieldsIndex = new ArrayList<>(); + for (int i = 0; i < fields.length; i++) { + Field field = fields[i]; + if (field.isAnnotationPresent(CellMerge.class)) { + CellMerge cm = field.getAnnotation(CellMerge.class); + mergeFields.add(field); + mergeFieldsIndex.add(cm.index() == -1 ? i : cm.index()); + if (hasTitle) { + ExcelProperty property = field.getAnnotation(ExcelProperty.class); + rowIndex = Math.max(rowIndex, property.value().length); + } + } + } + + Map map = new HashMap<>(); + // 生成两两合并单元格 + for (int i = 0; i < list.size(); i++) { + for (int j = 0; j < mergeFields.size(); j++) { + Field field = mergeFields.get(j); + Object val = ReflectUtils.invokeGetter(list.get(i), field.getName()); + + int colNum = mergeFieldsIndex.get(j); + if (!map.containsKey(field)) { + map.put(field, new RepeatCell(val, i)); + } else { + RepeatCell repeatCell = map.get(field); + Object cellValue = repeatCell.getValue(); + if (cellValue == null || "".equals(cellValue)) { + // 空值跳过不合并 + continue; + } + + if (!cellValue.equals(val)) { + if ((i - repeatCell.getCurrent() > 1)) { + cellList.add(new CellRangeAddress(repeatCell.getCurrent() + rowIndex, i + rowIndex - 1, colNum, colNum)); + } + map.put(field, new RepeatCell(val, i)); + } else if (i == list.size() - 1) { + if (!isMerge(list, i, field)) { + // 如果最后一行不能合并,检查之前的数据是否需要合并 + if (i - repeatCell.getCurrent() > 1) { + cellList.add(new CellRangeAddress(repeatCell.getCurrent() + rowIndex, i + rowIndex - 1, colNum, colNum)); + } + } else if (i > repeatCell.getCurrent()) { + // 如果最后一行可以合并,则直接合并到最后 + cellList.add(new CellRangeAddress(repeatCell.getCurrent() + rowIndex, i + rowIndex, colNum, colNum)); + } + } else if (!isMerge(list, i, field)) { + if ((i - repeatCell.getCurrent() > 1)) { + cellList.add(new CellRangeAddress(repeatCell.getCurrent() + rowIndex, i + rowIndex - 1, colNum, colNum)); + } + map.put(field, new RepeatCell(val, i)); + } + } + } + } + return cellList; + } + + private boolean isMerge(List list, int i, Field field) { + boolean isMerge = true; + CellMerge cm = field.getAnnotation(CellMerge.class); + final String[] mergeBy = cm.mergeBy(); + if (StrUtil.isAllNotBlank(mergeBy)) { + //比对当前list(i)和list(i - 1)的各个属性值一一比对 如果全为真 则为真 + for (String fieldName : mergeBy) { + final Object valCurrent = ReflectUtil.getFieldValue(list.get(i), fieldName); + final Object valPre = ReflectUtil.getFieldValue(list.get(i - 1), fieldName); + if (!Objects.equals(valPre, valCurrent)) { + //依赖字段如有任一不等值,则标记为不可合并 + isMerge = false; + } + } + } + return isMerge; + } + + @Data + @AllArgsConstructor + static class RepeatCell { + + private Object value; + + private int current; + + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DefaultExcelListener.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DefaultExcelListener.java new file mode 100644 index 0000000..fb46e6d --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DefaultExcelListener.java @@ -0,0 +1,104 @@ +package org.dromara.common.excel.core; + +import cn.hutool.core.util.StrUtil; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.event.AnalysisEventListener; +import cn.idev.excel.exception.ExcelAnalysisException; +import cn.idev.excel.exception.ExcelDataConvertException; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.Set; + +/** + * Excel 导入监听 + * + * @author Yjoioooo + * @author pengles + */ +@Slf4j +@NoArgsConstructor +public class DefaultExcelListener extends AnalysisEventListener implements ExcelListener { + + /** + * 是否Validator检验,默认为是 + */ + private Boolean isValidate = Boolean.TRUE; + + /** + * excel 表头数据 + */ + private Map headMap; + + /** + * 导入回执 + */ + private ExcelResult excelResult; + + public DefaultExcelListener(boolean isValidate) { + this.excelResult = new DefaultExcelResult<>(); + this.isValidate = isValidate; + } + + /** + * 处理异常 + * + * @param exception ExcelDataConvertException + * @param context Excel 上下文 + */ + @Override + public void onException(Exception exception, AnalysisContext context) throws Exception { + String errMsg = null; + if (exception instanceof ExcelDataConvertException excelDataConvertException) { + // 如果是某一个单元格的转换异常 能获取到具体行号 + Integer rowIndex = excelDataConvertException.getRowIndex(); + Integer columnIndex = excelDataConvertException.getColumnIndex(); + errMsg = StrUtil.format("第{}行-第{}列-表头{}: 解析异常
", + rowIndex + 1, columnIndex + 1, headMap.get(columnIndex)); + if (log.isDebugEnabled()) { + log.error(errMsg); + } + } + if (exception instanceof ConstraintViolationException constraintViolationException) { + Set> constraintViolations = constraintViolationException.getConstraintViolations(); + String constraintViolationsMsg = StreamUtils.join(constraintViolations, ConstraintViolation::getMessage, ", "); + errMsg = StrUtil.format("第{}行数据校验异常: {}", context.readRowHolder().getRowIndex() + 1, constraintViolationsMsg); + if (log.isDebugEnabled()) { + log.error(errMsg); + } + } + excelResult.getErrorList().add(errMsg); + throw new ExcelAnalysisException(errMsg); + } + + @Override + public void invokeHeadMap(Map headMap, AnalysisContext context) { + this.headMap = headMap; + log.debug("解析到一条表头数据: {}", JsonUtils.toJsonString(headMap)); + } + + @Override + public void invoke(T data, AnalysisContext context) { + if (isValidate) { + ValidatorUtils.validate(data); + } + excelResult.getList().add(data); + } + + @Override + public void doAfterAllAnalysed(AnalysisContext context) { + log.debug("所有数据解析完成!"); + } + + @Override + public ExcelResult getExcelResult() { + return excelResult; + } + +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DefaultExcelResult.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DefaultExcelResult.java new file mode 100644 index 0000000..19bfac6 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DefaultExcelResult.java @@ -0,0 +1,73 @@ +package org.dromara.common.excel.core; + +import cn.hutool.core.util.StrUtil; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 默认excel返回对象 + * + * @author Yjoioooo + * @author pengles + */ +public class DefaultExcelResult implements ExcelResult { + + /** + * 数据对象list + */ + @Setter + private List list; + + /** + * 错误信息列表 + */ + @Setter + private List errorList; + + public DefaultExcelResult() { + this.list = new ArrayList<>(); + this.errorList = new ArrayList<>(); + } + + public DefaultExcelResult(List list, List errorList) { + this.list = list; + this.errorList = errorList; + } + + public DefaultExcelResult(ExcelResult excelResult) { + this.list = excelResult.getList(); + this.errorList = excelResult.getErrorList(); + } + + @Override + public List getList() { + return list; + } + + @Override + public List getErrorList() { + return errorList; + } + + /** + * 获取导入回执 + * + * @return 导入回执 + */ + @Override + public String getAnalysis() { + int successCount = list.size(); + int errorCount = errorList.size(); + if (successCount == 0) { + return "读取失败,未解析到数据"; + } else { + if (errorCount == 0) { + return StrUtil.format("恭喜您,全部读取成功!共{}条", successCount); + } else { + return ""; + } + } + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DropDownOptions.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DropDownOptions.java new file mode 100644 index 0000000..8b53a0c --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/DropDownOptions.java @@ -0,0 +1,149 @@ +package org.dromara.common.excel.core; + +import cn.hutool.core.util.StrUtil; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.dromara.common.core.exception.ServiceException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + *

Excel下拉可选项

+ * 注意:为确保下拉框解析正确,传值务必使用createOptionValue()做为值的拼接 + * + * @author Emil.Zhang + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@SuppressWarnings("unused") +public class DropDownOptions { + /** + * 一级下拉所在列index,从0开始算 + */ + private int index = 0; + /** + * 二级下拉所在的index,从0开始算,不能与一级相同 + */ + private int nextIndex = 0; + /** + * 一级下拉所包含的数据 + */ + private List options = new ArrayList<>(); + /** + * 二级下拉所包含的数据Map + *

以每一个一级选项值为Key,每个一级选项对应的二级数据为Value

+ */ + private Map> nextOptions = new HashMap<>(); + /** + * 分隔符 + */ + private static final String DELIMITER = "_"; + + /** + * 创建只有一级的下拉选 + */ + public DropDownOptions(int index, List options) { + this.index = index; + this.options = options; + } + + /** + *

创建每个选项可选值

+ *

注意:不能以数字,特殊符号开头,选项中不可以包含任何运算符号

+ * + * @param vars 可选值内包含的参数 + * @return 合规的可选值 + */ + public static String createOptionValue(Object... vars) { + StringBuilder stringBuffer = new StringBuilder(); + String regex = "^[\\S\\d\\u4e00-\\u9fa5]+$"; + for (int i = 0; i < vars.length; i++) { + String var = StrUtil.trimToEmpty(String.valueOf(vars[i])); + if (!var.matches(regex)) { + throw new ServiceException("选项数据不符合规则,仅允许使用中英文字符以及数字"); + } + stringBuffer.append(var); + if (i < vars.length - 1) { + // 直至最后一个前,都以_作为切割线 + stringBuffer.append(DELIMITER); + } + } + if (stringBuffer.toString().matches("^\\d_*$")) { + throw new ServiceException("禁止以数字开头"); + } + return stringBuffer.toString(); + } + + /** + * 将处理后合理的可选值解析为原始的参数 + * + * @param option 经过处理后的合理的可选项 + * @return 原始的参数 + */ + public static List analyzeOptionValue(String option) { + return StrUtil.split(option, DELIMITER, true, true); + } + + /** + * 创建级联下拉选项 + * + * @param parentList 父实体可选项原始数据 + * @param parentIndex 父下拉选位置 + * @param sonList 子实体可选项原始数据 + * @param sonIndex 子下拉选位置 + * @param parentHowToGetIdFunction 父类如何获取唯一标识 + * @param sonHowToGetParentIdFunction 子类如何获取父类的唯一标识 + * @param howToBuildEveryOption 如何生成下拉选内容 + * @return 级联下拉选项 + */ + public static DropDownOptions buildLinkedOptions(List parentList, + int parentIndex, + List sonList, + int sonIndex, + Function parentHowToGetIdFunction, + Function sonHowToGetParentIdFunction, + Function howToBuildEveryOption) { + DropDownOptions parentLinkSonOptions = new DropDownOptions(); + // 先创建父类的下拉 + parentLinkSonOptions.setIndex(parentIndex); + parentLinkSonOptions.setOptions( + parentList.stream() + .map(howToBuildEveryOption) + .collect(Collectors.toList()) + ); + // 提取父-子级联下拉 + Map> sonOptions = new HashMap<>(); + // 父级依据自己的ID分组 + Map> parentGroupByIdMap = + parentList.stream().collect(Collectors.groupingBy(parentHowToGetIdFunction)); + // 遍历每个子集,提取到Map中 + sonList.forEach(everySon -> { + if (parentGroupByIdMap.containsKey(sonHowToGetParentIdFunction.apply(everySon))) { + // 找到对应的上级 + T parentObj = parentGroupByIdMap.get(sonHowToGetParentIdFunction.apply(everySon)).get(0); + // 提取名称和ID作为Key + String key = howToBuildEveryOption.apply(parentObj); + // Key对应的Value + List thisParentSonOptionList; + if (sonOptions.containsKey(key)) { + thisParentSonOptionList = sonOptions.get(key); + } else { + thisParentSonOptionList = new ArrayList<>(); + sonOptions.put(key, thisParentSonOptionList); + } + // 往Value中添加当前子集选项 + thisParentSonOptionList.add(howToBuildEveryOption.apply(everySon)); + } + }); + parentLinkSonOptions.setNextIndex(sonIndex); + parentLinkSonOptions.setNextOptions(sonOptions); + return parentLinkSonOptions; + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java new file mode 100644 index 0000000..f3b6415 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java @@ -0,0 +1,401 @@ +package org.dromara.common.excel.core; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.EnumUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.idev.excel.metadata.FieldCache; +import cn.idev.excel.metadata.FieldWrapper; +import cn.idev.excel.util.ClassUtils; +import cn.idev.excel.write.handler.SheetWriteHandler; +import cn.idev.excel.write.metadata.holder.WriteSheetHolder; +import cn.idev.excel.write.metadata.holder.WriteWorkbookHolder; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddressList; +import org.apache.poi.ss.util.WorkbookUtil; +import org.apache.poi.xssf.usermodel.XSSFDataValidation; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.DictService; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.annotation.ExcelEnumFormat; + +import java.lang.reflect.Field; +import java.util.*; + +/** + *

Excel表格下拉选操作

+ * 考虑到下拉选过多可能导致Excel打开缓慢的问题,只校验前1000行 + *

+ * 即只有前1000行的数据可以用下拉框,超出的自行通过限制数据量的形式,第二次输出 + * + * @author Emil.Zhang + */ +@Slf4j +public class ExcelDownHandler implements SheetWriteHandler { + + /** + * Excel表格中的列名英文 + * 仅为了解析列英文,禁止修改 + */ + private static final String EXCEL_COLUMN_NAME = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + /** + * 单选数据Sheet名 + */ + private static final String OPTIONS_SHEET_NAME = "options"; + /** + * 联动选择数据Sheet名的头 + */ + private static final String LINKED_OPTIONS_SHEET_NAME = "linkedOptions"; + /** + * 下拉可选项 + */ + private final List dropDownOptions; + private final DictService dictService; + /** + * 当前单选进度 + */ + private int currentOptionsColumnIndex; + /** + * 当前联动选择进度 + */ + private int currentLinkedOptionsSheetIndex; + + public ExcelDownHandler(List options) { + this.dropDownOptions = options; + this.currentOptionsColumnIndex = 0; + this.currentLinkedOptionsSheetIndex = 0; + this.dictService = SpringUtils.getBean(DictService.class); + } + + /** + *

开始创建下拉数据

+ * 1.通过解析传入的@ExcelProperty同级是否标注有@DropDown选项 + * 如果有且设置了value值,则将其直接置为下拉可选项 + *

+ * 2.或者在调用ExcelUtil时指定了可选项,将依据传入的可选项做下拉 + *

+ * 3.二者并存,注意调用方式 + */ + @Override + public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { + Sheet sheet = writeSheetHolder.getSheet(); + // 开始设置下拉框 HSSFWorkbook + DataValidationHelper helper = sheet.getDataValidationHelper(); + Workbook workbook = writeWorkbookHolder.getWorkbook(); + FieldCache fieldCache = ClassUtils.declaredFields(writeWorkbookHolder.getClazz(), writeWorkbookHolder); + for (Map.Entry entry : fieldCache.getSortedFieldMap().entrySet()) { + Integer index = entry.getKey(); + FieldWrapper wrapper = entry.getValue(); + Field field = wrapper.getField(); + // 循环实体中的每个属性 + // 可选的下拉值 + List options = new ArrayList<>(); + if (field.isAnnotationPresent(ExcelDictFormat.class)) { + // 如果指定了@ExcelDictFormat,则使用字典的逻辑 + ExcelDictFormat format = field.getDeclaredAnnotation(ExcelDictFormat.class); + String dictType = format.dictType(); + String converterExp = format.readConverterExp(); + if (StringUtils.isNotBlank(dictType)) { + // 如果传递了字典名,则依据字典建立下拉 + Collection values = Optional.ofNullable(dictService.getAllDictByDictType(dictType)) + .orElseThrow(() -> new ServiceException(String.format("字典 %s 不存在", dictType))) + .values(); + options = new ArrayList<>(values); + } else if (StringUtils.isNotBlank(converterExp)) { + // 如果指定了确切的值,则直接解析确切的值 + List strList = StringUtils.splitList(converterExp, format.separator()); + options = StreamUtils.toList(strList, s -> StringUtils.split(s, "=")[1]); + } + } else if (field.isAnnotationPresent(ExcelEnumFormat.class)) { + // 否则如果指定了@ExcelEnumFormat,则使用枚举的逻辑 + ExcelEnumFormat format = field.getDeclaredAnnotation(ExcelEnumFormat.class); + List values = EnumUtil.getFieldValues(format.enumClass(), format.textField()); + options = StreamUtils.toList(values, String::valueOf); + } + if (ObjectUtil.isNotEmpty(options)) { + // 仅当下拉可选项不为空时执行 + if (options.size() > 20) { + // 这里限制如果可选项大于20,则使用额外表形式 + dropDownWithSheet(helper, workbook, sheet, index, options); + } else { + // 否则使用固定值形式 + dropDownWithSimple(helper, sheet, index, options); + } + } + } + if (CollUtil.isEmpty(dropDownOptions)) { + return; + } + dropDownOptions.forEach(everyOptions -> { + // 如果传递了下拉框选择器参数 + if (!everyOptions.getNextOptions().isEmpty()) { + // 当二级选项不为空时,使用额外关联表的形式 + dropDownLinkedOptions(helper, workbook, sheet, everyOptions); + } else if (everyOptions.getOptions().size() > 10) { + // 当一级选项参数个数大于10,使用额外表的形式 + dropDownWithSheet(helper, workbook, sheet, everyOptions.getIndex(), everyOptions.getOptions()); + } else { + // 否则使用默认形式 + dropDownWithSimple(helper, sheet, everyOptions.getIndex(), everyOptions.getOptions()); + } + }); + } + + /** + *

简单下拉框

+ * 直接将可选项拼接为指定列的数据校验值 + * + * @param celIndex 列index + * @param value 下拉选可选值 + */ + private void dropDownWithSimple(DataValidationHelper helper, Sheet sheet, Integer celIndex, List value) { + if (ObjectUtil.isEmpty(value)) { + return; + } + this.markOptionsToSheet(helper, sheet, celIndex, helper.createExplicitListConstraint(ArrayUtil.toArray(value, String.class))); + } + + /** + *

额外表格形式的级联下拉框

+ * + * @param options 额外表格形式存储的下拉可选项 + */ + private void dropDownLinkedOptions(DataValidationHelper helper, Workbook workbook, Sheet sheet, DropDownOptions options) { + String linkedOptionsSheetName = String.format("%s_%d", LINKED_OPTIONS_SHEET_NAME, currentLinkedOptionsSheetIndex); + // 创建联动下拉数据表 + Sheet linkedOptionsDataSheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(linkedOptionsSheetName)); + // 将下拉表隐藏 + workbook.setSheetHidden(workbook.getSheetIndex(linkedOptionsDataSheet), true); + // 选项数据 + List firstOptions = options.getOptions(); + Map> secoundOptionsMap = options.getNextOptions(); + + // 采用按行填充数据的方式,避免出现数据无法写入的问题 + // Attempting to write a row in the range that is already written to disk + + // 使用ArrayList记载数据,防止乱序 + List columnNames = new ArrayList<>(); + // 写入第一行,即第一级的数据 + Row firstRow = linkedOptionsDataSheet.createRow(0); + for (int columnIndex = 0; columnIndex < firstOptions.size(); columnIndex++) { + String columnName = firstOptions.get(columnIndex); + firstRow.createCell(columnIndex) + .setCellValue(columnName); + columnNames.add(columnName); + } + + // 创建名称管理器 + Name name = workbook.createName(); + // 设置名称管理器的别名 + name.setNameName(linkedOptionsSheetName); + // 以横向第一行创建一级下拉拼接引用位置 + String firstOptionsFunction = String.format("%s!$%s$1:$%s$1", + linkedOptionsSheetName, + getExcelColumnName(0), + getExcelColumnName(firstOptions.size()) + ); + // 设置名称管理器的引用位置 + name.setRefersToFormula(firstOptionsFunction); + // 设置数据校验为序列模式,引用的是名称管理器中的别名 + this.markOptionsToSheet(helper, sheet, options.getIndex(), helper.createFormulaListConstraint(linkedOptionsSheetName)); + + // 创建二级选项的名称管理器 + for (int columIndex = 0; columIndex < columnNames.size(); columIndex++) { + // 列名 + String firstOptionsColumnName = getExcelColumnName(columIndex); + // 对应的一级值 + String thisFirstOptionsValue = columnNames.get(columIndex); + + // 以该一级选项值创建子名称管理器 + Name sonName = workbook.createName(); + // 设置名称管理器的别名 + sonName.setNameName(thisFirstOptionsValue); + // 以第二行该列数据拼接引用位置 + String sonFunction = String.format("%s!$%s$2:$%s$%d", + linkedOptionsSheetName, + firstOptionsColumnName, + firstOptionsColumnName, + // 二级选项存在则设置为(选项个数+1)行,否则设置为2行 + Math.max(Optional.ofNullable(secoundOptionsMap.get(thisFirstOptionsValue)) + .orElseGet(ArrayList::new).size(), 1) + 1 + ); + // 设置名称管理器的引用位置 + sonName.setRefersToFormula(sonFunction); + // 数据验证为序列模式,引用到每一个主表中的二级选项位置 + // 创建子项的名称管理器,只是为了使得Excel可以识别到数据 + String mainSheetFirstOptionsColumnName = getExcelColumnName(options.getIndex()); + for (int i = 0; i < 100; i++) { + // 以一级选项对应的主体所在位置创建二级下拉 + String secondOptionsFunction = String.format("=INDIRECT(%s%d)", mainSheetFirstOptionsColumnName, i + 1); + // 二级只能主表每一行的每一列添加二级校验 + markLinkedOptionsToSheet(helper, sheet, i, options.getNextIndex(), helper.createFormulaListConstraint(secondOptionsFunction)); + } + } + + // 将二级数据处理为按行区分 + Map> columnValueMap = new HashMap<>(); + int currentRow = 1; + while (currentRow >= 0) { + boolean flag = false; + List rowData = new ArrayList<>(); + for (String columnName : columnNames) { + List data = secoundOptionsMap.get(columnName); + if (CollUtil.isEmpty(data)) { + // 添加空字符串填充位置 + rowData.add(" "); + continue; + } + // 取第一个 + String str = data.get(0); + rowData.add(str); + // 通过移除的方式避免重复 + data.remove(0); + // 设置可以继续 + flag = true; + } + columnValueMap.put(currentRow, rowData); + // 可以继续,则增加行数,否则置为负数跳出循环 + if (flag) { + currentRow++; + } else { + currentRow = -1; + } + } + + // 填充第二级选项数据 + columnValueMap.forEach((rowIndex, rowValues) -> { + Row row = linkedOptionsDataSheet.createRow(rowIndex); + for (int columnIndex = 0; columnIndex < rowValues.size(); columnIndex++) { + String rowValue = rowValues.get(columnIndex); + // 填充位置的部分不渲染 + if (StrUtil.isNotBlank(rowValue)) { + row.createCell(columnIndex) + .setCellValue(rowValue); + } + } + }); + + currentLinkedOptionsSheetIndex++; + } + + /** + *

额外表格形式的普通下拉框

+ * 由于下拉框可选值数量过多,为提升Excel打开效率,使用额外表格形式做下拉 + * + * @param celIndex 下拉选 + * @param value 下拉选可选值 + */ + private void dropDownWithSheet(DataValidationHelper helper, Workbook workbook, Sheet sheet, Integer celIndex, List value) { + //由于poi的写出相关问题,超过100个会被临时写进硬盘,导致后续内存合并会出Attempting to write a row[] in the range [] that is already written to disk + String tmpOptionsSheetName = OPTIONS_SHEET_NAME + "_" + currentOptionsColumnIndex; + // 创建下拉数据表 + Sheet simpleDataSheet = Optional.ofNullable(workbook.getSheet(WorkbookUtil.createSafeSheetName(tmpOptionsSheetName))) + .orElseGet(() -> workbook.createSheet(WorkbookUtil.createSafeSheetName(tmpOptionsSheetName))); + // 将下拉表隐藏 + workbook.setSheetHidden(workbook.getSheetIndex(simpleDataSheet), true); + // 完善纵向的一级选项数据表 + for (int i = 0; i < value.size(); i++) { + int finalI = i; + // 获取每一选项行,如果没有则创建 + Row row = Optional.ofNullable(simpleDataSheet.getRow(i)) + .orElseGet(() -> simpleDataSheet.createRow(finalI)); + // 获取本级选项对应的选项列,如果没有则创建。上述采用多个sheet,默认索引为1列 + Cell cell = Optional.ofNullable(row.getCell(0)) + .orElseGet(() -> row.createCell(0)); + // 设置值 + cell.setCellValue(value.get(i)); + } + + // 创建名称管理器 + Name name = workbook.createName(); + // 设置名称管理器的别名 + String nameName = String.format("%s_%d", tmpOptionsSheetName, celIndex); + name.setNameName(nameName); + // 以纵向第一列创建一级下拉拼接引用位置 + String function = String.format("%s!$%s$1:$%s$%d", + tmpOptionsSheetName, + getExcelColumnName(0), + getExcelColumnName(0), + value.size()); + // 设置名称管理器的引用位置 + name.setRefersToFormula(function); + // 设置数据校验为序列模式,引用的是名称管理器中的别名 + this.markOptionsToSheet(helper, sheet, celIndex, helper.createFormulaListConstraint(nameName)); + currentOptionsColumnIndex++; + } + + /** + * 挂载下拉的列,仅限一级选项 + */ + private void markOptionsToSheet(DataValidationHelper helper, Sheet sheet, Integer celIndex, + DataValidationConstraint constraint) { + // 设置数据有效性加载在哪个单元格上,四个参数分别是:起始行、终止行、起始列、终止列 + CellRangeAddressList addressList = new CellRangeAddressList(1, 1000, celIndex, celIndex); + markDataValidationToSheet(helper, sheet, constraint, addressList); + } + + /** + * 挂载下拉的列,仅限二级选项 + */ + private void markLinkedOptionsToSheet(DataValidationHelper helper, Sheet sheet, Integer rowIndex, + Integer celIndex, DataValidationConstraint constraint) { + // 设置数据有效性加载在哪个单元格上,四个参数分别是:起始行、终止行、起始列、终止列 + CellRangeAddressList addressList = new CellRangeAddressList(rowIndex, rowIndex, celIndex, celIndex); + markDataValidationToSheet(helper, sheet, constraint, addressList); + } + + /** + * 应用数据校验 + */ + private void markDataValidationToSheet(DataValidationHelper helper, Sheet sheet, + DataValidationConstraint constraint, CellRangeAddressList addressList) { + // 数据有效性对象 + DataValidation dataValidation = helper.createValidation(constraint, addressList); + // 处理Excel兼容性问题 + if (dataValidation instanceof XSSFDataValidation) { + //数据校验 + dataValidation.setSuppressDropDownArrow(true); + //错误提示 + dataValidation.setErrorStyle(DataValidation.ErrorStyle.STOP); + dataValidation.createErrorBox("提示", "此值与单元格定义数据不一致"); + dataValidation.setShowErrorBox(true); + //选定提示 + dataValidation.createPromptBox("填写说明:", "填写内容只能为下拉中数据,其他数据将导致导入失败"); + dataValidation.setShowPromptBox(true); + sheet.addValidationData(dataValidation); + } else { + dataValidation.setSuppressDropDownArrow(false); + } + sheet.addValidationData(dataValidation); + } + + /** + *

依据列index获取列名英文

+ * 依据列index转换为Excel中的列名英文 + *

例如第1列,index为0,解析出来为A列

+ * 第27列,index为26,解析为AA列 + *

第28列,index为27,解析为AB列

+ * + * @param columnIndex 列index + * @return 列index所在得英文名 + */ + private String getExcelColumnName(int columnIndex) { + // 26一循环的次数 + int columnCircleCount = columnIndex / 26; + // 26一循环内的位置 + int thisCircleColumnIndex = columnIndex % 26; + // 26一循环的次数大于0,则视为栏名至少两位 + String columnPrefix = columnCircleCount == 0 + ? StrUtil.EMPTY + : StrUtil.subWithLength(EXCEL_COLUMN_NAME, columnCircleCount - 1, 1); + // 从26一循环内取对应的栏位名 + String columnNext = StrUtil.subWithLength(EXCEL_COLUMN_NAME, thisCircleColumnIndex, 1); + // 将二者拼接即为最终的栏位名 + return columnPrefix + columnNext; + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelListener.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelListener.java new file mode 100644 index 0000000..af3315b --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelListener.java @@ -0,0 +1,14 @@ +package org.dromara.common.excel.core; + +import cn.idev.excel.read.listener.ReadListener; + +/** + * Excel 导入监听 + * + * @author pengles + */ +public interface ExcelListener extends ReadListener { + + ExcelResult getExcelResult(); + +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelResult.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelResult.java new file mode 100644 index 0000000..67bfe4f --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelResult.java @@ -0,0 +1,26 @@ +package org.dromara.common.excel.core; + +import java.util.List; + +/** + * excel返回对象 + * + * @author pengles + */ +public interface ExcelResult { + + /** + * 对象列表 + */ + List getList(); + + /** + * 错误列表 + */ + List getErrorList(); + + /** + * 导入回执 + */ + String getAnalysis(); +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/handler/DataWriteHandler.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/handler/DataWriteHandler.java new file mode 100644 index 0000000..3770f80 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/handler/DataWriteHandler.java @@ -0,0 +1,123 @@ +package org.dromara.common.excel.handler; + +import cn.hutool.core.collection.CollUtil; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.metadata.data.DataFormatData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.util.StyleUtil; +import cn.idev.excel.write.handler.CellWriteHandler; +import cn.idev.excel.write.handler.SheetWriteHandler; +import cn.idev.excel.write.handler.context.CellWriteHandlerContext; +import cn.idev.excel.write.metadata.holder.WriteSheetHolder; +import cn.idev.excel.write.metadata.style.WriteCellStyle; +import cn.idev.excel.write.metadata.style.WriteFont; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFClientAnchor; +import org.apache.poi.xssf.usermodel.XSSFRichTextString; +import org.dromara.common.excel.annotation.ExcelNotation; +import org.dromara.common.excel.annotation.ExcelRequired; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +/** + * 批注、必填 + * + * @author guzhouyanyu + */ +public class DataWriteHandler implements SheetWriteHandler, CellWriteHandler { + + /** + * 批注 + */ + private final Map notationMap; + + /** + * 头列字体颜色 + */ + private final Map headColumnMap; + + + public DataWriteHandler(Class clazz) { + notationMap = getNotationMap(clazz); + headColumnMap = getRequiredMap(clazz); + } + + @Override + public void afterCellDispose(CellWriteHandlerContext context) { + if (CollUtil.isEmpty(notationMap) && CollUtil.isEmpty(headColumnMap)) { + return; + } + // 第一行 + WriteCellData cellData = context.getFirstCellData(); + // 第一个格子 + WriteCellStyle writeCellStyle = cellData.getOrCreateStyle(); + + if (context.getHead()) { + DataFormatData dataFormatData = new DataFormatData(); + // 单元格设置为文本格式 + dataFormatData.setIndex((short) 49); + writeCellStyle.setDataFormatData(dataFormatData); + Cell cell = context.getCell(); + WriteSheetHolder writeSheetHolder = context.getWriteSheetHolder(); + Sheet sheet = writeSheetHolder.getSheet(); + Workbook workbook = writeSheetHolder.getSheet().getWorkbook(); + Drawing drawing = sheet.createDrawingPatriarch(); + // 设置标题字体样式 + WriteFont headWriteFont = new WriteFont(); + // 加粗 + headWriteFont.setBold(true); + if (CollUtil.isNotEmpty(headColumnMap) && headColumnMap.containsKey(cell.getStringCellValue())) { + // 设置字体颜色 + headWriteFont.setColor(headColumnMap.get(cell.getStringCellValue())); + } + writeCellStyle.setWriteFont(headWriteFont); + CellStyle cellStyle = StyleUtil.buildCellStyle(workbook, null, writeCellStyle); + cell.setCellStyle(cellStyle); + + if (CollUtil.isNotEmpty(notationMap) && notationMap.containsKey(cell.getStringCellValue())) { + // 批注内容 + String notationContext = notationMap.get(cell.getStringCellValue()); + // 创建绘图对象 + Comment comment = drawing.createCellComment(new XSSFClientAnchor(0, 0, 0, 0, (short) cell.getColumnIndex(), 0, (short) 5, 5)); + comment.setString(new XSSFRichTextString(notationContext)); + cell.setCellComment(comment); + } + } + } + + /** + * 获取必填列 + */ + private static Map getRequiredMap(Class clazz) { + Map requiredMap = new HashMap<>(); + Field[] fields = clazz.getDeclaredFields(); + for (Field field : fields) { + if (!field.isAnnotationPresent(ExcelRequired.class)) { + continue; + } + ExcelRequired excelRequired = field.getAnnotation(ExcelRequired.class); + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + requiredMap.put(excelProperty.value()[0], excelRequired.fontColor().getIndex()); + } + return requiredMap; + } + + /** + * 获取批注 + */ + private static Map getNotationMap(Class clazz) { + Map notationMap = new HashMap<>(); + Field[] fields = clazz.getDeclaredFields(); + for (Field field : fields) { + if (!field.isAnnotationPresent(ExcelNotation.class)) { + continue; + } + ExcelNotation excelNotation = field.getAnnotation(ExcelNotation.class); + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + notationMap.put(excelProperty.value()[0], excelNotation.value()); + } + return notationMap; + } +} diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/utils/ExcelUtil.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/utils/ExcelUtil.java new file mode 100644 index 0000000..57d5f13 --- /dev/null +++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/utils/ExcelUtil.java @@ -0,0 +1,440 @@ +package org.dromara.common.excel.utils; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.resource.ClassPathResource; +import cn.hutool.core.util.IdUtil; +import cn.idev.excel.FastExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.write.builder.ExcelWriterSheetBuilder; +import cn.idev.excel.write.metadata.WriteSheet; +import cn.idev.excel.write.metadata.fill.FillConfig; +import cn.idev.excel.write.metadata.fill.FillWrapper; +import cn.idev.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.file.FileUtils; +import org.dromara.common.excel.convert.ExcelBigNumberConvert; +import org.dromara.common.excel.core.*; +import org.dromara.common.excel.handler.DataWriteHandler; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * Excel相关处理 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ExcelUtil { + + /** + * 同步导入(适用于小数据量) + * + * @param is 输入流 + * @return 转换后集合 + */ + public static List importExcel(InputStream is, Class clazz) { + return FastExcel.read(is).head(clazz).autoCloseStream(false).sheet().doReadSync(); + } + + + /** + * 使用校验监听器 异步导入 同步返回 + * + * @param is 输入流 + * @param clazz 对象类型 + * @param isValidate 是否 Validator 检验 默认为是 + * @return 转换后集合 + */ + public static ExcelResult importExcel(InputStream is, Class clazz, boolean isValidate) { + DefaultExcelListener listener = new DefaultExcelListener<>(isValidate); + FastExcel.read(is, clazz, listener).sheet().doRead(); + return listener.getExcelResult(); + } + + /** + * 使用自定义监听器 异步导入 自定义返回 + * + * @param is 输入流 + * @param clazz 对象类型 + * @param listener 自定义监听器 + * @return 转换后集合 + */ + public static ExcelResult importExcel(InputStream is, Class clazz, ExcelListener listener) { + FastExcel.read(is, clazz, listener).sheet().doRead(); + return listener.getExcelResult(); + } + + /** + * 导出excel + * + * @param list 导出数据集合 + * @param sheetName 工作表的名称 + * @param clazz 实体类 + * @param response 响应体 + */ + public static void exportExcel(List list, String sheetName, Class clazz, HttpServletResponse response) { + try { + resetResponse(sheetName, response); + ServletOutputStream os = response.getOutputStream(); + exportExcel(list, sheetName, clazz, false, os, null); + } catch (IOException e) { + throw new RuntimeException("导出Excel异常"); + } + } + + /** + * 导出excel + * + * @param list 导出数据集合 + * @param sheetName 工作表的名称 + * @param clazz 实体类 + * @param response 响应体 + * @param options 级联下拉选 + */ + public static void exportExcel(List list, String sheetName, Class clazz, HttpServletResponse response, List options) { + try { + resetResponse(sheetName, response); + ServletOutputStream os = response.getOutputStream(); + exportExcel(list, sheetName, clazz, false, os, options); + } catch (IOException e) { + throw new RuntimeException("导出Excel异常"); + } + } + + /** + * 导出excel + * + * @param list 导出数据集合 + * @param sheetName 工作表的名称 + * @param clazz 实体类 + * @param merge 是否合并单元格 + * @param response 响应体 + */ + public static void exportExcel(List list, String sheetName, Class clazz, boolean merge, HttpServletResponse response) { + try { + resetResponse(sheetName, response); + ServletOutputStream os = response.getOutputStream(); + exportExcel(list, sheetName, clazz, merge, os, null); + } catch (IOException e) { + throw new RuntimeException("导出Excel异常"); + } + } + + /** + * 导出excel + * + * @param list 导出数据集合 + * @param sheetName 工作表的名称 + * @param clazz 实体类 + * @param merge 是否合并单元格 + * @param response 响应体 + * @param options 级联下拉选 + */ + public static void exportExcel(List list, String sheetName, Class clazz, boolean merge, HttpServletResponse response, List options) { + try { + resetResponse(sheetName, response); + ServletOutputStream os = response.getOutputStream(); + exportExcel(list, sheetName, clazz, merge, os, options); + } catch (IOException e) { + throw new RuntimeException("导出Excel异常"); + } + } + + /** + * 导出excel + * + * @param list 导出数据集合 + * @param sheetName 工作表的名称 + * @param clazz 实体类 + * @param os 输出流 + */ + public static void exportExcel(List list, String sheetName, Class clazz, OutputStream os) { + exportExcel(list, sheetName, clazz, false, os, null); + } + + /** + * 导出excel + * + * @param list 导出数据集合 + * @param sheetName 工作表的名称 + * @param clazz 实体类 + * @param os 输出流 + * @param options 级联下拉选内容 + */ + public static void exportExcel(List list, String sheetName, Class clazz, OutputStream os, List options) { + exportExcel(list, sheetName, clazz, false, os, options); + } + + /** + * 导出excel + * + * @param list 导出数据集合 + * @param sheetName 工作表的名称 + * @param clazz 实体类 + * @param merge 是否合并单元格 + * @param os 输出流 + */ + public static void exportExcel(List list, String sheetName, Class clazz, boolean merge, + OutputStream os, List options) { + ExcelWriterSheetBuilder builder = FastExcel.write(os, clazz) + .autoCloseStream(false) + // 自动适配 + .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) + // 大数值自动转换 防止失真 + .registerConverter(new ExcelBigNumberConvert()) + .registerWriteHandler(new DataWriteHandler(clazz)) + .sheet(sheetName); + if (merge) { + // 合并处理器 + builder.registerWriteHandler(new CellMergeStrategy(list, true)); + } + // 添加下拉框操作 + builder.registerWriteHandler(new ExcelDownHandler(options)); + builder.doWrite(list); + } + + /** + * 单表多数据模板导出 模板格式为 {.属性} + * + * @param filename 文件名 + * @param templatePath 模板路径 resource 目录下的路径包括模板文件名 + * 例如: excel/temp.xlsx + * 重点: 模板文件必须放置到启动类对应的 resource 目录下 + * @param data 模板需要的数据 + * @param response 响应体 + */ + public static void exportTemplate(List data, String filename, String templatePath, HttpServletResponse response) { + try { + if (CollUtil.isEmpty(data)) { + throw new IllegalArgumentException("数据为空"); + } + resetResponse(filename, response); + ServletOutputStream os = response.getOutputStream(); + exportTemplate(data, templatePath, os); + } catch (IOException e) { + throw new RuntimeException("导出Excel异常"); + } + } + + /** + * 单表多数据模板导出 模板格式为 {.属性} + * + * @param templatePath 模板路径 resource 目录下的路径包括模板文件名 + * 例如: excel/temp.xlsx + * 重点: 模板文件必须放置到启动类对应的 resource 目录下 + * @param data 模板需要的数据 + * @param os 输出流 + */ + public static void exportTemplate(List data, String templatePath, OutputStream os) { + ClassPathResource templateResource = new ClassPathResource(templatePath); + ExcelWriter excelWriter = FastExcel.write(os) + .withTemplate(templateResource.getStream()) + .autoCloseStream(false) + // 大数值自动转换 防止失真 + .registerConverter(new ExcelBigNumberConvert()) + .registerWriteHandler(new DataWriteHandler(data.get(0).getClass())) + .build(); + WriteSheet writeSheet = FastExcel.writerSheet().build(); + FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); + // 单表多数据导出 模板格式为 {.属性} + for (T d : data) { + excelWriter.fill(d, fillConfig, writeSheet); + } + excelWriter.finish(); + } + + /** + * 多表多数据模板导出 模板格式为 {key.属性} + * + * @param filename 文件名 + * @param templatePath 模板路径 resource 目录下的路径包括模板文件名 + * 例如: excel/temp.xlsx + * 重点: 模板文件必须放置到启动类对应的 resource 目录下 + * @param data 模板需要的数据 + * @param response 响应体 + */ + public static void exportTemplateMultiList(Map data, String filename, String templatePath, HttpServletResponse response) { + try { + if (CollUtil.isEmpty(data)) { + throw new IllegalArgumentException("数据为空"); + } + resetResponse(filename, response); + ServletOutputStream os = response.getOutputStream(); + exportTemplateMultiList(data, templatePath, os); + } catch (IOException e) { + throw new RuntimeException("导出Excel异常"); + } + } + + /** + * 多sheet模板导出 模板格式为 {key.属性} + * + * @param filename 文件名 + * @param templatePath 模板路径 resource 目录下的路径包括模板文件名 + * 例如: excel/temp.xlsx + * 重点: 模板文件必须放置到启动类对应的 resource 目录下 + * @param data 模板需要的数据 + * @param response 响应体 + */ + public static void exportTemplateMultiSheet(List> data, String filename, String templatePath, HttpServletResponse response) { + try { + if (CollUtil.isEmpty(data)) { + throw new IllegalArgumentException("数据为空"); + } + resetResponse(filename, response); + ServletOutputStream os = response.getOutputStream(); + exportTemplateMultiSheet(data, templatePath, os); + } catch (IOException e) { + throw new RuntimeException("导出Excel异常"); + } + } + + /** + * 多表多数据模板导出 模板格式为 {key.属性} + * + * @param templatePath 模板路径 resource 目录下的路径包括模板文件名 + * 例如: excel/temp.xlsx + * 重点: 模板文件必须放置到启动类对应的 resource 目录下 + * @param data 模板需要的数据 + * @param os 输出流 + */ + public static void exportTemplateMultiList(Map data, String templatePath, OutputStream os) { + ClassPathResource templateResource = new ClassPathResource(templatePath); + ExcelWriter excelWriter = FastExcel.write(os) + .withTemplate(templateResource.getStream()) + .autoCloseStream(false) + // 大数值自动转换 防止失真 + .registerConverter(new ExcelBigNumberConvert()) + .build(); + WriteSheet writeSheet = FastExcel.writerSheet().build(); + for (Map.Entry map : data.entrySet()) { + // 设置列表后续还有数据 + FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); + if (map.getValue() instanceof Collection) { + // 多表导出必须使用 FillWrapper + excelWriter.fill(new FillWrapper(map.getKey(), (Collection) map.getValue()), fillConfig, writeSheet); + } else { + excelWriter.fill(map.getValue(), fillConfig, writeSheet); + } + } + excelWriter.finish(); + } + + /** + * 多sheet模板导出 模板格式为 {key.属性} + * + * @param templatePath 模板路径 resource 目录下的路径包括模板文件名 + * 例如: excel/temp.xlsx + * 重点: 模板文件必须放置到启动类对应的 resource 目录下 + * @param data 模板需要的数据 + * @param os 输出流 + */ + public static void exportTemplateMultiSheet(List> data, String templatePath, OutputStream os) { + ClassPathResource templateResource = new ClassPathResource(templatePath); + ExcelWriter excelWriter = FastExcel.write(os) + .withTemplate(templateResource.getStream()) + .autoCloseStream(false) + // 大数值自动转换 防止失真 + .registerConverter(new ExcelBigNumberConvert()) + .build(); + for (int i = 0; i < data.size(); i++) { + WriteSheet writeSheet = FastExcel.writerSheet(i).build(); + for (Map.Entry map : data.get(i).entrySet()) { + // 设置列表后续还有数据 + FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); + if (map.getValue() instanceof Collection) { + // 多表导出必须使用 FillWrapper + excelWriter.fill(new FillWrapper(map.getKey(), (Collection) map.getValue()), fillConfig, writeSheet); + } else { + excelWriter.fill(map.getValue(), writeSheet); + } + } + } + excelWriter.finish(); + } + + /** + * 重置响应体 + */ + private static void resetResponse(String sheetName, HttpServletResponse response) throws UnsupportedEncodingException { + String filename = encodingFilename(sheetName); + FileUtils.setAttachmentResponseHeader(response, filename); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8"); + } + + /** + * 解析导出值 0=男,1=女,2=未知 + * + * @param propertyValue 参数值 + * @param converterExp 翻译注解 + * @param separator 分隔符 + * @return 解析后值 + */ + public static String convertByExp(String propertyValue, String converterExp, String separator) { + StringBuilder propertyString = new StringBuilder(); + String[] convertSource = converterExp.split(StringUtils.SEPARATOR); + for (String item : convertSource) { + String[] itemArray = item.split("="); + if (StringUtils.containsAny(propertyValue, separator)) { + for (String value : propertyValue.split(separator)) { + if (itemArray[0].equals(value)) { + propertyString.append(itemArray[1] + separator); + break; + } + } + } else { + if (itemArray[0].equals(propertyValue)) { + return itemArray[1]; + } + } + } + return StringUtils.stripEnd(propertyString.toString(), separator); + } + + /** + * 反向解析值 男=0,女=1,未知=2 + * + * @param propertyValue 参数值 + * @param converterExp 翻译注解 + * @param separator 分隔符 + * @return 解析后值 + */ + public static String reverseByExp(String propertyValue, String converterExp, String separator) { + StringBuilder propertyString = new StringBuilder(); + String[] convertSource = converterExp.split(StringUtils.SEPARATOR); + for (String item : convertSource) { + String[] itemArray = item.split("="); + if (StringUtils.containsAny(propertyValue, separator)) { + for (String value : propertyValue.split(separator)) { + if (itemArray[1].equals(value)) { + propertyString.append(itemArray[0] + separator); + break; + } + } + } else { + if (itemArray[1].equals(propertyValue)) { + return itemArray[0]; + } + } + } + return StringUtils.stripEnd(propertyString.toString(), separator); + } + + /** + * 编码文件名 + */ + public static String encodingFilename(String filename) { + return IdUtil.fastSimpleUUID() + "_" + filename + ".xlsx"; + } + +} diff --git a/ruoyi-common/ruoyi-common-idempotent/pom.xml b/ruoyi-common/ruoyi-common-idempotent/pom.xml new file mode 100644 index 0000000..64418b4 --- /dev/null +++ b/ruoyi-common/ruoyi-common-idempotent/pom.xml @@ -0,0 +1,41 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-idempotent + + + ruoyi-common-idempotent 幂等功能 + + + + + org.dromara + ruoyi-common-json + + + + org.dromara + ruoyi-common-redis + + + + cn.hutool + hutool-crypto + + + + cn.dev33 + sa-token-core + + + + + diff --git a/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/annotation/RepeatSubmit.java b/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/annotation/RepeatSubmit.java new file mode 100644 index 0000000..fba2286 --- /dev/null +++ b/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/annotation/RepeatSubmit.java @@ -0,0 +1,29 @@ +package org.dromara.common.idempotent.annotation; + +import java.lang.annotation.*; +import java.util.concurrent.TimeUnit; + +/** + * 自定义注解防止表单重复提交 + * + * @author pengles + */ +@Inherited +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RepeatSubmit { + + /** + * 间隔时间(ms),小于此时间视为重复提交 + */ + int interval() default 5000; + + TimeUnit timeUnit() default TimeUnit.MILLISECONDS; + + /** + * 提示消息 支持国际化 格式为 {code} + */ + String message() default "{repeat.submit.message}"; + +} diff --git a/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/aspectj/RepeatSubmitAspect.java b/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/aspectj/RepeatSubmitAspect.java new file mode 100644 index 0000000..5a8e192 --- /dev/null +++ b/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/aspectj/RepeatSubmitAspect.java @@ -0,0 +1,146 @@ +package org.dromara.common.idempotent.aspectj; + +import cn.dev33.satoken.SaManager; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.crypto.SecureUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.springframework.validation.BindingResult; +import org.springframework.web.multipart.MultipartFile; + +import java.time.Duration; +import java.util.Collection; +import java.util.Map; +import java.util.StringJoiner; + +/** + * 防止重复提交(参考美团GTIS防重系统) + * + * @author pengles + */ +@Aspect +public class RepeatSubmitAspect { + + private static final ThreadLocal KEY_CACHE = new ThreadLocal<>(); + + @Before("@annotation(repeatSubmit)") + public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable { + // 如果注解不为0 则使用注解数值 + long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval()); + + if (interval < 1000) { + throw new ServiceException("重复提交间隔时间不能小于'1'秒"); + } + HttpServletRequest request = ServletUtils.getRequest(); + String nowParams = argsArrayToString(point.getArgs()); + + // 请求地址(作为存放cache的key值) + String url = request.getRequestURI(); + + // 唯一值(没有消息头则使用请求地址) + String submitKey = StringUtils.trimToEmpty(request.getHeader(SaManager.getConfig().getTokenName())); + + submitKey = SecureUtil.md5(submitKey + ":" + nowParams); + // 唯一标识(指定key + url + 消息头) + String cacheRepeatKey = GlobalConstants.REPEAT_SUBMIT_KEY + url + submitKey; + if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) { + KEY_CACHE.set(cacheRepeatKey); + } else { + String message = repeatSubmit.message(); + if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) { + message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1)); + } + throw new ServiceException(message); + } + } + + /** + * 处理完请求后执行 + * + * @param joinPoint 切点 + */ + @AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult") + public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) { + if (jsonResult instanceof R r) { + try { + // 成功则不删除redis数据 保证在有效时间内无法重复提交 + if (r.getCode() == R.SUCCESS) { + return; + } + RedisUtils.deleteObject(KEY_CACHE.get()); + } finally { + KEY_CACHE.remove(); + } + } + } + + /** + * 拦截异常操作 + * + * @param joinPoint 切点 + * @param e 异常 + */ + @AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e") + public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) { + RedisUtils.deleteObject(KEY_CACHE.get()); + KEY_CACHE.remove(); + } + + /** + * 参数拼装 + */ + private String argsArrayToString(Object[] paramsArray) { + StringJoiner params = new StringJoiner(" "); + if (ArrayUtil.isEmpty(paramsArray)) { + return params.toString(); + } + for (Object o : paramsArray) { + if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) { + params.add(JsonUtils.toJsonString(o)); + } + } + return params.toString(); + } + + /** + * 判断是否需要过滤的对象。 + * + * @param o 对象信息。 + * @return 如果是需要过滤的对象,则返回true;否则返回false。 + */ + @SuppressWarnings("rawtypes") + public boolean isFilterObject(final Object o) { + Class clazz = o.getClass(); + if (clazz.isArray()) { + return MultipartFile.class.isAssignableFrom(clazz.getComponentType()); + } else if (Collection.class.isAssignableFrom(clazz)) { + Collection collection = (Collection) o; + for (Object value : collection) { + return value instanceof MultipartFile; + } + } else if (Map.class.isAssignableFrom(clazz)) { + Map map = (Map) o; + for (Object value : map.values()) { + return value instanceof MultipartFile; + } + } + return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse + || o instanceof BindingResult; + } + +} diff --git a/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/config/IdempotentConfig.java b/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/config/IdempotentConfig.java new file mode 100644 index 0000000..2c74a0e --- /dev/null +++ b/ruoyi-common/ruoyi-common-idempotent/src/main/java/org/dromara/common/idempotent/config/IdempotentConfig.java @@ -0,0 +1,21 @@ +package org.dromara.common.idempotent.config; + +import org.dromara.common.idempotent.aspectj.RepeatSubmitAspect; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisConfiguration; + +/** + * 幂等功能配置 + * + * @author pengles + */ +@AutoConfiguration(after = RedisConfiguration.class) +public class IdempotentConfig { + + @Bean + public RepeatSubmitAspect repeatSubmitAspect() { + return new RepeatSubmitAspect(); + } + +} diff --git a/ruoyi-common/ruoyi-common-idempotent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-idempotent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f2fa958 --- /dev/null +++ b/ruoyi-common/ruoyi-common-idempotent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.common.idempotent.config.IdempotentConfig diff --git a/ruoyi-common/ruoyi-common-job/pom.xml b/ruoyi-common/ruoyi-common-job/pom.xml new file mode 100644 index 0000000..3a4a0cb --- /dev/null +++ b/ruoyi-common/ruoyi-common-job/pom.xml @@ -0,0 +1,46 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-job + + + ruoyi-common-job 定时任务 + + + + + + org.springframework.boot + spring-boot-autoconfigure + + + + + com.aizuda + snail-job-client-starter + + + com.aizuda + snail-job-client-job-core + + + + org.projectlombok + lombok + + + + org.dromara + ruoyi-common-core + + + + diff --git a/ruoyi-common/ruoyi-common-job/src/main/java/org/dromara/common/job/config/SnailJobConfig.java b/ruoyi-common/ruoyi-common-job/src/main/java/org/dromara/common/job/config/SnailJobConfig.java new file mode 100644 index 0000000..0f8a0b6 --- /dev/null +++ b/ruoyi-common/ruoyi-common-job/src/main/java/org/dromara/common/job/config/SnailJobConfig.java @@ -0,0 +1,36 @@ +package org.dromara.common.job.config; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.aizuda.snailjob.client.common.appender.SnailLogbackAppender; +import com.aizuda.snailjob.client.common.event.SnailClientStartingEvent; +import com.aizuda.snailjob.client.starter.EnableSnailJob; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 启动定时任务 + * + * @author pengles + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "snail-job", name = "enabled", havingValue = "true") +@EnableScheduling +@EnableSnailJob +public class SnailJobConfig { + + @EventListener(SnailClientStartingEvent.class) + public void onStarting(SnailClientStartingEvent event) { + LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory(); + SnailLogbackAppender ca = new SnailLogbackAppender<>(); + ca.setName("snail_log_appender"); + ca.start(); + Logger rootLogger = lc.getLogger(Logger.ROOT_LOGGER_NAME); + rootLogger.addAppender(ca); + } + +} diff --git a/ruoyi-common/ruoyi-common-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..3aa1881 --- /dev/null +++ b/ruoyi-common/ruoyi-common-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.common.job.config.SnailJobConfig diff --git a/ruoyi-common/ruoyi-common-json/pom.xml b/ruoyi-common/ruoyi-common-json/pom.xml new file mode 100644 index 0000000..870df5c --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/pom.xml @@ -0,0 +1,37 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-json + + + ruoyi-common-json 序列化模块 + + + + + org.dromara + ruoyi-common-core + + + + + com.fasterxml.jackson.core + jackson-databind + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + diff --git a/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/config/JacksonConfig.java b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/config/JacksonConfig.java new file mode 100644 index 0000000..b4dade2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/config/JacksonConfig.java @@ -0,0 +1,51 @@ +package org.dromara.common.json.config; + +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.dromara.common.json.handler.BigNumberSerializer; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.json.handler.BigNumberSerializer; +import org.dromara.common.json.handler.CustomDateDeserializer; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.context.annotation.Bean; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.TimeZone; + +/** + * jackson 配置 + * + * @author pengles + */ +@Slf4j +@AutoConfiguration(before = JacksonAutoConfiguration.class) +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer customizer() { + return builder -> { + // 全局配置序列化返回 JSON 处理 + JavaTimeModule javaTimeModule = new JavaTimeModule(); + javaTimeModule.addSerializer(Long.class, BigNumberSerializer.INSTANCE); + javaTimeModule.addSerializer(Long.TYPE, BigNumberSerializer.INSTANCE); + javaTimeModule.addSerializer(BigInteger.class, BigNumberSerializer.INSTANCE); + javaTimeModule.addSerializer(BigDecimal.class, ToStringSerializer.instance); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter)); + javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(formatter)); + javaTimeModule.addDeserializer(Date.class, new CustomDateDeserializer()); + builder.modules(javaTimeModule); + builder.timeZone(TimeZone.getDefault()); + log.info("初始化 jackson 配置"); + }; + } + +} diff --git a/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/BigNumberSerializer.java b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/BigNumberSerializer.java new file mode 100644 index 0000000..f6fe164 --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/BigNumberSerializer.java @@ -0,0 +1,42 @@ +package org.dromara.common.json.handler; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JacksonStdImpl; +import com.fasterxml.jackson.databind.ser.std.NumberSerializer; + +import java.io.IOException; + +/** + * 超出 JS 最大最小值 处理 + * + * @author pengles + */ +@JacksonStdImpl +public class BigNumberSerializer extends NumberSerializer { + + /** + * 根据 JS Number.MAX_SAFE_INTEGER 与 Number.MIN_SAFE_INTEGER 得来 + */ + private static final long MAX_SAFE_INTEGER = 9007199254740991L; + private static final long MIN_SAFE_INTEGER = -9007199254740991L; + + /** + * 提供实例 + */ + public static final BigNumberSerializer INSTANCE = new BigNumberSerializer(Number.class); + + public BigNumberSerializer(Class rawType) { + super(rawType); + } + + @Override + public void serialize(Number value, JsonGenerator gen, SerializerProvider provider) throws IOException { + // 超出范围 序列化为字符串 + if (value.longValue() > MIN_SAFE_INTEGER && value.longValue() < MAX_SAFE_INTEGER) { + super.serialize(value, gen, provider); + } else { + gen.writeString(value.toString()); + } + } +} diff --git a/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/CustomDateDeserializer.java b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/CustomDateDeserializer.java new file mode 100644 index 0000000..069b924 --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/CustomDateDeserializer.java @@ -0,0 +1,31 @@ +package org.dromara.common.json.handler; + +import cn.hutool.core.date.DateUtil; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.util.Date; + +/** + * 自定义 Date 类型反序列化处理器(支持多种格式) + * + * @author AprilWind + */ +public class CustomDateDeserializer extends JsonDeserializer { + + /** + * 反序列化逻辑:将字符串转换为 Date 对象 + * + * @param p JSON 解析器,用于获取字符串值 + * @param ctxt 上下文环境(可用于获取更多配置) + * @return 转换后的 Date 对象,若为空字符串返回 null + * @throws IOException 当字符串格式非法或转换失败时抛出 + */ + @Override + public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return DateUtil.parse(p.getText()); + } + +} diff --git a/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/utils/JsonUtils.java b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/utils/JsonUtils.java new file mode 100644 index 0000000..65c2faa --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/utils/JsonUtils.java @@ -0,0 +1,170 @@ +package org.dromara.common.json.utils; + +import cn.hutool.core.lang.Dict; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * JSON 工具类 + * + * @author 芋道源码 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JsonUtils { + + private static final ObjectMapper OBJECT_MAPPER = SpringUtils.getBean(ObjectMapper.class); + + public static ObjectMapper getObjectMapper() { + return OBJECT_MAPPER; + } + + /** + * 将对象转换为JSON格式的字符串 + * + * @param object 要转换的对象 + * @return JSON格式的字符串,如果对象为null,则返回null + * @throws RuntimeException 如果转换过程中发生JSON处理异常,则抛出运行时异常 + */ + public static String toJsonString(Object object) { + if (ObjectUtil.isNull(object)) { + return null; + } + try { + return OBJECT_MAPPER.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + /** + * 将JSON格式的字符串转换为指定类型的对象 + * + * @param text JSON格式的字符串 + * @param clazz 要转换的目标对象类型 + * @param 目标对象的泛型类型 + * @return 转换后的对象,如果字符串为空则返回null + * @throws RuntimeException 如果转换过程中发生IO异常,则抛出运行时异常 + */ + public static T parseObject(String text, Class clazz) { + if (StringUtils.isEmpty(text)) { + return null; + } + try { + return OBJECT_MAPPER.readValue(text, clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 将字节数组转换为指定类型的对象 + * + * @param bytes 字节数组 + * @param clazz 要转换的目标对象类型 + * @param 目标对象的泛型类型 + * @return 转换后的对象,如果字节数组为空则返回null + * @throws RuntimeException 如果转换过程中发生IO异常,则抛出运行时异常 + */ + public static T parseObject(byte[] bytes, Class clazz) { + if (ArrayUtil.isEmpty(bytes)) { + return null; + } + try { + return OBJECT_MAPPER.readValue(bytes, clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 将JSON格式的字符串转换为指定类型的对象,支持复杂类型 + * + * @param text JSON格式的字符串 + * @param typeReference 指定类型的TypeReference对象 + * @param 目标对象的泛型类型 + * @return 转换后的对象,如果字符串为空则返回null + * @throws RuntimeException 如果转换过程中发生IO异常,则抛出运行时异常 + */ + public static T parseObject(String text, TypeReference typeReference) { + if (StringUtils.isBlank(text)) { + return null; + } + try { + return OBJECT_MAPPER.readValue(text, typeReference); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 将JSON格式的字符串转换为Dict对象 + * + * @param text JSON格式的字符串 + * @return 转换后的Dict对象,如果字符串为空或者不是JSON格式则返回null + * @throws RuntimeException 如果转换过程中发生IO异常,则抛出运行时异常 + */ + public static Dict parseMap(String text) { + if (StringUtils.isBlank(text)) { + return null; + } + try { + return OBJECT_MAPPER.readValue(text, OBJECT_MAPPER.getTypeFactory().constructType(Dict.class)); + } catch (MismatchedInputException e) { + // 类型不匹配说明不是json + return null; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 将JSON格式的字符串转换为Dict对象的列表 + * + * @param text JSON格式的字符串 + * @return 转换后的Dict对象的列表,如果字符串为空则返回null + * @throws RuntimeException 如果转换过程中发生IO异常,则抛出运行时异常 + */ + public static List parseArrayMap(String text) { + if (StringUtils.isBlank(text)) { + return null; + } + try { + return OBJECT_MAPPER.readValue(text, OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, Dict.class)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 将JSON格式的字符串转换为指定类型对象的列表 + * + * @param text JSON格式的字符串 + * @param clazz 要转换的目标对象类型 + * @param 目标对象的泛型类型 + * @return 转换后的对象的列表,如果字符串为空则返回空列表 + * @throws RuntimeException 如果转换过程中发生IO异常,则抛出运行时异常 + */ + public static List parseArray(String text, Class clazz) { + if (StringUtils.isEmpty(text)) { + return new ArrayList<>(); + } + try { + return OBJECT_MAPPER.readValue(text, OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, clazz)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-json/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-json/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..1625397 --- /dev/null +++ b/ruoyi-common/ruoyi-common-json/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.common.json.config.JacksonConfig diff --git a/ruoyi-common/ruoyi-common-log/pom.xml b/ruoyi-common/ruoyi-common-log/pom.xml new file mode 100644 index 0000000..1e2b33b --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/pom.xml @@ -0,0 +1,32 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-log + + + ruoyi-common-log 日志记录 + + + + + + org.dromara + ruoyi-common-satoken + + + + org.dromara + ruoyi-common-json + + + + + diff --git a/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/annotation/Log.java b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/annotation/Log.java new file mode 100644 index 0000000..f2b0004 --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/annotation/Log.java @@ -0,0 +1,48 @@ +package org.dromara.common.log.annotation; + +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.log.enums.OperatorType; + +import java.lang.annotation.*; + +/** + * 自定义操作日志记录注解 + * + * @author pengles + */ +@Target({ElementType.PARAMETER, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Log { + /** + * 模块 + */ + String title() default ""; + + /** + * 功能 + */ + BusinessType businessType() default BusinessType.OTHER; + + /** + * 操作人类别 + */ + OperatorType operatorType() default OperatorType.MANAGE; + + /** + * 是否保存请求的参数 + */ + boolean isSaveRequestData() default true; + + /** + * 是否保存响应的参数 + */ + boolean isSaveResponseData() default true; + + + /** + * 排除指定的请求参数 + */ + String[] excludeParamNames() default {}; + +} diff --git a/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/aspect/LogAspect.java b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/aspect/LogAspect.java new file mode 100644 index 0000000..30a8572 --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/aspect/LogAspect.java @@ -0,0 +1,218 @@ +package org.dromara.common.log.aspect; + +import cn.hutool.core.lang.Dict; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.time.StopWatch; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessStatus; +import org.dromara.common.log.event.OperLogEvent; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.http.HttpMethod; +import org.springframework.validation.BindingResult; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collection; +import java.util.Map; +import java.util.StringJoiner; + +/** + * 操作日志记录处理 + * + * @author pengles + */ +@Slf4j +@Aspect +@AutoConfiguration +public class LogAspect { + + /** + * 排除敏感属性字段 + */ + public static final String[] EXCLUDE_PROPERTIES = { "password", "oldPassword", "newPassword", "confirmPassword" }; + + + /** + * 计时 key + */ + private static final ThreadLocal KEY_CACHE = new ThreadLocal<>(); + + /** + * 处理请求前执行 + */ + @Before(value = "@annotation(controllerLog)") + public void doBefore(JoinPoint joinPoint, Log controllerLog) { + StopWatch stopWatch = new StopWatch(); + KEY_CACHE.set(stopWatch); + stopWatch.start(); + } + + /** + * 处理完请求后执行 + * + * @param joinPoint 切点 + */ + @AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult") + public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) { + handleLog(joinPoint, controllerLog, null, jsonResult); + } + + /** + * 拦截异常操作 + * + * @param joinPoint 切点 + * @param e 异常 + */ + @AfterThrowing(value = "@annotation(controllerLog)", throwing = "e") + public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) { + handleLog(joinPoint, controllerLog, e, null); + } + + protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) { + try { + + // *========数据库日志=========*// + OperLogEvent operLog = new OperLogEvent(); + operLog.setStatus(BusinessStatus.SUCCESS.ordinal()); + // 请求的地址 + String ip = ServletUtils.getClientIP(); + operLog.setOperIp(ip); + operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255)); + LoginUser loginUser = LoginHelper.getLoginUser(); + operLog.setOperName(loginUser.getUsername()); + operLog.setDeptName(loginUser.getDeptName()); + + if (e != null) { + operLog.setStatus(BusinessStatus.FAIL.ordinal()); + operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 3800)); + } + // 设置方法名称 + String className = joinPoint.getTarget().getClass().getName(); + String methodName = joinPoint.getSignature().getName(); + operLog.setMethod(className + "." + methodName + "()"); + // 设置请求方式 + operLog.setRequestMethod(ServletUtils.getRequest().getMethod()); + // 处理设置注解上的参数 + getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult); + // 设置消耗时间 + StopWatch stopWatch = KEY_CACHE.get(); + stopWatch.stop(); + operLog.setCostTime(stopWatch.getDuration().toMillis()); + // 发布事件保存数据库 + SpringUtils.context().publishEvent(operLog); + } catch (Exception exp) { + // 记录本地异常日志 + log.error("异常信息:{}", exp.getMessage()); + } finally { + KEY_CACHE.remove(); + } + } + + /** + * 获取注解中对方法的描述信息 用于Controller层注解 + * + * @param log 日志 + * @param operLog 操作日志 + * @throws Exception + */ + public void getControllerMethodDescription(JoinPoint joinPoint, Log log, OperLogEvent operLog, Object jsonResult) throws Exception { + // 设置action动作 + operLog.setBusinessType(log.businessType().ordinal()); + // 设置标题 + operLog.setTitle(log.title()); + // 设置操作人类别 + operLog.setOperatorType(log.operatorType().ordinal()); + // 是否需要保存request,参数和值 + if (log.isSaveRequestData()) { + // 获取参数的信息,传入到数据库中。 + setRequestValue(joinPoint, operLog, log.excludeParamNames()); + } + // 是否需要保存response,参数和值 + if (log.isSaveResponseData() && ObjectUtil.isNotNull(jsonResult)) { + operLog.setJsonResult(StringUtils.substring(JsonUtils.toJsonString(jsonResult), 0, 3800)); + } + } + + /** + * 获取请求的参数,放到log中 + * + * @param operLog 操作日志 + * @throws Exception 异常 + */ + private void setRequestValue(JoinPoint joinPoint, OperLogEvent operLog, String[] excludeParamNames) throws Exception { + Map paramsMap = ServletUtils.getParamMap(ServletUtils.getRequest()); + String requestMethod = operLog.getRequestMethod(); + if (MapUtil.isEmpty(paramsMap) && StringUtils.equalsAny(requestMethod, HttpMethod.PUT.name(), HttpMethod.POST.name(), HttpMethod.DELETE.name())) { + String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames); + operLog.setOperParam(StringUtils.substring(params, 0, 3800)); + } else { + MapUtil.removeAny(paramsMap, EXCLUDE_PROPERTIES); + MapUtil.removeAny(paramsMap, excludeParamNames); + operLog.setOperParam(StringUtils.substring(JsonUtils.toJsonString(paramsMap), 0, 3800)); + } + } + + /** + * 参数拼装 + */ + private String argsArrayToString(Object[] paramsArray, String[] excludeParamNames) { + StringJoiner params = new StringJoiner(" "); + if (ArrayUtil.isEmpty(paramsArray)) { + return params.toString(); + } + for (Object o : paramsArray) { + if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) { + String str = JsonUtils.toJsonString(o); + Dict dict = JsonUtils.parseMap(str); + if (MapUtil.isNotEmpty(dict)) { + MapUtil.removeAny(dict, EXCLUDE_PROPERTIES); + MapUtil.removeAny(dict, excludeParamNames); + str = JsonUtils.toJsonString(dict); + } + params.add(str); + } + } + return params.toString(); + } + + /** + * 判断是否需要过滤的对象。 + * + * @param o 对象信息。 + * @return 如果是需要过滤的对象,则返回true;否则返回false。 + */ + @SuppressWarnings("rawtypes") + public boolean isFilterObject(final Object o) { + Class clazz = o.getClass(); + if (clazz.isArray()) { + return MultipartFile.class.isAssignableFrom(clazz.getComponentType()); + } else if (Collection.class.isAssignableFrom(clazz)) { + Collection collection = (Collection) o; + for (Object value : collection) { + return value instanceof MultipartFile; + } + } else if (Map.class.isAssignableFrom(clazz)) { + Map map = (Map) o; + for (Object value : map.values()) { + return value instanceof MultipartFile; + } + } + return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse + || o instanceof BindingResult; + } +} diff --git a/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessStatus.java b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessStatus.java new file mode 100644 index 0000000..6feeea7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessStatus.java @@ -0,0 +1,18 @@ +package org.dromara.common.log.enums; + +/** + * 操作状态 + * + * @author pengles + */ +public enum BusinessStatus { + /** + * 成功 + */ + SUCCESS, + + /** + * 失败 + */ + FAIL, +} diff --git a/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessType.java b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessType.java new file mode 100644 index 0000000..07c04c7 --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessType.java @@ -0,0 +1,58 @@ +package org.dromara.common.log.enums; + +/** + * 业务操作类型 + * + * @author pengles + */ +public enum BusinessType { + /** + * 其它 + */ + OTHER, + + /** + * 新增 + */ + INSERT, + + /** + * 修改 + */ + UPDATE, + + /** + * 删除 + */ + DELETE, + + /** + * 授权 + */ + GRANT, + + /** + * 导出 + */ + EXPORT, + + /** + * 导入 + */ + IMPORT, + + /** + * 强退 + */ + FORCE, + + /** + * 生成代码 + */ + GENCODE, + + /** + * 清空数据 + */ + CLEAN, +} diff --git a/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/OperatorType.java b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/OperatorType.java new file mode 100644 index 0000000..0069c82 --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/OperatorType.java @@ -0,0 +1,23 @@ +package org.dromara.common.log.enums; + +/** + * 操作人类别 + * + * @author pengles + */ +public enum OperatorType { + /** + * 其它 + */ + OTHER, + + /** + * 后台用户 + */ + MANAGE, + + /** + * 手机端用户 + */ + MOBILE +} diff --git a/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/event/LogininforEvent.java b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/event/LogininforEvent.java new file mode 100644 index 0000000..dd0f59e --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/event/LogininforEvent.java @@ -0,0 +1,46 @@ +package org.dromara.common.log.event; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 登录事件 + * + * @author pengles + */ + +@Data +public class LogininforEvent implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户账号 + */ + private String username; + + /** + * 登录状态 0成功 1失败 + */ + private String status; + + /** + * 提示消息 + */ + private String message; + + /** + * 请求体 + */ + private HttpServletRequest request; + + /** + * 其他参数 + */ + private Object[] args; + +} diff --git a/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/event/OperLogEvent.java b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/event/OperLogEvent.java new file mode 100644 index 0000000..4f01dc2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/event/OperLogEvent.java @@ -0,0 +1,110 @@ +package org.dromara.common.log.event; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 操作日志事件 + * + * @author pengles + */ + +@Data +public class OperLogEvent implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 日志主键 + */ + private Long operId; + + /** + * 操作模块 + */ + private String title; + + /** + * 业务类型(0其它 1新增 2修改 3删除) + */ + private Integer businessType; + + /** + * 业务类型数组 + */ + private Integer[] businessTypes; + + /** + * 请求方法 + */ + private String method; + + /** + * 请求方式 + */ + private String requestMethod; + + /** + * 操作类别(0其它 1后台用户 2手机端用户) + */ + private Integer operatorType; + + /** + * 操作人员 + */ + private String operName; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 请求url + */ + private String operUrl; + + /** + * 操作地址 + */ + private String operIp; + + /** + * 操作地点 + */ + private String operLocation; + + /** + * 请求参数 + */ + private String operParam; + + /** + * 返回参数 + */ + private String jsonResult; + + /** + * 操作状态(0正常 1异常) + */ + private Integer status; + + /** + * 错误消息 + */ + private String errorMsg; + + /** + * 操作时间 + */ + private Date operTime; + + /** + * 消耗时间 + */ + private Long costTime; +} diff --git a/ruoyi-common/ruoyi-common-log/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-log/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..6893020 --- /dev/null +++ b/ruoyi-common/ruoyi-common-log/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.common.log.aspect.LogAspect diff --git a/ruoyi-common/ruoyi-common-mail/pom.xml b/ruoyi-common/ruoyi-common-mail/pom.xml new file mode 100644 index 0000000..c0e1b2e --- /dev/null +++ b/ruoyi-common/ruoyi-common-mail/pom.xml @@ -0,0 +1,34 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-mail + + + ruoyi-common-mail 邮件模块 + + + + + org.dromara + ruoyi-common-core + + + + jakarta.mail + jakarta.mail-api + + + org.eclipse.angus + jakarta.mail + + + + diff --git a/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/config/MailConfig.java b/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/config/MailConfig.java new file mode 100644 index 0000000..dd235eb --- /dev/null +++ b/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/config/MailConfig.java @@ -0,0 +1,37 @@ +package org.dromara.common.mail.config; + +import cn.hutool.extra.mail.MailAccount; +import org.dromara.common.mail.config.properties.MailProperties; +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; + +/** + * JavaMail 配置 + * + * @author pengles + */ +@AutoConfiguration +@EnableConfigurationProperties(MailProperties.class) +public class MailConfig { + + @Bean + @ConditionalOnProperty(value = "mail.enabled", havingValue = "true") + public MailAccount mailAccount(MailProperties mailProperties) { + MailAccount account = new MailAccount(); + account.setHost(mailProperties.getHost()); + account.setPort(mailProperties.getPort()); + account.setAuth(mailProperties.getAuth()); + account.setFrom(mailProperties.getFrom()); + account.setUser(mailProperties.getUser()); + account.setPass(mailProperties.getPass()); + account.setSocketFactoryPort(mailProperties.getPort()); + account.setStarttlsEnable(mailProperties.getStarttlsEnable()); + account.setSslEnable(mailProperties.getSslEnable()); + account.setTimeout(mailProperties.getTimeout()); + account.setConnectionTimeout(mailProperties.getConnectionTimeout()); + return account; + } + +} diff --git a/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/config/properties/MailProperties.java b/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/config/properties/MailProperties.java new file mode 100644 index 0000000..07cc637 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/config/properties/MailProperties.java @@ -0,0 +1,75 @@ +package org.dromara.common.mail.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * JavaMail 配置属性 + * + * @author pengles + */ +@Data +@ConfigurationProperties(prefix = "mail") +public class MailProperties { + + /** + * 过滤开关 + */ + private Boolean enabled; + + /** + * SMTP服务器域名 + */ + private String host; + + /** + * SMTP服务端口 + */ + private Integer port; + + /** + * 是否需要用户名密码验证 + */ + private Boolean auth; + + /** + * 用户名 + */ + private String user; + + /** + * 密码 + */ + private String pass; + + /** + * 发送方,遵循RFC-822标准
+ * 发件人可以是以下形式: + * + *
+     * 1. user@xxx.xx
+     * 2.  name <user@xxx.xx>
+     * 
+ */ + private String from; + + /** + * 使用 STARTTLS安全连接,STARTTLS是对纯文本通信协议的扩展。它将纯文本连接升级为加密连接(TLS或SSL), 而不是使用一个单独的加密通信端口。 + */ + private Boolean starttlsEnable; + + /** + * 使用 SSL安全连接 + */ + private Boolean sslEnable; + + /** + * SMTP超时时长,单位毫秒,缺省值不超时 + */ + private Long timeout; + + /** + * Socket连接超时值,单位毫秒,缺省值不超时 + */ + private Long connectionTimeout; +} diff --git a/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/utils/MailUtils.java b/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/utils/MailUtils.java new file mode 100644 index 0000000..a28701f --- /dev/null +++ b/ruoyi-common/ruoyi-common-mail/src/main/java/org/dromara/common/mail/utils/MailUtils.java @@ -0,0 +1,469 @@ +package org.dromara.common.mail.utils; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.CharUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.mail.JakartaMail; +import cn.hutool.extra.mail.JakartaUserPassAuthenticator; +import cn.hutool.extra.mail.MailAccount; +import jakarta.mail.Authenticator; +import jakarta.mail.Session; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; + +import java.io.File; +import java.io.InputStream; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +/** + * 邮件工具类 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MailUtils { + + private static final MailAccount ACCOUNT = SpringUtils.getBean(MailAccount.class); + + /** + * 获取邮件发送实例 + */ + public static MailAccount getMailAccount() { + return ACCOUNT; + } + + /** + * 获取邮件发送实例 (自定义发送人以及授权码) + * + * @param user 发送人 + * @param pass 授权码 + */ + public static MailAccount getMailAccount(String from, String user, String pass) { + ACCOUNT.setFrom(StringUtils.blankToDefault(from, ACCOUNT.getFrom())); + ACCOUNT.setUser(StringUtils.blankToDefault(user, ACCOUNT.getUser())); + ACCOUNT.setPass(StringUtils.blankToDefault(pass, ACCOUNT.getPass())); + return ACCOUNT; + } + + /** + * 使用配置文件中设置的账户发送文本邮件,发送给单个或多个收件人
+ * 多个收件人可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * + * @param to 收件人 + * @param subject 标题 + * @param content 正文 + * @param files 附件列表 + * @return message-id + * @since 3.2.0 + */ + public static String sendText(String to, String subject, String content, File... files) { + return send(to, subject, content, false, files); + } + + /** + * 使用配置文件中设置的账户发送HTML邮件,发送给单个或多个收件人
+ * 多个收件人可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * + * @param to 收件人 + * @param subject 标题 + * @param content 正文 + * @param files 附件列表 + * @return message-id + * @since 3.2.0 + */ + public static String sendHtml(String to, String subject, String content, File... files) { + return send(to, subject, content, true, files); + } + + /** + * 使用配置文件中设置的账户发送邮件,发送单个或多个收件人
+ * 多个收件人可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * + * @param to 收件人 + * @param subject 标题 + * @param content 正文 + * @param isHtml 是否为HTML + * @param files 附件列表 + * @return message-id + */ + public static String send(String to, String subject, String content, boolean isHtml, File... files) { + return send(splitAddress(to), subject, content, isHtml, files); + } + + /** + * 使用配置文件中设置的账户发送邮件,发送单个或多个收件人
+ * 多个收件人、抄送人、密送人可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * + * @param to 收件人,可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * @param cc 抄送人,可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * @param bcc 密送人,可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * @param subject 标题 + * @param content 正文 + * @param isHtml 是否为HTML + * @param files 附件列表 + * @return message-id + * @since 4.0.3 + */ + public static String send(String to, String cc, String bcc, String subject, String content, boolean isHtml, File... files) { + return send(splitAddress(to), splitAddress(cc), splitAddress(bcc), subject, content, isHtml, files); + } + + /** + * 使用配置文件中设置的账户发送文本邮件,发送给多人 + * + * @param tos 收件人列表 + * @param subject 标题 + * @param content 正文 + * @param files 附件列表 + * @return message-id + */ + public static String sendText(Collection tos, String subject, String content, File... files) { + return send(tos, subject, content, false, files); + } + + /** + * 使用配置文件中设置的账户发送HTML邮件,发送给多人 + * + * @param tos 收件人列表 + * @param subject 标题 + * @param content 正文 + * @param files 附件列表 + * @return message-id + * @since 3.2.0 + */ + public static String sendHtml(Collection tos, String subject, String content, File... files) { + return send(tos, subject, content, true, files); + } + + /** + * 使用配置文件中设置的账户发送邮件,发送给多人 + * + * @param tos 收件人列表 + * @param subject 标题 + * @param content 正文 + * @param isHtml 是否为HTML + * @param files 附件列表 + * @return message-id + */ + public static String send(Collection tos, String subject, String content, boolean isHtml, File... files) { + return send(tos, null, null, subject, content, isHtml, files); + } + + /** + * 使用配置文件中设置的账户发送邮件,发送给多人 + * + * @param tos 收件人列表 + * @param ccs 抄送人列表,可以为null或空 + * @param bccs 密送人列表,可以为null或空 + * @param subject 标题 + * @param content 正文 + * @param isHtml 是否为HTML + * @param files 附件列表 + * @return message-id + * @since 4.0.3 + */ + public static String send(Collection tos, Collection ccs, Collection bccs, String subject, String content, boolean isHtml, File... files) { + return send(getMailAccount(), true, tos, ccs, bccs, subject, content, null, isHtml, files); + } + + // ------------------------------------------------------------------------------------------------------------------------------- Custom MailAccount + + /** + * 发送邮件给多人 + * + * @param mailAccount 邮件认证对象 + * @param to 收件人,多个收件人逗号或者分号隔开 + * @param subject 标题 + * @param content 正文 + * @param isHtml 是否为HTML格式 + * @param files 附件列表 + * @return message-id + * @since 3.2.0 + */ + public static String send(MailAccount mailAccount, String to, String subject, String content, boolean isHtml, File... files) { + return send(mailAccount, splitAddress(to), subject, content, isHtml, files); + } + + /** + * 发送邮件给多人 + * + * @param mailAccount 邮件帐户信息 + * @param tos 收件人列表 + * @param subject 标题 + * @param content 正文 + * @param isHtml 是否为HTML格式 + * @param files 附件列表 + * @return message-id + */ + public static String send(MailAccount mailAccount, Collection tos, String subject, String content, boolean isHtml, File... files) { + return send(mailAccount, tos, null, null, subject, content, isHtml, files); + } + + /** + * 发送邮件给多人 + * + * @param mailAccount 邮件帐户信息 + * @param tos 收件人列表 + * @param ccs 抄送人列表,可以为null或空 + * @param bccs 密送人列表,可以为null或空 + * @param subject 标题 + * @param content 正文 + * @param isHtml 是否为HTML格式 + * @param files 附件列表 + * @return message-id + * @since 4.0.3 + */ + public static String send(MailAccount mailAccount, Collection tos, Collection ccs, Collection bccs, String subject, String content, boolean isHtml, File... files) { + return send(mailAccount, false, tos, ccs, bccs, subject, content, null, isHtml, files); + } + + /** + * 使用配置文件中设置的账户发送HTML邮件,发送给单个或多个收件人
+ * 多个收件人可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * + * @param to 收件人 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param files 附件列表 + * @return message-id + * @since 3.2.0 + */ + public static String sendHtml(String to, String subject, String content, Map imageMap, File... files) { + return send(to, subject, content, imageMap, true, files); + } + + /** + * 使用配置文件中设置的账户发送邮件,发送单个或多个收件人
+ * 多个收件人可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * + * @param to 收件人 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param isHtml 是否为HTML + * @param files 附件列表 + * @return message-id + */ + public static String send(String to, String subject, String content, Map imageMap, boolean isHtml, File... files) { + return send(splitAddress(to), subject, content, imageMap, isHtml, files); + } + + /** + * 使用配置文件中设置的账户发送邮件,发送单个或多个收件人
+ * 多个收件人、抄送人、密送人可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * + * @param to 收件人,可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * @param cc 抄送人,可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * @param bcc 密送人,可以使用逗号“,”分隔,也可以通过分号“;”分隔 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param isHtml 是否为HTML + * @param files 附件列表 + * @return message-id + * @since 4.0.3 + */ + public static String send(String to, String cc, String bcc, String subject, String content, Map imageMap, boolean isHtml, File... files) { + return send(splitAddress(to), splitAddress(cc), splitAddress(bcc), subject, content, imageMap, isHtml, files); + } + + /** + * 使用配置文件中设置的账户发送HTML邮件,发送给多人 + * + * @param tos 收件人列表 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param files 附件列表 + * @return message-id + * @since 3.2.0 + */ + public static String sendHtml(Collection tos, String subject, String content, Map imageMap, File... files) { + return send(tos, subject, content, imageMap, true, files); + } + + /** + * 使用配置文件中设置的账户发送邮件,发送给多人 + * + * @param tos 收件人列表 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param isHtml 是否为HTML + * @param files 附件列表 + * @return message-id + */ + public static String send(Collection tos, String subject, String content, Map imageMap, boolean isHtml, File... files) { + return send(tos, null, null, subject, content, imageMap, isHtml, files); + } + + /** + * 使用配置文件中设置的账户发送邮件,发送给多人 + * + * @param tos 收件人列表 + * @param ccs 抄送人列表,可以为null或空 + * @param bccs 密送人列表,可以为null或空 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param isHtml 是否为HTML + * @param files 附件列表 + * @return message-id + * @since 4.0.3 + */ + public static String send(Collection tos, Collection ccs, Collection bccs, String subject, String content, Map imageMap, boolean isHtml, File... files) { + return send(getMailAccount(), true, tos, ccs, bccs, subject, content, imageMap, isHtml, files); + } + + // ------------------------------------------------------------------------------------------------------------------------------- Custom MailAccount + + /** + * 发送邮件给多人 + * + * @param mailAccount 邮件认证对象 + * @param to 收件人,多个收件人逗号或者分号隔开 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param isHtml 是否为HTML格式 + * @param files 附件列表 + * @return message-id + * @since 3.2.0 + */ + public static String send(MailAccount mailAccount, String to, String subject, String content, Map imageMap, boolean isHtml, File... files) { + return send(mailAccount, splitAddress(to), subject, content, imageMap, isHtml, files); + } + + /** + * 发送邮件给多人 + * + * @param mailAccount 邮件帐户信息 + * @param tos 收件人列表 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param isHtml 是否为HTML格式 + * @param files 附件列表 + * @return message-id + * @since 4.6.3 + */ + public static String send(MailAccount mailAccount, Collection tos, String subject, String content, Map imageMap, boolean isHtml, File... files) { + return send(mailAccount, tos, null, null, subject, content, imageMap, isHtml, files); + } + + /** + * 发送邮件给多人 + * + * @param mailAccount 邮件帐户信息 + * @param tos 收件人列表 + * @param ccs 抄送人列表,可以为null或空 + * @param bccs 密送人列表,可以为null或空 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:$IMAGE_PLACEHOLDER + * @param isHtml 是否为HTML格式 + * @param files 附件列表 + * @return message-id + * @since 4.6.3 + */ + public static String send(MailAccount mailAccount, Collection tos, Collection ccs, Collection bccs, String subject, String content, Map imageMap, + boolean isHtml, File... files) { + return send(mailAccount, false, tos, ccs, bccs, subject, content, imageMap, isHtml, files); + } + + /** + * 根据配置文件,获取邮件客户端会话 + * + * @param mailAccount 邮件账户配置 + * @param isSingleton 是否单例(全局共享会话) + * @return {@link Session} + * @since 5.5.7 + */ + public static Session getSession(MailAccount mailAccount, boolean isSingleton) { + Authenticator authenticator = null; + if (mailAccount.isAuth()) { + authenticator = new JakartaUserPassAuthenticator(mailAccount.getUser(), mailAccount.getPass()); + } + + return isSingleton ? Session.getDefaultInstance(mailAccount.getSmtpProps(), authenticator) // + : Session.getInstance(mailAccount.getSmtpProps(), authenticator); + } + + // ------------------------------------------------------------------------------------------------------------------------ Private method start + + /** + * 发送邮件给多人 + * + * @param mailAccount 邮件帐户信息 + * @param useGlobalSession 是否全局共享Session + * @param tos 收件人列表 + * @param ccs 抄送人列表,可以为null或空 + * @param bccs 密送人列表,可以为null或空 + * @param subject 标题 + * @param content 正文 + * @param imageMap 图片与占位符,占位符格式为cid:${cid} + * @param isHtml 是否为HTML格式 + * @param files 附件列表 + * @return message-id + * @since 4.6.3 + */ + private static String send(MailAccount mailAccount, boolean useGlobalSession, Collection tos, Collection ccs, Collection bccs, String subject, String content, + Map imageMap, boolean isHtml, File... files) { + final JakartaMail mail = JakartaMail.create(mailAccount).setUseGlobalSession(useGlobalSession); + + // 可选抄送人 + if (CollUtil.isNotEmpty(ccs)) { + mail.setCcs(ccs.toArray(new String[0])); + } + // 可选密送人 + if (CollUtil.isNotEmpty(bccs)) { + mail.setBccs(bccs.toArray(new String[0])); + } + + mail.setTos(tos.toArray(new String[0])); + mail.setTitle(subject); + mail.setContent(content); + mail.setHtml(isHtml); + mail.setFiles(files); + + // 图片 + if (MapUtil.isNotEmpty(imageMap)) { + for (Entry entry : imageMap.entrySet()) { + mail.addImage(entry.getKey(), entry.getValue()); + // 关闭流 + IoUtil.close(entry.getValue()); + } + } + + return mail.send(); + } + + /** + * 将多个联系人转为列表,分隔符为逗号或者分号 + * + * @param addresses 多个联系人,如果为空返回null + * @return 联系人列表 + */ + private static List splitAddress(String addresses) { + if (StrUtil.isBlank(addresses)) { + return null; + } + + List result; + if (StrUtil.contains(addresses, CharUtil.COMMA)) { + result = StrUtil.splitTrim(addresses, CharUtil.COMMA); + } else if (StrUtil.contains(addresses, ';')) { + result = StrUtil.splitTrim(addresses, ';'); + } else { + result = CollUtil.newArrayList(addresses); + } + return result; + } + // ------------------------------------------------------------------------------------------------------------------------ Private method end +} diff --git a/ruoyi-common/ruoyi-common-mail/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-mail/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..ef0cf11 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mail/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.common.mail.config.MailConfig diff --git a/ruoyi-common/ruoyi-common-mybatis/pom.xml b/ruoyi-common/ruoyi-common-mybatis/pom.xml new file mode 100644 index 0000000..d79ba28 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/pom.xml @@ -0,0 +1,52 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-mybatis + + + ruoyi-common-mybatis 数据库服务 + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-satoken + + + + + com.baomidou + dynamic-datasource-spring-boot3-starter + + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + + com.baomidou + mybatis-plus-jsqlparser + + + + + p6spy + p6spy + + + + diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/annotation/DataColumn.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/annotation/DataColumn.java new file mode 100644 index 0000000..ed7d4d2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/annotation/DataColumn.java @@ -0,0 +1,40 @@ +package org.dromara.common.mybatis.annotation; + +import java.lang.annotation.*; + +/** + * 数据权限注解,用于标记数据权限的占位符关键字和替换值 + *

+ * 一个注解只能对应一个模板 + *

+ * + * @author pengles + * @version 3.5.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DataColumn { + + /** + * 数据权限模板的占位符关键字,默认为 "deptName" + * + * @return 占位符关键字数组 + */ + String[] key() default "deptName"; + + /** + * 数据权限模板的占位符替换值,默认为 "dept_id" + * + * @return 占位符替换值数组 + */ + String[] value() default "dept_id"; + + /** + * 权限标识符 用于通过菜单权限标识符来获取数据权限 + * 拥有此标识符的角色 将不会拼接此角色的数据过滤sql + * + * @return 权限标识符 + */ + String permission() default ""; +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/annotation/DataPermission.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/annotation/DataPermission.java new file mode 100644 index 0000000..12e780a --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/annotation/DataPermission.java @@ -0,0 +1,30 @@ +package org.dromara.common.mybatis.annotation; + +import java.lang.annotation.*; + +/** + * 数据权限组注解,用于标记数据权限配置数组 + * + * @author pengles + * @version 3.5.0 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DataPermission { + + /** + * 数据权限配置数组,用于指定数据权限的占位符关键字和替换值 + * + * @return 数据权限配置数组 + */ + DataColumn[] value(); + + /** + * 权限拼接标识符(用于指定连接语句的sql符号) + * 如不填 默认 select 用 OR 其他语句用 AND + * 内容 OR 或者 AND + */ + String joinStr() default ""; + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/aspect/DataPermissionAspect.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/aspect/DataPermissionAspect.java new file mode 100644 index 0000000..6f34d6d --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/aspect/DataPermissionAspect.java @@ -0,0 +1,50 @@ +package org.dromara.common.mybatis.aspect; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.dromara.common.mybatis.annotation.DataPermission; +import org.dromara.common.mybatis.helper.DataPermissionHelper; + +/** + * 数据权限处理 + * + * @author pengles + */ +@Slf4j +@Aspect +public class DataPermissionAspect { + + /** + * 处理请求前执行 + */ + @Before(value = "@annotation(dataPermission)") + public void doBefore(JoinPoint joinPoint, DataPermission dataPermission) { + DataPermissionHelper.setPermission(dataPermission); + } + + /** + * 处理完请求后执行 + * + * @param joinPoint 切点 + */ + @AfterReturning(pointcut = "@annotation(dataPermission)") + public void doAfterReturning(JoinPoint joinPoint, DataPermission dataPermission) { + DataPermissionHelper.removePermission(); + } + + /** + * 拦截异常操作 + * + * @param joinPoint 切点 + * @param e 异常 + */ + @AfterThrowing(value = "@annotation(dataPermission)", throwing = "e") + public void doAfterThrowing(JoinPoint joinPoint, DataPermission dataPermission, Exception e) { + DataPermissionHelper.removePermission(); + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java new file mode 100644 index 0000000..a164c58 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java @@ -0,0 +1,130 @@ +package org.dromara.common.mybatis.config; + +import cn.hutool.core.net.NetUtil; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.core.handlers.PostInitTableInfoHandler; +import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; +import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.dromara.common.core.factory.YmlPropertySourceFactory; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.mybatis.aspect.DataPermissionAspect; +import org.dromara.common.mybatis.handler.InjectionMetaObjectHandler; +import org.dromara.common.mybatis.handler.MybatisExceptionHandler; +import org.dromara.common.mybatis.handler.PlusPostInitTableInfoHandler; +import org.dromara.common.mybatis.interceptor.PlusDataPermissionInterceptor; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.PropertySource; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * mybatis-plus配置类(下方注释有插件介绍) + * + * @author pengles + */ +@EnableTransactionManagement(proxyTargetClass = true) +@MapperScan("${mybatis-plus.mapperPackage}") +@PropertySource(value = "classpath:common-mybatis.yml", factory = YmlPropertySourceFactory.class) +public class MybatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + // 数据权限处理 + interceptor.addInnerInterceptor(dataPermissionInterceptor()); + // 分页插件 + interceptor.addInnerInterceptor(paginationInnerInterceptor()); + // 乐观锁插件 + interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor()); + return interceptor; + } + + /** + * 数据权限拦截器 + */ + public PlusDataPermissionInterceptor dataPermissionInterceptor() { + return new PlusDataPermissionInterceptor(SpringUtils.getProperty("mybatis-plus.mapperPackage")); + } + + /** + * 数据权限切面处理器 + */ + @Bean + public DataPermissionAspect dataPermissionAspect() { + return new DataPermissionAspect(); + } + + /** + * 分页插件,自动识别数据库类型 + */ + public PaginationInnerInterceptor paginationInnerInterceptor() { + PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(); + // 分页合理化 + paginationInnerInterceptor.setOverflow(true); + return paginationInnerInterceptor; + } + + /** + * 乐观锁插件 + */ + public OptimisticLockerInnerInterceptor optimisticLockerInnerInterceptor() { + return new OptimisticLockerInnerInterceptor(); + } + + /** + * 元对象字段填充控制器 + */ + @Bean + public MetaObjectHandler metaObjectHandler() { + return new InjectionMetaObjectHandler(); + } + + /** + * 使用网卡信息绑定雪花生成器 + * 防止集群雪花ID重复 + */ + @Bean + public IdentifierGenerator idGenerator() { + return new DefaultIdentifierGenerator(NetUtil.getLocalhost()); + } + + /** + * 异常处理器 + */ + @Bean + public MybatisExceptionHandler mybatisExceptionHandler() { + return new MybatisExceptionHandler(); + } + + /** + * 初始化表对象处理器 + */ + @Bean + public PostInitTableInfoHandler postInitTableInfoHandler() { + return new PlusPostInitTableInfoHandler(); + } + + /** + * PaginationInnerInterceptor 分页插件,自动识别数据库类型 + * https://baomidou.com/pages/97710a/ + * OptimisticLockerInnerInterceptor 乐观锁插件 + * https://baomidou.com/pages/0d93c0/ + * MetaObjectHandler 元对象字段填充控制器 + * https://baomidou.com/pages/4c6bcf/ + * ISqlInjector sql注入器 + * https://baomidou.com/pages/42ea4a/ + * BlockAttackInnerInterceptor 如果是对全表的删除或更新操作,就会终止该操作 + * https://baomidou.com/pages/f9a237/ + * IllegalSQLInnerInterceptor sql性能规范插件(垃圾SQL拦截) + * IdentifierGenerator 自定义主键策略 + * https://baomidou.com/pages/568eb2/ + * TenantLineInnerInterceptor 多租户插件 + * https://baomidou.com/pages/aef2f2/ + * DynamicTableNameInnerInterceptor 动态表名插件 + * https://baomidou.com/pages/2a45ff/ + */ + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/domain/BaseEntity.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/domain/BaseEntity.java new file mode 100644 index 0000000..8f9a8c3 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/domain/BaseEntity.java @@ -0,0 +1,70 @@ +package org.dromara.common.mybatis.core.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * Entity基类 + * + * @author pengles + */ +@Data +public class BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 搜索值 + */ + @JsonIgnore + @TableField(exist = false) + private String searchValue; + + /** + * 创建部门 + */ + @TableField(fill = FieldFill.INSERT) + private Long createDept; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** + * 请求参数 + */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @TableField(exist = false) + private Map params = new HashMap<>(); + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/mapper/BaseMapperPlus.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/mapper/BaseMapperPlus.java new file mode 100644 index 0000000..aa443c3 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/mapper/BaseMapperPlus.java @@ -0,0 +1,334 @@ +package org.dromara.common.mybatis.core.mapper; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +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.reflect.GenericTypeUtils; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.toolkit.Db; +import org.apache.ibatis.logging.Log; +import org.apache.ibatis.logging.LogFactory; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StreamUtils; + +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * 自定义 Mapper 接口, 实现 自定义扩展 + * + * @param table 泛型 + * @param vo 泛型 + * @author pengles + * @since 2021-05-13 + */ +@SuppressWarnings("unchecked") +public interface BaseMapperPlus extends BaseMapper { + + Log log = LogFactory.getLog(BaseMapperPlus.class); + + /** + * 获取当前实例对象关联的泛型类型 V 的 Class 对象 + * + * @return 返回当前实例对象关联的泛型类型 V 的 Class 对象 + */ + default Class currentVoClass() { + return (Class) GenericTypeUtils.resolveTypeArguments(this.getClass(), BaseMapperPlus.class)[1]; + } + + /** + * 获取当前实例对象关联的泛型类型 T 的 Class 对象 + * + * @return 返回当前实例对象关联的泛型类型 T 的 Class 对象 + */ + default Class currentModelClass() { + return (Class) GenericTypeUtils.resolveTypeArguments(this.getClass(), BaseMapperPlus.class)[0]; + } + + /** + * 使用默认的查询条件查询并返回结果列表 + * + * @return 返回查询结果的列表 + */ + default List selectList() { + return this.selectList(new QueryWrapper<>()); + } + + /** + * 批量插入实体对象集合 + * + * @param entityList 实体对象集合 + * @return 插入操作是否成功的布尔值 + */ + default boolean insertBatch(Collection entityList) { + return Db.saveBatch(entityList); + } + + /** + * 批量根据ID更新实体对象集合 + * + * @param entityList 实体对象集合 + * @return 更新操作是否成功的布尔值 + */ + default boolean updateBatchById(Collection entityList) { + return Db.updateBatchById(entityList); + } + + /** + * 批量插入或更新实体对象集合 + * + * @param entityList 实体对象集合 + * @return 插入或更新操作是否成功的布尔值 + */ + default boolean insertOrUpdateBatch(Collection entityList) { + return Db.saveOrUpdateBatch(entityList); + } + + /** + * 批量插入实体对象集合并指定批处理大小 + * + * @param entityList 实体对象集合 + * @param batchSize 批处理大小 + * @return 插入操作是否成功的布尔值 + */ + default boolean insertBatch(Collection entityList, int batchSize) { + return Db.saveBatch(entityList, batchSize); + } + + /** + * 批量根据ID更新实体对象集合并指定批处理大小 + * + * @param entityList 实体对象集合 + * @param batchSize 批处理大小 + * @return 更新操作是否成功的布尔值 + */ + default boolean updateBatchById(Collection entityList, int batchSize) { + return Db.updateBatchById(entityList, batchSize); + } + + /** + * 批量插入或更新实体对象集合并指定批处理大小 + * + * @param entityList 实体对象集合 + * @param batchSize 批处理大小 + * @return 插入或更新操作是否成功的布尔值 + */ + default boolean insertOrUpdateBatch(Collection entityList, int batchSize) { + return Db.saveOrUpdateBatch(entityList, batchSize); + } + + /** + * 根据ID查询单个VO对象 + * + * @param id 主键ID + * @return 查询到的单个VO对象 + */ + default V selectVoById(Serializable id) { + return selectVoById(id, this.currentVoClass()); + } + + /** + * 根据ID查询单个VO对象并将其转换为指定的VO类 + * + * @param id 主键ID + * @param voClass 要转换的VO类的Class对象 + * @param VO类的类型 + * @return 查询到的单个VO对象,经过转换为指定的VO类后返回 + */ + default C selectVoById(Serializable id, Class voClass) { + T obj = this.selectById(id); + if (ObjectUtil.isNull(obj)) { + return null; + } + return MapstructUtils.convert(obj, voClass); + } + + /** + * 根据ID集合批量查询VO对象列表 + * + * @param idList 主键ID集合 + * @return 查询到的VO对象列表 + */ + default List selectVoByIds(Collection idList) { + return selectVoByIds(idList, this.currentVoClass()); + } + + /** + * 根据ID集合批量查询实体对象列表,并将其转换为指定的VO对象列表 + * + * @param idList 主键ID集合 + * @param voClass 要转换的VO类的Class对象 + * @param VO类的类型 + * @return 查询到的VO对象列表,经过转换为指定的VO类后返回 + */ + default List selectVoByIds(Collection idList, Class voClass) { + List list = this.selectByIds(idList); + if (CollUtil.isEmpty(list)) { + return CollUtil.newArrayList(); + } + return MapstructUtils.convert(list, voClass); + } + + /** + * 根据查询条件Map查询VO对象列表 + * + * @param map 查询条件Map + * @return 查询到的VO对象列表 + */ + default List selectVoByMap(Map map) { + return selectVoByMap(map, this.currentVoClass()); + } + + /** + * 根据查询条件Map查询实体对象列表,并将其转换为指定的VO对象列表 + * + * @param map 查询条件Map + * @param voClass 要转换的VO类的Class对象 + * @param VO类的类型 + * @return 查询到的VO对象列表,经过转换为指定的VO类后返回 + */ + default List selectVoByMap(Map map, Class voClass) { + List list = this.selectByMap(map); + if (CollUtil.isEmpty(list)) { + return CollUtil.newArrayList(); + } + return MapstructUtils.convert(list, voClass); + } + + /** + * 根据条件查询单个VO对象 + * + * @param wrapper 查询条件Wrapper + * @return 查询到的单个VO对象 + */ + default V selectVoOne(Wrapper wrapper) { + return selectVoOne(wrapper, this.currentVoClass()); + } + + /** + * 根据条件查询单个VO对象,并根据需要决定是否抛出异常 + * + * @param wrapper 查询条件Wrapper + * @param throwEx 是否抛出异常的标志 + * @return 查询到的单个VO对象 + */ + default V selectVoOne(Wrapper wrapper, boolean throwEx) { + return selectVoOne(wrapper, this.currentVoClass(), throwEx); + } + + /** + * 根据条件查询单个VO对象,并指定返回的VO对象的类型 + * + * @param wrapper 查询条件Wrapper + * @param voClass 返回的VO对象的Class对象 + * @param 返回的VO对象的类型 + * @return 查询到的单个VO对象,经过类型转换为指定的VO类后返回 + */ + default C selectVoOne(Wrapper wrapper, Class voClass) { + return selectVoOne(wrapper, voClass, true); + } + + /** + * 根据条件查询单个实体对象,并将其转换为指定的VO对象 + * + * @param wrapper 查询条件Wrapper + * @param voClass 要转换的VO类的Class对象 + * @param throwEx 是否抛出异常的标志 + * @param VO类的类型 + * @return 查询到的单个VO对象,经过转换为指定的VO类后返回 + */ + default C selectVoOne(Wrapper wrapper, Class voClass, boolean throwEx) { + T obj = this.selectOne(wrapper, throwEx); + if (ObjectUtil.isNull(obj)) { + return null; + } + return MapstructUtils.convert(obj, voClass); + } + + /** + * 查询所有VO对象列表 + * + * @return 查询到的VO对象列表 + */ + default List selectVoList() { + return selectVoList(new QueryWrapper<>(), this.currentVoClass()); + } + + /** + * 根据条件查询VO对象列表 + * + * @param wrapper 查询条件Wrapper + * @return 查询到的VO对象列表 + */ + default List selectVoList(Wrapper wrapper) { + return selectVoList(wrapper, this.currentVoClass()); + } + + /** + * 根据条件查询实体对象列表,并将其转换为指定的VO对象列表 + * + * @param wrapper 查询条件Wrapper + * @param voClass 要转换的VO类的Class对象 + * @param VO类的类型 + * @return 查询到的VO对象列表,经过转换为指定的VO类后返回 + */ + default List selectVoList(Wrapper wrapper, Class voClass) { + List list = this.selectList(wrapper); + if (CollUtil.isEmpty(list)) { + return CollUtil.newArrayList(); + } + return MapstructUtils.convert(list, voClass); + } + + /** + * 根据条件分页查询VO对象列表 + * + * @param page 分页信息 + * @param wrapper 查询条件Wrapper + * @return 查询到的VO对象分页列表 + */ + default

> P selectVoPage(IPage page, Wrapper wrapper) { + return selectVoPage(page, wrapper, this.currentVoClass()); + } + + /** + * 根据条件分页查询实体对象列表,并将其转换为指定的VO对象分页列表 + * + * @param page 分页信息 + * @param wrapper 查询条件Wrapper + * @param voClass 要转换的VO类的Class对象 + * @param VO类的类型 + * @param

VO对象分页列表的类型 + * @return 查询到的VO对象分页列表,经过转换为指定的VO类后返回 + */ + default > P selectVoPage(IPage page, Wrapper wrapper, Class voClass) { + // 根据条件分页查询实体对象列表 + List list = this.selectList(page, wrapper); + // 创建一个新的VO对象分页列表,并设置分页信息 + IPage voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); + if (CollUtil.isEmpty(list)) { + return (P) voPage; + } + voPage.setRecords(MapstructUtils.convert(list, voClass)); + return (P) voPage; + } + + /** + * 根据条件查询符合条件的对象,并将其转换为指定类型的对象列表 + * + * @param wrapper 查询条件Wrapper + * @param mapper 转换函数,用于将查询到的对象转换为指定类型的对象 + * @param 要转换的对象的类型 + * @return 查询到的符合条件的对象列表,经过转换为指定类型的对象后返回 + */ + default List selectObjs(Wrapper wrapper, Function mapper) { + return StreamUtils.toList(this.selectObjs(wrapper), mapper); + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java new file mode 100644 index 0000000..d350f5c --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/PageQuery.java @@ -0,0 +1,129 @@ +package org.dromara.common.mybatis.core.page; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.sql.SqlUtil; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 分页查询实体类 + * + * @author pengles + */ +@Data +@NoArgsConstructor +public class PageQuery implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 分页大小 + */ + private Integer pageSize; + + /** + * 当前页数 + */ + private Integer pageNum; + + /** + * 排序列 + */ + private String orderByColumn; + + /** + * 排序的方向desc或者asc + */ + private String isAsc; + + /** + * 当前记录起始索引 默认值 + */ + public static final int DEFAULT_PAGE_NUM = 1; + + /** + * 每页显示记录数 默认值 默认查全部 + */ + public static final int DEFAULT_PAGE_SIZE = Integer.MAX_VALUE; + + /** + * 构建分页对象 + */ + public Page build() { + Integer pageNum = ObjectUtil.defaultIfNull(getPageNum(), DEFAULT_PAGE_NUM); + Integer pageSize = ObjectUtil.defaultIfNull(getPageSize(), DEFAULT_PAGE_SIZE); + if (pageNum <= 0) { + pageNum = DEFAULT_PAGE_NUM; + } + Page page = new Page<>(pageNum, pageSize); + List orderItems = buildOrderItem(); + if (CollUtil.isNotEmpty(orderItems)) { + page.addOrder(orderItems); + } + return page; + } + + /** + * 构建排序 + * + * 支持的用法如下: + * {isAsc:"asc",orderByColumn:"id"} order by id asc + * {isAsc:"asc",orderByColumn:"id,createTime"} order by id asc,create_time asc + * {isAsc:"desc",orderByColumn:"id,createTime"} order by id desc,create_time desc + * {isAsc:"asc,desc",orderByColumn:"id,createTime"} order by id asc,create_time desc + */ + private List buildOrderItem() { + if (StringUtils.isBlank(orderByColumn) || StringUtils.isBlank(isAsc)) { + return null; + } + String orderBy = SqlUtil.escapeOrderBySql(orderByColumn); + orderBy = StringUtils.toUnderScoreCase(orderBy); + + // 兼容前端排序类型 + isAsc = StringUtils.replaceEach(isAsc, new String[]{"ascending", "descending"}, new String[]{"asc", "desc"}); + + String[] orderByArr = orderBy.split(StringUtils.SEPARATOR); + String[] isAscArr = isAsc.split(StringUtils.SEPARATOR); + if (isAscArr.length != 1 && isAscArr.length != orderByArr.length) { + throw new ServiceException("排序参数有误"); + } + + List list = new ArrayList<>(); + // 每个字段各自排序 + for (int i = 0; i < orderByArr.length; i++) { + String orderByStr = orderByArr[i]; + String isAscStr = isAscArr.length == 1 ? isAscArr[0] : isAscArr[i]; + if ("asc".equals(isAscStr)) { + list.add(OrderItem.asc(orderByStr)); + } else if ("desc".equals(isAscStr)) { + list.add(OrderItem.desc(orderByStr)); + } else { + throw new ServiceException("排序参数有误"); + } + } + return list; + } + + @JsonIgnore + public Integer getFirstNum() { + return (pageNum - 1) * pageSize; + } + + public PageQuery(Integer pageSize, Integer pageNum) { + this.pageSize = pageSize; + this.pageNum = pageNum; + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/TableDataInfo.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/TableDataInfo.java new file mode 100644 index 0000000..1e91ca1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/core/page/TableDataInfo.java @@ -0,0 +1,107 @@ +package org.dromara.common.mybatis.core.page; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.http.HttpStatus; +import com.baomidou.mybatisplus.core.metadata.IPage; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 表格分页数据对象 + * + * @author pengles + */ +@Data +@NoArgsConstructor +public class TableDataInfo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 总记录数 + */ + private long total; + + /** + * 列表数据 + */ + private List rows; + + /** + * 消息状态码 + */ + private int code; + + /** + * 消息内容 + */ + private String msg; + + /** + * 分页 + * + * @param list 列表数据 + * @param total 总记录数 + */ + public TableDataInfo(List list, long total) { + this.rows = list; + this.total = total; + this.code = HttpStatus.HTTP_OK; + this.msg = "查询成功"; + } + + /** + * 根据分页对象构建表格分页数据对象 + */ + public static TableDataInfo build(IPage page) { + TableDataInfo rspData = new TableDataInfo<>(); + rspData.setCode(HttpStatus.HTTP_OK); + rspData.setMsg("查询成功"); + rspData.setRows(page.getRecords()); + rspData.setTotal(page.getTotal()); + return rspData; + } + + /** + * 根据数据列表构建表格分页数据对象 + */ + public static TableDataInfo build(List list) { + TableDataInfo rspData = new TableDataInfo<>(); + rspData.setCode(HttpStatus.HTTP_OK); + rspData.setMsg("查询成功"); + rspData.setRows(list); + rspData.setTotal(list.size()); + return rspData; + } + + /** + * 构建表格分页数据对象 + */ + public static TableDataInfo build() { + TableDataInfo rspData = new TableDataInfo<>(); + rspData.setCode(HttpStatus.HTTP_OK); + rspData.setMsg("查询成功"); + return rspData; + } + + /** + * 根据原始数据列表和分页参数,构建表格分页数据对象(用于假分页) + * + * @param list 原始数据列表(全部数据) + * @param page 分页参数对象(包含当前页码、每页大小等) + * @return 构造好的分页结果 TableDataInfo + */ + public static TableDataInfo build(List list, IPage page) { + if (CollUtil.isEmpty(list)) { + return TableDataInfo.build(); + } + List pageList = CollUtil.page((int) page.getCurrent() - 1, (int) page.getSize(), list); + return new TableDataInfo<>(pageList, list.size()); + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/enums/DataBaseType.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/enums/DataBaseType.java new file mode 100644 index 0000000..911e7dd --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/enums/DataBaseType.java @@ -0,0 +1,58 @@ +package org.dromara.common.mybatis.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.dromara.common.core.utils.StringUtils; + +/** + * 数据库类型 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum DataBaseType { + + /** + * MySQL + */ + MY_SQL("MySQL"), + + /** + * Oracle + */ + ORACLE("Oracle"), + + /** + * PostgreSQL + */ + POSTGRE_SQL("PostgreSQL"), + + /** + * SQL Server + */ + SQL_SERVER("Microsoft SQL Server"); + + /** + * 数据库类型 + */ + private final String type; + + /** + * 根据数据库产品名称查找对应的数据库类型 + * + * @param databaseProductName 数据库产品名称 + * @return 对应的数据库类型枚举值,如果未找到则返回 null + */ + public static DataBaseType find(String databaseProductName) { + if (StringUtils.isBlank(databaseProductName)) { + return null; + } + for (DataBaseType type : values()) { + if (type.getType().equals(databaseProductName)) { + return type; + } + } + return null; + } +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/enums/DataScopeType.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/enums/DataScopeType.java new file mode 100644 index 0000000..684f964 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/enums/DataScopeType.java @@ -0,0 +1,87 @@ +package org.dromara.common.mybatis.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.helper.DataPermissionHelper; + +/** + * 数据权限类型枚举 + *

+ * 支持使用 SpEL 模板表达式定义 SQL 查询条件 + * 内置数据: + * - {@code user}: 当前登录用户信息,参考 {@link LoginUser} + * 内置服务: + * - {@code sdss}: 系统数据权限服务,参考 ISysDataScopeService + * 如需扩展数据,可以通过 {@link DataPermissionHelper} 进行操作 + * 如需扩展服务,可以通过 ISysDataScopeService 自行编写 + *

+ * + * @author pengles + * @version 3.5.0 + */ +@Getter +@AllArgsConstructor +public enum DataScopeType { + + /** + * 全部数据权限 + */ + ALL("1", "", ""), + + /** + * 自定数据权限 + */ + CUSTOM("2", " #{#deptName} IN ( #{@sdss.getRoleCustom( #user.roleId )} ) ", " 1 = 0 "), + + /** + * 部门数据权限 + */ + DEPT("3", " #{#deptName} = #{#user.deptId} ", " 1 = 0 "), + + /** + * 部门及以下数据权限 + */ + DEPT_AND_CHILD("4", " #{#deptName} IN ( #{@sdss.getDeptAndChild( #user.deptId )} )", " 1 = 0 "), + + /** + * 仅本人数据权限 + */ + SELF("5", " #{#userName} = #{#user.userId} ", " 1 = 0 "), + + /** + * 部门及以下或本人数据权限 + */ + DEPT_AND_CHILD_OR_SELF("6", " #{#deptName} IN ( #{@sdss.getDeptAndChild( #user.deptId )} ) OR #{#userName} = #{#user.userId} ", " 1 = 0 "); + + private final String code; + + /** + * SpEL 模板表达式,用于构建 SQL 查询条件 + */ + private final String sqlTemplate; + + /** + * 如果不满足 {@code sqlTemplate} 的条件,则使用此默认 SQL 表达式 + */ + private final String elseSql; + + /** + * 根据枚举代码查找对应的枚举值 + * + * @param code 枚举代码 + * @return 对应的枚举值,如果未找到则返回 null + */ + public static DataScopeType findCode(String code) { + if (StringUtils.isBlank(code)) { + return null; + } + for (DataScopeType type : values()) { + if (type.getCode().equals(code)) { + return type; + } + } + return null; + } +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/InjectionMetaObjectHandler.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/InjectionMetaObjectHandler.java new file mode 100644 index 0000000..33fd098 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/InjectionMetaObjectHandler.java @@ -0,0 +1,113 @@ +package org.dromara.common.mybatis.handler; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.http.HttpStatus; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.reflection.MetaObject; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.ObjectUtils; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.common.satoken.utils.LoginHelper; + +import java.util.Date; + +/** + * MP注入处理器 + * + * @author pengles + * @date 2021/4/25 + */ +@Slf4j +public class InjectionMetaObjectHandler implements MetaObjectHandler { + + /** + * 如果用户不存在默认注入-1代表无用户 + */ + private static final Long DEFAULT_USER_ID = -1L; + + /** + * 插入填充方法,用于在插入数据时自动填充实体对象中的创建时间、更新时间、创建人、更新人等信息 + * + * @param metaObject 元对象,用于获取原始对象并进行填充 + */ + @Override + public void insertFill(MetaObject metaObject) { + try { + if (ObjectUtil.isNotNull(metaObject) && metaObject.getOriginalObject() instanceof BaseEntity baseEntity) { + // 获取当前时间作为创建时间和更新时间,如果创建时间不为空,则使用创建时间,否则使用当前时间 + Date current = ObjectUtils.notNull(baseEntity.getCreateTime(), new Date()); + baseEntity.setCreateTime(current); + baseEntity.setUpdateTime(current); + + // 如果创建人为空,则填充当前登录用户的信息 + if (ObjectUtil.isNull(baseEntity.getCreateBy())) { + LoginUser loginUser = getLoginUser(); + if (ObjectUtil.isNotNull(loginUser)) { + Long userId = loginUser.getUserId(); + // 填充创建人、更新人和创建部门信息 + baseEntity.setCreateBy(userId); + baseEntity.setUpdateBy(userId); + baseEntity.setCreateDept(ObjectUtils.notNull(baseEntity.getCreateDept(), loginUser.getDeptId())); + } else { + // 填充创建人、更新人和创建部门信息 + baseEntity.setCreateBy(DEFAULT_USER_ID); + baseEntity.setUpdateBy(DEFAULT_USER_ID); + baseEntity.setCreateDept(ObjectUtils.notNull(baseEntity.getCreateDept(), DEFAULT_USER_ID)); + } + } + } else { + Date date = new Date(); + this.strictInsertFill(metaObject, "createTime", Date.class, date); + this.strictInsertFill(metaObject, "updateTime", Date.class, date); + } + } catch (Exception e) { + throw new ServiceException("自动注入异常 => " + e.getMessage(), HttpStatus.HTTP_UNAUTHORIZED); + } + } + + /** + * 更新填充方法,用于在更新数据时自动填充实体对象中的更新时间和更新人信息 + * + * @param metaObject 元对象,用于获取原始对象并进行填充 + */ + @Override + public void updateFill(MetaObject metaObject) { + try { + if (ObjectUtil.isNotNull(metaObject) && metaObject.getOriginalObject() instanceof BaseEntity baseEntity) { + // 获取当前时间作为更新时间,无论原始对象中的更新时间是否为空都填充 + Date current = new Date(); + baseEntity.setUpdateTime(current); + + // 获取当前登录用户的ID,并填充更新人信息 + Long userId = LoginHelper.getUserId(); + if (ObjectUtil.isNotNull(userId)) { + baseEntity.setUpdateBy(userId); + } else { + baseEntity.setUpdateBy(DEFAULT_USER_ID); + } + } else { + this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date()); + } + } catch (Exception e) { + throw new ServiceException("自动注入异常 => " + e.getMessage(), HttpStatus.HTTP_UNAUTHORIZED); + } + } + + /** + * 获取当前登录用户信息 + * + * @return 当前登录用户的信息,如果用户未登录则返回 null + */ + private LoginUser getLoginUser() { + LoginUser loginUser; + try { + loginUser = LoginHelper.getLoginUser(); + } catch (Exception e) { + return null; + } + return loginUser; + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/MybatisExceptionHandler.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/MybatisExceptionHandler.java new file mode 100644 index 0000000..795132e --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/MybatisExceptionHandler.java @@ -0,0 +1,47 @@ +package org.dromara.common.mybatis.handler; + +import cn.hutool.http.HttpStatus; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; +import org.mybatis.spring.MyBatisSystemException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Mybatis异常处理器 + * + * @author pengles + */ +@Slf4j +@RestControllerAdvice +public class MybatisExceptionHandler { + + /** + * 主键或UNIQUE索引,数据重复异常 + */ + @ExceptionHandler(DuplicateKeyException.class) + public R handleDuplicateKeyException(DuplicateKeyException e, HttpServletRequest request) { + String requestURI = request.getRequestURI(); + log.error("请求地址'{}',数据库中已存在记录'{}'", requestURI, e.getMessage()); + return R.fail(HttpStatus.HTTP_CONFLICT, "数据库中已存在该记录,请联系管理员确认"); + } + + /** + * Mybatis系统异常 通用处理 + */ + @ExceptionHandler(MyBatisSystemException.class) + public R handleCannotFindDataSourceException(MyBatisSystemException e, HttpServletRequest request) { + String requestURI = request.getRequestURI(); + String message = e.getMessage(); + if (StringUtils.contains(message, "CannotFindDataSourceException")) { + log.error("请求地址'{}', 未找到数据源", requestURI); + return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "未找到数据源,请联系管理员确认"); + } + log.error("请求地址'{}', Mybatis系统异常", requestURI, e); + return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, message); + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/PlusDataPermissionHandler.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/PlusDataPermissionHandler.java new file mode 100644 index 0000000..a977c8a --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/PlusDataPermissionHandler.java @@ -0,0 +1,357 @@ +package org.dromara.common.mybatis.handler; + +import cn.hutool.core.annotation.AnnotationUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import org.apache.ibatis.io.Resources; +import org.dromara.common.core.domain.dto.RoleDTO; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.annotation.DataColumn; +import org.dromara.common.mybatis.annotation.DataPermission; +import org.dromara.common.mybatis.enums.DataScopeType; +import org.dromara.common.mybatis.helper.DataPermissionHelper; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.ClassMetadata; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.expression.*; +import org.springframework.expression.common.TemplateParserContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.util.ClassUtils; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * 数据权限过滤 + * + * @author pengles + * @version 3.5.0 + */ +@Slf4j +public class PlusDataPermissionHandler { + + /** + * 类名称与注解的映射关系缓存(由于aop无法拦截mybatis接口类上的注解 只能通过启动预扫描的方式进行) + */ + private final Map dataPermissionCacheMap = new ConcurrentHashMap<>(); + + /** + * spel 解析器 + */ + private final ExpressionParser parser = new SpelExpressionParser(); + private final ParserContext parserContext = new TemplateParserContext(); + /** + * bean解析器 用于处理 spel 表达式中对 bean 的调用 + */ + private final BeanResolver beanResolver = new BeanFactoryResolver(SpringUtils.getBeanFactory()); + + /** + * 构造方法,扫描指定包下的 Mapper 类并初始化缓存 + * + * @param mapperPackage Mapper 类所在的包路径 + */ + public PlusDataPermissionHandler(String mapperPackage) { + scanMapperClasses(mapperPackage); + } + + /** + * 获取数据过滤条件的 SQL 片段 + * + * @param where 原始的查询条件表达式 + * @param mappedStatementId Mapper 方法的 ID + * @param isSelect 是否为查询语句 + * @return 数据过滤条件的 SQL 片段 + */ + public Expression getSqlSegment(Expression where, String mappedStatementId, boolean isSelect) { + try { + // 获取数据权限配置 + DataPermission dataPermission = getDataPermission(mappedStatementId); + // 获取当前登录用户信息 + LoginUser currentUser = DataPermissionHelper.getVariable("user"); + if (ObjectUtil.isNull(currentUser)) { + currentUser = LoginHelper.getLoginUser(); + DataPermissionHelper.setVariable("user", currentUser); + } + // 如果是超级管理员,则不过滤数据 + if (LoginHelper.isSuperAdmin()) { + return where; + } + // 构造数据过滤条件的 SQL 片段 + String dataFilterSql = buildDataFilter(dataPermission, isSelect); + if (StringUtils.isBlank(dataFilterSql)) { + return where; + } + Expression expression = CCJSqlParserUtil.parseExpression(dataFilterSql); + // 数据权限使用单独的括号 防止与其他条件冲突 + ParenthesedExpressionList parenthesis = new ParenthesedExpressionList<>(expression); + if (ObjectUtil.isNotNull(where)) { + return new AndExpression(where, parenthesis); + } else { + return parenthesis; + } + } catch (JSQLParserException e) { + throw new ServiceException("数据权限解析异常 => " + e.getMessage()); + } finally { + DataPermissionHelper.removePermission(); + } + } + + /** + * 构建数据过滤条件的 SQL 语句 + * + * @param dataPermission 数据权限注解 + * @param isSelect 标志当前操作是否为查询操作,查询操作和更新或删除操作在处理过滤条件时会有不同的处理方式 + * @return 构建的数据过滤条件的 SQL 语句 + * @throws ServiceException 如果角色的数据范围异常或者 key 与 value 的长度不匹配,则抛出 ServiceException 异常 + */ + private String buildDataFilter(DataPermission dataPermission, boolean isSelect) { + // 更新或删除需满足所有条件 + String joinStr = isSelect ? " OR " : " AND "; + if (StringUtils.isNotBlank(dataPermission.joinStr())) { + joinStr = " " + dataPermission.joinStr() + " "; + } + LoginUser user = DataPermissionHelper.getVariable("user"); + Object defaultValue = "-1"; + NullSafeStandardEvaluationContext context = new NullSafeStandardEvaluationContext(defaultValue); + context.addPropertyAccessor(new NullSafePropertyAccessor(context.getPropertyAccessors().get(0), defaultValue)); + context.setBeanResolver(beanResolver); + DataPermissionHelper.getContext().forEach(context::setVariable); + Set conditions = new HashSet<>(); + // 优先设置变量 + List keys = new ArrayList<>(); + Map ignoreMap = new HashMap<>(); + for (DataColumn dataColumn : dataPermission.value()) { + if (dataColumn.key().length != dataColumn.value().length) { + throw new ServiceException("角色数据范围异常 => key与value长度不匹配"); + } + // 包含权限标识符 这直接跳过 + if (StringUtils.isNotBlank(dataColumn.permission()) && + CollUtil.contains(user.getMenuPermission(), dataColumn.permission()) + ) { + ignoreMap.put(dataColumn, Boolean.TRUE); + continue; + } + // 设置注解变量 key 为表达式变量 value 为变量值 + for (int i = 0; i < dataColumn.key().length; i++) { + context.setVariable(dataColumn.key()[i], dataColumn.value()[i]); + } + keys.addAll(Arrays.stream(dataColumn.key()).map(key -> "#" + key).toList()); + } + + for (RoleDTO role : user.getRoles()) { + user.setRoleId(role.getRoleId()); + // 获取角色权限泛型 + DataScopeType type = DataScopeType.findCode(role.getDataScope()); + if (ObjectUtil.isNull(type)) { + throw new ServiceException("角色数据范围异常 => " + role.getDataScope()); + } + // 全部数据权限直接返回 + if (type == DataScopeType.ALL) { + return StringUtils.EMPTY; + } + boolean isSuccess = false; + for (DataColumn dataColumn : dataPermission.value()) { + // 包含权限标识符 这直接跳过 + if (ignoreMap.containsKey(dataColumn)) { + // 修复多角色与权限标识符共用问题 https://gitee.com/dromara/RuoYi-Vue-Plus/issues/IB4CS4 + conditions.add(joinStr + " 1 = 1 "); + isSuccess = true; + continue; + } + // 不包含 key 变量 则不处理 + if (!StringUtils.containsAny(type.getSqlTemplate(), keys.toArray(String[]::new))) { + continue; + } + // 当前注解不满足模板 不处理 + if (!StringUtils.containsAny(type.getSqlTemplate(), dataColumn.key())) { + continue; + } + // 忽略数据权限 防止spel表达式内有其他sql查询导致死循环调用 + String sql = DataPermissionHelper.ignore(() -> + parser.parseExpression(type.getSqlTemplate(), parserContext).getValue(context, String.class) + ); + // 解析sql模板并填充 + conditions.add(joinStr + sql); + isSuccess = true; + } + // 未处理成功则填充兜底方案 + if (!isSuccess && StringUtils.isNotBlank(type.getElseSql())) { + conditions.add(joinStr + type.getElseSql()); + } + } + + if (CollUtil.isNotEmpty(conditions)) { + String sql = StreamUtils.join(conditions, Function.identity(), ""); + return sql.substring(joinStr.length()); + } + return StringUtils.EMPTY; + } + + /** + * 扫描指定包下的 Mapper 类,并查找其中带有特定注解的方法或类 + * + * @param mapperPackage Mapper 类所在的包路径 + */ + private void scanMapperClasses(String mapperPackage) { + // 创建资源解析器和元数据读取工厂 + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + CachingMetadataReaderFactory factory = new CachingMetadataReaderFactory(); + // 将 Mapper 包路径按分隔符拆分为数组 + String[] packagePatternArray = StringUtils.splitPreserveAllTokens(mapperPackage, ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS); + String classpath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX; + try { + for (String packagePattern : packagePatternArray) { + // 将包路径转换为资源路径 + String path = ClassUtils.convertClassNameToResourcePath(packagePattern); + // 获取指定路径下的所有 .class 文件资源 + Resource[] resources = resolver.getResources(classpath + path + "/*.class"); + for (Resource resource : resources) { + // 获取资源的类元数据 + ClassMetadata classMetadata = factory.getMetadataReader(resource).getClassMetadata(); + // 获取资源对应的类对象 + Class clazz = Resources.classForName(classMetadata.getClassName()); + // 查找类中的特定注解 + findAnnotation(clazz); + } + } + } catch (Exception e) { + log.error("初始化数据安全缓存时出错:{}", e.getMessage()); + } + } + + /** + * 在指定的类中查找特定的注解 DataPermission,并将带有这个注解的方法或类存储到 dataPermissionCacheMap 中 + * + * @param clazz 要查找的类 + */ + private void findAnnotation(Class clazz) { + DataPermission dataPermission; + for (Method method : clazz.getMethods()) { + if (method.isDefault() || method.isVarArgs()) { + continue; + } + String mappedStatementId = clazz.getName() + "." + method.getName(); + if (AnnotationUtil.hasAnnotation(method, DataPermission.class)) { + dataPermission = AnnotationUtil.getAnnotation(method, DataPermission.class); + dataPermissionCacheMap.put(mappedStatementId, dataPermission); + } + } + if (AnnotationUtil.hasAnnotation(clazz, DataPermission.class)) { + dataPermission = AnnotationUtil.getAnnotation(clazz, DataPermission.class); + dataPermissionCacheMap.put(clazz.getName(), dataPermission); + } + } + + /** + * 根据映射语句 ID 或类名获取对应的 DataPermission 注解对象 + * + * @param mapperId 映射语句 ID + * @return DataPermission 注解对象,如果不存在则返回 null + */ + public DataPermission getDataPermission(String mapperId) { + // 检查上下文中是否包含映射语句 ID 对应的 DataPermission 注解对象 + if (DataPermissionHelper.getPermission() != null) { + return DataPermissionHelper.getPermission(); + } + // 检查缓存中是否包含映射语句 ID 对应的 DataPermission 注解对象 + if (dataPermissionCacheMap.containsKey(mapperId)) { + return dataPermissionCacheMap.get(mapperId); + } + // 如果缓存中不包含映射语句 ID 对应的 DataPermission 注解对象,则尝试使用类名作为键查找 + String clazzName = mapperId.substring(0, mapperId.lastIndexOf(".")); + if (dataPermissionCacheMap.containsKey(clazzName)) { + return dataPermissionCacheMap.get(clazzName); + } + return null; + } + + /** + * 检查给定的映射语句 ID 是否有效,即是否能够找到对应的 DataPermission 注解对象 + * + * @param mapperId 映射语句 ID + * @return 如果找到对应的 DataPermission 注解对象,则返回 false;否则返回 true + */ + public boolean invalid(String mapperId) { + return getDataPermission(mapperId) == null; + } + + /** + * 对所有null变量找不到的变量返回默认值 + */ + @AllArgsConstructor + private static class NullSafeStandardEvaluationContext extends StandardEvaluationContext { + + private final Object defaultValue; + + @Override + public Object lookupVariable(String name) { + Object obj = super.lookupVariable(name); + // 如果读取到的值是 null,则返回默认值 + if (obj == null) { + return defaultValue; + } + return obj; + } + + } + + /** + * 对所有null变量找不到的变量返回默认值 委托模式 将不需要处理的方法委托给原处理器 + */ + @AllArgsConstructor + private static class NullSafePropertyAccessor implements PropertyAccessor { + + private final PropertyAccessor delegate; + private final Object defaultValue; + + @Override + public Class[] getSpecificTargetClasses() { + return delegate.getSpecificTargetClasses(); + } + + @Override + public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException { + return delegate.canRead(context, target, name); + } + + @Override + public TypedValue read(EvaluationContext context, Object target, String name) throws AccessException { + TypedValue value = delegate.read(context, target, name); + // 如果读取到的值是 null,则返回默认值 + if (value.getValue() == null) { + return new TypedValue(defaultValue); + } + return value; + } + + @Override + public boolean canWrite(EvaluationContext context, Object target, String name) throws AccessException { + return delegate.canWrite(context, target, name); + } + + @Override + public void write(EvaluationContext context, Object target, String name, Object newValue) throws AccessException { + delegate.write(context, target, name, newValue); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/PlusPostInitTableInfoHandler.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/PlusPostInitTableInfoHandler.java new file mode 100644 index 0000000..729e6e0 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/handler/PlusPostInitTableInfoHandler.java @@ -0,0 +1,27 @@ +package org.dromara.common.mybatis.handler; + +import cn.hutool.core.convert.Convert; +import com.baomidou.mybatisplus.core.handlers.PostInitTableInfoHandler; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import org.apache.ibatis.session.Configuration; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.reflect.ReflectUtils; + +/** + * 修改表信息初始化方式 + * 目前用于全局修改是否使用逻辑删除 + * + * @author pengles + */ +public class PlusPostInitTableInfoHandler implements PostInitTableInfoHandler { + + @Override + public void postTableInfo(TableInfo tableInfo, Configuration configuration) { + String flag = SpringUtils.getProperty("mybatis-plus.enableLogicDelete", "true"); + // 只有关闭时 统一设置false 为true时mp自动判断不处理 + if (!Convert.toBool(flag)) { + ReflectUtils.setFieldValue(tableInfo, "withLogicDelete", false); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/helper/DataBaseHelper.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/helper/DataBaseHelper.java new file mode 100644 index 0000000..025f5e4 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/helper/DataBaseHelper.java @@ -0,0 +1,81 @@ +package org.dromara.common.mybatis.helper; + +import cn.hutool.core.convert.Convert; +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.mybatis.enums.DataBaseType; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * 数据库助手 + * + * @author pengles + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DataBaseHelper { + + private static final DynamicRoutingDataSource DS = SpringUtils.getBean(DynamicRoutingDataSource.class); + + /** + * 获取当前数据库类型 + */ + public static DataBaseType getDataBaseType() { + DataSource dataSource = DS.determineDataSource(); + try (Connection conn = dataSource.getConnection()) { + DatabaseMetaData metaData = conn.getMetaData(); + String databaseProductName = metaData.getDatabaseProductName(); + return DataBaseType.find(databaseProductName); + } catch (SQLException e) { + throw new ServiceException(e.getMessage()); + } + } + + public static boolean isMySql() { + return DataBaseType.MY_SQL == getDataBaseType(); + } + + public static boolean isOracle() { + return DataBaseType.ORACLE == getDataBaseType(); + } + + public static boolean isPostgerSql() { + return DataBaseType.POSTGRE_SQL == getDataBaseType(); + } + + public static boolean isSqlServer() { + return DataBaseType.SQL_SERVER == getDataBaseType(); + } + + public static String findInSet(Object var1, String var2) { + DataBaseType dataBasyType = getDataBaseType(); + String var = Convert.toStr(var1); + if (dataBasyType == DataBaseType.SQL_SERVER) { + // charindex(',100,' , ',0,100,101,') <> 0 + return "charindex(',%s,' , ','+%s+',') <> 0".formatted(var, var2); + } else if (dataBasyType == DataBaseType.POSTGRE_SQL) { + // (select strpos(',0,100,101,' , ',100,')) <> 0 + return "(select strpos(','||%s||',' , ',%s,')) <> 0".formatted(var2, var); + } else if (dataBasyType == DataBaseType.ORACLE) { + // instr(',0,100,101,' , ',100,') <> 0 + return "instr(','||%s||',' , ',%s,') <> 0".formatted(var2, var); + } + // find_in_set(100 , '0,100,101') + return "find_in_set('%s' , %s) <> 0".formatted(var, var2); + } + + /** + * 获取当前加载的数据库名 + */ + public static List getDataSourceNameList() { + return new ArrayList<>(DS.getDataSources().keySet()); + } +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/helper/DataPermissionHelper.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/helper/DataPermissionHelper.java new file mode 100644 index 0000000..9c3e7df --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/helper/DataPermissionHelper.java @@ -0,0 +1,175 @@ +package org.dromara.common.mybatis.helper; + +import cn.dev33.satoken.context.SaHolder; +import cn.dev33.satoken.context.model.SaStorage; +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.plugins.IgnoreStrategy; +import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.reflect.ReflectUtils; +import org.dromara.common.mybatis.annotation.DataPermission; + +import java.util.HashMap; +import java.util.Map; +import java.util.Stack; +import java.util.function.Supplier; + +/** + * 数据权限助手 + * + * @author pengles + * @version 3.5.0 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@SuppressWarnings("unchecked cast") +public class DataPermissionHelper { + + private static final String DATA_PERMISSION_KEY = "data:permission"; + + private static final ThreadLocal> REENTRANT_IGNORE = ThreadLocal.withInitial(Stack::new); + + private static final ThreadLocal PERMISSION_CACHE = new ThreadLocal<>(); + + /** + * 获取当前执行mapper权限注解 + * + * @return 返回当前执行mapper权限注解 + */ + public static DataPermission getPermission() { + return PERMISSION_CACHE.get(); + } + + /** + * 设置当前执行mapper权限注解 + * + * @param dataPermission 数据权限注解 + */ + public static void setPermission(DataPermission dataPermission) { + PERMISSION_CACHE.set(dataPermission); + } + + /** + * 删除当前执行mapper权限注解 + */ + public static void removePermission() { + PERMISSION_CACHE.remove(); + } + + /** + * 从上下文中获取指定键的变量值,并将其转换为指定的类型 + * + * @param key 变量的键 + * @param 变量值的类型 + * @return 指定键的变量值,如果不存在则返回 null + */ + public static T getVariable(String key) { + Map context = getContext(); + return (T) context.get(key); + } + + /** + * 向上下文中设置指定键的变量值 + * + * @param key 要设置的变量的键 + * @param value 要设置的变量值 + */ + public static void setVariable(String key, Object value) { + Map context = getContext(); + context.put(key, value); + } + + /** + * 获取数据权限上下文 + * + * @return 存储在SaStorage中的Map对象,用于存储数据权限相关的上下文信息 + * @throws NullPointerException 如果数据权限上下文类型异常,则抛出NullPointerException + */ + public static Map getContext() { + SaStorage saStorage = SaHolder.getStorage(); + Object attribute = saStorage.get(DATA_PERMISSION_KEY); + if (ObjectUtil.isNull(attribute)) { + saStorage.set(DATA_PERMISSION_KEY, new HashMap<>()); + attribute = saStorage.get(DATA_PERMISSION_KEY); + } + if (attribute instanceof Map map) { + return map; + } + throw new NullPointerException("data permission context type exception"); + } + + private static IgnoreStrategy getIgnoreStrategy() { + Object ignoreStrategyLocal = ReflectUtils.getStaticFieldValue(ReflectUtils.getField(InterceptorIgnoreHelper.class, "IGNORE_STRATEGY_LOCAL")); + if (ignoreStrategyLocal instanceof ThreadLocal IGNORE_STRATEGY_LOCAL) { + if (IGNORE_STRATEGY_LOCAL.get() instanceof IgnoreStrategy ignoreStrategy) { + return ignoreStrategy; + } + } + return null; + } + + /** + * 开启忽略数据权限(开启后需手动调用 {@link #disableIgnore()} 关闭) + */ + public static void enableIgnore() { + IgnoreStrategy ignoreStrategy = getIgnoreStrategy(); + if (ObjectUtil.isNull(ignoreStrategy)) { + InterceptorIgnoreHelper.handle(IgnoreStrategy.builder().dataPermission(true).build()); + } else { + ignoreStrategy.setDataPermission(true); + } + Stack reentrantStack = REENTRANT_IGNORE.get(); + reentrantStack.push(reentrantStack.size() + 1); + } + + /** + * 关闭忽略数据权限 + */ + public static void disableIgnore() { + IgnoreStrategy ignoreStrategy = getIgnoreStrategy(); + if (ObjectUtil.isNotNull(ignoreStrategy)) { + boolean noOtherIgnoreStrategy = !Boolean.TRUE.equals(ignoreStrategy.getDynamicTableName()) + && !Boolean.TRUE.equals(ignoreStrategy.getBlockAttack()) + && !Boolean.TRUE.equals(ignoreStrategy.getIllegalSql()) + && CollectionUtil.isEmpty(ignoreStrategy.getOthers()); + Stack reentrantStack = REENTRANT_IGNORE.get(); + boolean empty = reentrantStack.isEmpty() || reentrantStack.pop() == 1; + if (noOtherIgnoreStrategy && empty) { + InterceptorIgnoreHelper.clearIgnoreStrategy(); + } else if (empty) { + ignoreStrategy.setDataPermission(false); + } + + } + } + + /** + * 在忽略数据权限中执行 + * + * @param handle 处理执行方法 + */ + public static void ignore(Runnable handle) { + enableIgnore(); + try { + handle.run(); + } finally { + disableIgnore(); + } + } + + /** + * 在忽略数据权限中执行 + * + * @param handle 处理执行方法 + */ + public static T ignore(Supplier handle) { + enableIgnore(); + try { + return handle.get(); + } finally { + disableIgnore(); + } + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/interceptor/PlusDataPermissionInterceptor.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/interceptor/PlusDataPermissionInterceptor.java new file mode 100644 index 0000000..74494f2 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/interceptor/PlusDataPermissionInterceptor.java @@ -0,0 +1,181 @@ +package org.dromara.common.mybatis.interceptor; + +import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper; +import com.baomidou.mybatisplus.core.toolkit.PluginUtils; +import com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler; +import com.baomidou.mybatisplus.extension.plugins.inner.BaseMultiTableInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; +import lombok.extern.slf4j.Slf4j; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.delete.Delete; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.statement.select.SetOperationList; +import net.sf.jsqlparser.statement.update.Update; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.mapping.SqlCommandType; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; +import org.dromara.common.mybatis.handler.PlusDataPermissionHandler; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; + +/** + * 数据权限拦截器 + * + * @author pengles + * @version 3.5.0 + */ +@Slf4j +public class PlusDataPermissionInterceptor extends BaseMultiTableInnerInterceptor implements InnerInterceptor { + + private final PlusDataPermissionHandler dataPermissionHandler; + + /** + * 构造函数,初始化 PlusDataPermissionHandler 实例 + * + * @param mapperPackage 扫描的映射器包 + */ + public PlusDataPermissionInterceptor(String mapperPackage) { + this.dataPermissionHandler = new PlusDataPermissionHandler(mapperPackage); + } + + /** + * 在执行查询之前,检查并处理数据权限相关逻辑 + * + * @param executor MyBatis 执行器对象 + * @param ms 映射语句对象 + * @param parameter 方法参数 + * @param rowBounds 分页对象 + * @param resultHandler 结果处理器 + * @param boundSql 绑定的 SQL 对象 + * @throws SQLException 如果发生 SQL 异常 + */ + @Override + public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { + // 检查是否需要忽略数据权限处理 + if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) { + return; + } + // 检查是否缺少有效的数据权限注解 + if (dataPermissionHandler.invalid(ms.getId())) { + return; + } + // 解析 sql 分配对应方法 + PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql); + mpBs.sql(parserSingle(mpBs.sql(), ms.getId())); + } + + /** + * 在准备 SQL 语句之前,检查并处理更新和删除操作的数据权限相关逻辑 + * + * @param sh MyBatis StatementHandler 对象 + * @param connection 数据库连接对象 + * @param transactionTimeout 事务超时时间 + */ + @Override + public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) { + PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh); + MappedStatement ms = mpSh.mappedStatement(); + // 获取 SQL 命令类型(增、删、改、查) + SqlCommandType sct = ms.getSqlCommandType(); + + // 只处理更新和删除操作的 SQL 语句 + if (sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) { + if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) { + return; + } + // 检查是否缺少有效的数据权限注解 + if (dataPermissionHandler.invalid(ms.getId())) { + return; + } + PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql(); + mpBs.sql(parserMulti(mpBs.sql(), ms.getId())); + } + } + + /** + * 处理 SELECT 查询语句中的 WHERE 条件 + * + * @param select SELECT 查询对象 + * @param index 查询语句的索引 + * @param sql 查询语句 + * @param obj WHERE 条件参数 + */ + @Override + protected void processSelect(Select select, int index, String sql, Object obj) { + if (select instanceof PlainSelect) { + this.setWhere((PlainSelect) select, (String) obj); + } else if (select instanceof SetOperationList setOperationList) { + List + + where t.table_id = #{tableId} order by c.sort + + + + + + + + diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/mapper/package-info.md b/ruoyi-modules/ruoyi-generator/src/main/resources/mapper/package-info.md new file mode 100644 index 0000000..c938b1e --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/mapper/package-info.md @@ -0,0 +1,3 @@ +java包使用 `.` 分割 resource 目录使用 `/` 分割 +
+此文件目的 防止文件夹粘连找不到 `xml` 文件 \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/bo.java.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/bo.java.vm new file mode 100644 index 0000000..e5b3fd3 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/bo.java.vm @@ -0,0 +1,51 @@ +package ${packageName}.domain.bo; + +import ${packageName}.domain.${ClassName}; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import jakarta.validation.constraints.*; +#foreach ($import in $importList) +import ${import}; +#end + +/** + * ${functionName}业务对象 ${tableName} + * + * @author ${author} + * @date ${datetime} + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = ${ClassName}.class,reverseConvertGenerate =false) + +public class ${ClassName}Bo extends BaseEntity { + + #foreach ($column in $columns) + #if(!$table.isSuperColumn($column.javaField) && ($column.query || $column.insert || $column.edit)) + /** + * $column.columnComment + */ + #if($column.insert && $column.edit) + #set($Group="AddGroup.class, EditGroup.class") + #elseif($column.insert) + #set($Group="AddGroup.class") + #elseif($column.edit) + #set($Group="EditGroup.class") + #end + #if($column.required) + #if($column.javaType == 'String') + @NotBlank(message = "$column.columnComment不能为空", groups = { $Group }) + #else + @NotNull(message = "$column.columnComment不能为空", groups = { $Group }) + #end + #end + private $column.javaType $column.javaField; + + #end + #end + +} diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/controller.java.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/controller.java.vm new file mode 100644 index 0000000..fa16597 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/controller.java.vm @@ -0,0 +1,115 @@ +package ${packageName}.controller; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.*; +import cn.dev33.satoken.annotation.SaCheckPermission; +import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.web.core.BaseController; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.excel.utils.ExcelUtil; +import ${packageName}.domain.vo.${ClassName}Vo; +import ${packageName}.domain.bo.${ClassName}Bo; +import ${packageName}.service.I${ClassName}Service; +#if($table.crud) +import org.dromara.common.mybatis.core.page.TableDataInfo; +#elseif($table.tree) +#end + +/** + * ${functionName} + * + * @author ${author} + * @date ${datetime} + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/${moduleName}/${businessName}") +public class ${ClassName}Controller extends BaseController { + + private final I${ClassName}Service ${className}Service; + +/** + * 查询${functionName}列表 + */ +@SaCheckPermission("${permissionPrefix}:list") +@GetMapping("/list") + #if($table.crud) + public TableDataInfo<${ClassName}Vo> list(${ClassName}Bo bo, PageQuery pageQuery) { + return ${className}Service.queryPageList(bo, pageQuery); + } + #elseif($table.tree) + public R> list(${ClassName}Bo bo) { + List<${ClassName}Vo> list = ${className}Service.queryList(bo); + return R.ok(list); + } + #end + + /** + * 导出${functionName}列表 + */ + @SaCheckPermission("${permissionPrefix}:export") + @Log(title = "${functionName}", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(${ClassName}Bo bo, HttpServletResponse response) { + List<${ClassName}Vo> list = ${className}Service.queryList(bo); + ExcelUtil.exportExcel(list, "${functionName}", ${ClassName}Vo.class, response); + } + + /** + * 获取${functionName}详细信息 + * + * @param ${pkColumn.javaField} 主键 + */ + @SaCheckPermission("${permissionPrefix}:query") + @GetMapping("/{${pkColumn.javaField}}") + public R<${ClassName}Vo> getInfo(@NotNull(message = "主键不能为空") + @PathVariable ${pkColumn.javaType} ${pkColumn.javaField}) { + return R.ok(${className}Service.queryById(${pkColumn.javaField})); + } + + /** + * 新增${functionName} + */ + @SaCheckPermission("${permissionPrefix}:add") + @Log(title = "${functionName}", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody ${ClassName}Bo bo) { + return toAjax(${className}Service.insertByBo(bo)); + } + + /** + * 修改${functionName} + */ + @SaCheckPermission("${permissionPrefix}:edit") + @Log(title = "${functionName}", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody ${ClassName}Bo bo) { + return toAjax(${className}Service.updateByBo(bo)); + } + + /** + * 删除${functionName} + * + * @param ${pkColumn.javaField}s 主键串 + */ + @SaCheckPermission("${permissionPrefix}:remove") + @Log(title = "${functionName}", businessType = BusinessType.DELETE) + @DeleteMapping("/{${pkColumn.javaField}s}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable ${pkColumn.javaType}[] ${pkColumn.javaField}s) { + return toAjax(${className}Service.deleteWithValidByIds(List.of(${pkColumn.javaField}s), true)); + } +} diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/domain.java.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/domain.java.vm new file mode 100644 index 0000000..e0330a4 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/domain.java.vm @@ -0,0 +1,48 @@ +package ${packageName}.domain; + +import org.dromara.common.mybatis.core.domain.BaseEntity; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; + #foreach ($import in $importList) + import ${import}; + #end + +import java.io.Serial; + +/** + * ${functionName}对象 ${tableName} + * + * @author ${author} + * @date ${datetime} + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("${tableName}") +public class ${ClassName} extends + + BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + #foreach ($column in $columns) + #if(!$table.isSuperColumn($column.javaField)) + /** + * $column.columnComment + */ + #if($column.javaField=='delFlag') + @TableLogic + #end + #if($column.javaField=='version') + @Version + #end + #if($column.isPk==1) + @TableId(value = "$column.columnName") + #end + private $column.javaType $column.javaField; + + #end + #end + + } diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/mapper.java.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/mapper.java.vm new file mode 100644 index 0000000..0922401 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/mapper.java.vm @@ -0,0 +1,15 @@ +package ${packageName}.mapper; + +import ${packageName}.domain.${ClassName}; +import ${packageName}.domain.vo.${ClassName}Vo; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; + +/** + * ${functionName}Mapper接口 + * + * @author ${author} + * @date ${datetime} + */ +public interface ${ClassName}Mapper extends BaseMapperPlus<${ClassName}, ${ClassName}Vo> { + +} diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/service.java.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/service.java.vm new file mode 100644 index 0000000..753d42d --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/service.java.vm @@ -0,0 +1,72 @@ +package ${packageName}.service; + +import ${packageName}.domain.vo.${ClassName}Vo; +import ${packageName}.domain.bo.${ClassName}Bo; + #if($table.crud) + import org.dromara.common.mybatis.core.page.TableDataInfo; + import org.dromara.common.mybatis.core.page.PageQuery; + #end + +import java.util.Collection; +import java.util.List; + +/** + * ${functionName}Service接口 + * + * @author ${author} + * @date ${datetime} + */ +public interface I${ClassName}Service { + + /** + * 查询${functionName} + * + * @param ${pkColumn.javaField} 主键 + * @return ${functionName} + */ + ${ClassName}Vo queryById(${pkColumn.javaType} ${pkColumn.javaField}); + + #if($table.crud) + /** + * 分页查询${functionName}列表 + * + * @param bo 查询条件 + * @param pageQuery 分页参数 + * @return ${functionName}分页列表 + */ + TableDataInfo<${ClassName}Vo> queryPageList(${ClassName}Bo bo, PageQuery pageQuery); + #end + + /** + * 查询符合条件的${functionName}列表 + * + * @param bo 查询条件 + * @return ${functionName}列表 + */ + List<${ClassName}Vo> queryList(${ClassName}Bo bo); + + /** + * 新增${functionName} + * + * @param bo ${functionName} + * @return 是否新增成功 + */ + Boolean insertByBo(${ClassName}Bo bo); + + /** + * 修改${functionName} + * + * @param bo ${functionName} + * @return 是否修改成功 + */ + Boolean updateByBo(${ClassName}Bo bo); + + /** + * 校验并批量删除${functionName}信息 + * + * @param ids 待删除的主键集合 + * @param isValid 是否进行有效性校验 + * @return 是否删除成功 + */ + Boolean deleteWithValidByIds(Collection<${pkColumn.javaType}> ids, Boolean isValid); +} diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/serviceImpl.java.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/serviceImpl.java.vm new file mode 100644 index 0000000..00cf3f4 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/serviceImpl.java.vm @@ -0,0 +1,160 @@ +package ${packageName}.service.impl; + +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; + #if($table.crud) + import org.dromara.common.mybatis.core.page.TableDataInfo; + import org.dromara.common.mybatis.core.page.PageQuery; + import com.baomidou.mybatisplus.extension.plugins.pagination.Page; + #end +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ${packageName}.domain.bo.${ClassName}Bo; +import ${packageName}.domain.vo.${ClassName}Vo; +import ${packageName}.domain.${ClassName}; +import ${packageName}.mapper.${ClassName}Mapper; +import ${packageName}.service.I${ClassName}Service; + +import java.util.List; +import java.util.Map; +import java.util.Collection; + +/** + * ${functionName}Service业务层处理 + * + * @author ${author} + * @date ${datetime} + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class ${ClassName}ServiceImpl implements I${ClassName}Service { + + private final ${ClassName}Mapper baseMapper; + + /** + * 查询${functionName} + * + * @param ${pkColumn.javaField} 主键 + * @return ${functionName} + */ + @Override + public ${ClassName}Vo queryById(${pkColumn.javaType} ${pkColumn.javaField}) { + return baseMapper.selectVoById(${pkColumn.javaField}); + } + + #if($table.crud) + /** + * 分页查询${functionName}列表 + * + * @param bo 查询条件 + * @param pageQuery 分页参数 + * @return ${functionName}分页列表 + */ + @Override + public TableDataInfo<${ClassName}Vo> queryPageList(${ClassName}Bo bo, PageQuery pageQuery) { + LambdaQueryWrapper<${ClassName}> lqw = buildQueryWrapper(bo); + Page<${ClassName}Vo> result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + #end + + /** + * 查询符合条件的${functionName}列表 + * + * @param bo 查询条件 + * @return ${functionName}列表 + */ + @Override + public List<${ClassName}Vo> queryList(${ClassName}Bo bo) { + LambdaQueryWrapper<${ClassName}> lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper<${ClassName}> buildQueryWrapper(${ClassName}Bo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper<${ClassName}> lqw = Wrappers.lambdaQuery(); + #foreach($column in $columns) + #if($column.query) + #set($queryType=$column.queryType) + #set($javaField=$column.javaField) + #set($javaType=$column.javaType) + #set($columnName=$column.columnName) + #set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)}) + #set($mpMethod=$column.queryType.toLowerCase()) + #if($queryType != 'BETWEEN') + #if($javaType == 'String') + #set($condition='StringUtils.isNotBlank(bo.get'+$AttrName+'())') + #else + #set($condition='bo.get'+$AttrName+'() != null') + #end + lqw.$mpMethod($condition, ${ClassName}::get$AttrName, bo.get$AttrName()); + #else + lqw.between(params.get("begin$AttrName") != null && params.get("end$AttrName") != null, + ${ClassName}::get$AttrName , params.get("begin$AttrName"), params.get("end$AttrName")); + #end + #end + #set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)}) + #if($column.isPk==1) + lqw.orderByAsc(${ClassName}::get$AttrName); + #end + #end + return lqw; + } + + /** + * 新增${functionName} + * + * @param bo ${functionName} + * @return 是否新增成功 + */ + @Override + public Boolean insertByBo(${ClassName}Bo bo) { + ${ClassName} add = MapstructUtils.convert(bo, ${ClassName}. class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + #set($pk=$pkColumn.javaField.substring(0,1).toUpperCase() + ${pkColumn.javaField.substring(1)}) + if (flag) { + bo.set$pk(add.get$pk()); + } + return flag; + } + + /** + * 修改${functionName} + * + * @param bo ${functionName} + * @return 是否修改成功 + */ + @Override + public Boolean updateByBo(${ClassName}Bo bo) { + ${ClassName} update = MapstructUtils.convert(bo, ${ClassName}. class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(${ClassName} entity) { + //TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除${functionName}信息 + * + * @param ids 待删除的主键集合 + * @param isValid 是否进行有效性校验 + * @return 是否删除成功 + */ + @Override + public Boolean deleteWithValidByIds(Collection<${pkColumn.javaType}> ids, Boolean isValid) { + if (isValid) { + //TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/vo.java.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/vo.java.vm new file mode 100644 index 0000000..af60f97 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/java/vo.java.vm @@ -0,0 +1,66 @@ +package ${packageName}.domain.vo; + + #foreach ($import in $importList) + import ${import}; + #end +import ${packageName}.domain.${ClassName}; +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * ${functionName}视图对象 ${tableName} + * + * @author ${author} + * @date ${datetime} + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = ${ClassName}.class) + +public class ${ClassName}Vo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + #foreach ($column in $columns) + #if($column.list) + /** + * $column.columnComment + */ + #set($parentheseIndex=$column.columnComment.indexOf("(")) + #if($parentheseIndex != -1) + #set($comment=$column.columnComment.substring(0, $parentheseIndex)) + #else + #set($comment=$column.columnComment) + #end + #if(${column.dictType} && ${column.dictType} != '') + @ExcelProperty(value = "${comment}", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "${column.dictType}") + #elseif($parentheseIndex != -1) + @ExcelProperty(value = "${comment}", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "$column.readConverterExp()") + #else + @ExcelProperty(value = "${comment}") + #end + private $column.javaType $column.javaField; + + #if($column.htmlType == "imageUpload") + /** + * ${column.columnComment}Url + */ + @Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "${column.javaField}") + private String ${column.javaField}Url; + #end + #end + #end + +} diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/oracle/sql.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/oracle/sql.vm new file mode 100644 index 0000000..f6638be --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/oracle/sql.vm @@ -0,0 +1,19 @@ +-- 菜单 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[0]}, '${functionName}', '${parentMenuId}', '1', '${businessName}', '${moduleName}/${businessName}/index', 1, 0, 'C', '0', '0', '${permissionPrefix}:list', '#', 103, 1, sysdate, null, null, '${functionName}菜单'); + +-- 按钮 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[1]}, '${functionName}查询', ${table.menuIds[0]}, '1', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:query', '#', 103, 1, sysdate, null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[2]}, '${functionName}新增', ${table.menuIds[0]}, '2', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:add', '#', 103, 1, sysdate, null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[3]}, '${functionName}修改', ${table.menuIds[0]}, '3', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:edit', '#', 103, 1, sysdate, null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[4]}, '${functionName}删除', ${table.menuIds[0]}, '4', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:remove', '#', 103, 1, sysdate, null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[5]}, '${functionName}导出', ${table.menuIds[0]}, '5', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:export', '#', 103, 1, sysdate, null, null, ''); diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/postgres/sql.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/postgres/sql.vm new file mode 100644 index 0000000..0923392 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/postgres/sql.vm @@ -0,0 +1,20 @@ +-- 菜单 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[0]}, '${functionName}', '${parentMenuId}', '1', '${businessName}', '${moduleName}/${businessName}/index', 1, 0, 'C', '0', '0', '${permissionPrefix}:list', '#', 103, 1, now(), null, null, '${functionName}菜单'); + +-- 按钮 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[1]}, '${functionName}查询', ${table.menuIds[0]}, '1', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:query', '#', 103, 1, now(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[2]}, '${functionName}新增', ${table.menuIds[0]}, '2', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:add', '#', 103, 1, now(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[3]}, '${functionName}修改', ${table.menuIds[0]}, '3', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:edit', '#', 103, 1, now(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[4]}, '${functionName}删除', ${table.menuIds[0]}, '4', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:remove', '#', 103, 1, now(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[5]}, '${functionName}导出', ${table.menuIds[0]}, '5', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:export', '#', 103, 1, now(), null, null, ''); + diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/sql.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/sql.vm new file mode 100644 index 0000000..01824c2 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/sql.vm @@ -0,0 +1,19 @@ +-- 菜单 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[0]}, '${functionName}', '${parentMenuId}', '1', '${businessName}', '${moduleName}/${businessName}/index', 1, 0, 'C', '0', '0', '${permissionPrefix}:list', '#', 103, 1, sysdate(), null, null, '${functionName}菜单'); + +-- 按钮 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[1]}, '${functionName}查询', ${table.menuIds[0]}, '1', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:query', '#', 103, 1, sysdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[2]}, '${functionName}新增', ${table.menuIds[0]}, '2', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:add', '#', 103, 1, sysdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[3]}, '${functionName}修改', ${table.menuIds[0]}, '3', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:edit', '#', 103, 1, sysdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[4]}, '${functionName}删除', ${table.menuIds[0]}, '4', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:remove', '#', 103, 1, sysdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[5]}, '${functionName}导出', ${table.menuIds[0]}, '5', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:export', '#', 103, 1, sysdate(), null, null, ''); diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/sqlserver/sql.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/sqlserver/sql.vm new file mode 100644 index 0000000..bdf166e --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/sql/sqlserver/sql.vm @@ -0,0 +1,19 @@ +-- 菜单 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[0]}, '${functionName}', '${parentMenuId}', '1', '${businessName}', '${moduleName}/${businessName}/index', 1, 0, 'C', '0', '0', '${permissionPrefix}:list', '#', 103, 1, getdate(), null, null, '${functionName}菜单'); + +-- 按钮 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[1]}, '${functionName}查询', ${table.menuIds[0]}, '1', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:query', '#', 103, 1, getdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[2]}, '${functionName}新增', ${table.menuIds[0]}, '2', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:add', '#', 103, 1, getdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[3]}, '${functionName}修改', ${table.menuIds[0]}, '3', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:edit', '#', 103, 1, getdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[4]}, '${functionName}删除', ${table.menuIds[0]}, '4', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:remove', '#', 103, 1, getdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(${table.menuIds[5]}, '${functionName}导出', ${table.menuIds[0]}, '5', '#', '', 1, 0, 'F', '0', '0', '${permissionPrefix}:export', '#', 103, 1, getdate(), null, null, ''); diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/ts/api.ts.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/ts/api.ts.vm new file mode 100644 index 0000000..aa1e281 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/ts/api.ts.vm @@ -0,0 +1,63 @@ +import request from '@/utils/request'; +import {AxiosPromise} from 'axios'; +import {${BusinessName}Form, ${BusinessName}Query, ${BusinessName}VO} from '@/api/'; + +/** + * 查询${functionName}列表 + * @param query + * @returns {*} + */ + +export const list${BusinessName} = (query?: ${BusinessName}Query): AxiosPromise<${BusinessName}VO[]> => { + return request({ + url: '/${moduleName}/${businessName}/list', + method: 'get', + params: query + }); +}; + +/** + * 查询${functionName}详细 + * @param ${pkColumn.javaField} + */ +export const get${BusinessName} = (${pkColumn.javaField}: string | number): AxiosPromise<${BusinessName}VO> => { + return request({ + url: '/${moduleName}/${businessName}/' + ${pkColumn.javaField}, + method: 'get' + }); +}; + +/** + * 新增${functionName} + * @param data + */ +export const add${BusinessName} = (data: ${BusinessName}Form) => { + return request({ + url: '/${moduleName}/${businessName}', + method: 'post', + data: data + }); +}; + +/** + * 修改${functionName} + * @param data + */ +export const update${BusinessName} = (data: ${BusinessName}Form) => { + return request({ + url: '/${moduleName}/${businessName}', + method: 'put', + data: data + }); +}; + +/** + * 删除${functionName} + * @param ${pkColumn.javaField} + */ +export const del${BusinessName} = (${pkColumn.javaField}: string | number | Array) => { + return request({ + url: '/${moduleName}/${businessName}/' + ${pkColumn.javaField}, + method: 'delete' + }); +}; diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/ts/types.ts.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/ts/types.ts.vm new file mode 100644 index 0000000..c3e1de9 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/ts/types.ts.vm @@ -0,0 +1,70 @@ +export interface ${BusinessName}VO { + #foreach ($column in $columns) + #if($column.list) + /** + * $column.columnComment + */ + $column.javaField:#if($column.javaField.indexOf("id") != -1 || $column.javaField.indexOf( + "Id") != -1) string | number; + #elseif($column.javaType == 'Long' || $column.javaType == 'Integer' || $column.javaType == + 'Double' || $column.javaType == 'Float' || $column.javaType == 'BigDecimal') number; + #elseif($column.javaType == 'Boolean') boolean; + #else string; + #end + #if($column.htmlType == "imageUpload") + /** + * ${column.columnComment}Url + */ + ${column.javaField}Url: string; + #end + #end + #end + #if ($table.tree) + /** + * 子对象 + */ + children: ${BusinessName}VO[]; + #end +} + +export interface ${BusinessName}Form extends BaseEntity { + #foreach ($column in $columns) + #if($column.insert || $column.edit) + /** + * $column.columnComment + */ + $column.javaField?:#if($column.javaField.indexOf("id") != -1 || $column.javaField.indexOf( + "Id") != -1) string | number; + #elseif($column.javaType == 'Long' || $column.javaType == 'Integer' || $column.javaType == + 'Double' || $column.javaType == 'Float' || $column.javaType == 'BigDecimal') number; + #elseif($column.javaType == 'Boolean') boolean; + #else string; + #end + #end + #end +} + +export interface ${BusinessName}Query #if(!${treeCode})extends PageQuery #end{ + + #foreach ($column in $columns) + #if($column.query) + /** + * $column.columnComment + */ + $column.javaField?:#if($column.javaField.indexOf("id") != -1 || $column.javaField.indexOf( + "Id") != -1) string | number; + #elseif($column.javaType == 'Long' || $column.javaType == 'Integer' || $column.javaType == + 'Double' || $column.javaType == 'Float' || $column.javaType == 'BigDecimal') number; + #elseif($column.javaType == 'Boolean') boolean; + #else string; + #end + #end + #end + /** + * 日期范围参数 + */ + params?: any; +} + + + diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/vue/index-tree.vue.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/vue/index-tree.vue.vm new file mode 100644 index 0000000..5cd323e --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/vue/index-tree.vue.vm @@ -0,0 +1,544 @@ + + + diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/vue/index.vue.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/vue/index.vue.vm new file mode 100644 index 0000000..cfc93c3 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/vue/index.vue.vm @@ -0,0 +1,519 @@ + + + diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/vm/xml/mapper.xml.vm b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/xml/mapper.xml.vm new file mode 100644 index 0000000..473e757 --- /dev/null +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/vm/xml/mapper.xml.vm @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-job/pom.xml b/ruoyi-modules/ruoyi-job/pom.xml new file mode 100644 index 0000000..6865b1a --- /dev/null +++ b/ruoyi-modules/ruoyi-job/pom.xml @@ -0,0 +1,34 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + jar + ruoyi-job + + + 任务调度 + + + + + + + org.dromara + ruoyi-common-json + + + + org.dromara + ruoyi-common-job + + + + + + diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/entity/BillDto.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/entity/BillDto.java new file mode 100644 index 0000000..2661e34 --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/entity/BillDto.java @@ -0,0 +1,30 @@ +package org.dromara.job.entity; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class BillDto { + + /** + * 账单ID + */ + private Long billId; + + /** + * 账单渠道 + */ + private String billChannel; + + /** + * 账单日期 + */ + private String billDate; + + /** + * 账单金额 + */ + private BigDecimal billAmount; + +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/package-info.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/package-info.java new file mode 100644 index 0000000..2f118b0 --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/package-info.java @@ -0,0 +1 @@ +package org.dromara.job; diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/AlipayBillTask.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/AlipayBillTask.java new file mode 100644 index 0000000..b6f8530 --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/AlipayBillTask.java @@ -0,0 +1,42 @@ +package org.dromara.job.snailjob; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.aizuda.snailjob.client.job.core.annotation.JobExecutor; +import com.aizuda.snailjob.client.job.core.dto.JobArgs; +import com.aizuda.snailjob.client.model.ExecuteResult; +import com.aizuda.snailjob.common.log.SnailJobLog; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.job.entity.BillDto; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +/** + * DAG工作流任务-模拟支付宝账单任务 + * + * + * @author pengles + */ +@Component +@JobExecutor(name = "alipayBillTask") +public class AlipayBillTask { + + public ExecuteResult jobExecute(JobArgs jobArgs) throws InterruptedException { + BillDto billDto = new BillDto(); + billDto.setBillId(23456789L); + billDto.setBillChannel("alipay"); + // 设置清算日期 + String settlementDate = (String) jobArgs.getWfContext().get("settlementDate"); + if (StrUtil.equals(settlementDate, "sysdate")) { + settlementDate = DateUtil.today(); + } + billDto.setBillDate(settlementDate); + billDto.setBillAmount(new BigDecimal("2345.67")); + // 把billDto对象放入上下文进行传递 + jobArgs.appendContext("alipay", JsonUtils.toJsonString(billDto)); + SnailJobLog.REMOTE.info("上下文: {}", jobArgs.getWfContext()); + return ExecuteResult.success(billDto); + } + +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/SummaryBillTask.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/SummaryBillTask.java new file mode 100644 index 0000000..fc188fd --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/SummaryBillTask.java @@ -0,0 +1,45 @@ +package org.dromara.job.snailjob; + +import cn.hutool.core.util.StrUtil; +import com.aizuda.snailjob.client.job.core.annotation.JobExecutor; +import com.aizuda.snailjob.client.job.core.dto.JobArgs; +import com.aizuda.snailjob.client.model.ExecuteResult; +import com.aizuda.snailjob.common.log.SnailJobLog; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.job.entity.BillDto; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +/** + * DAG工作流任务-模拟汇总账单任务 + * + * + * @author pengles + */ +@Component +@JobExecutor(name = "summaryBillTask") +public class SummaryBillTask { + + public ExecuteResult jobExecute(JobArgs jobArgs) throws InterruptedException { + // 获得微信账单 + BigDecimal wechatAmount = BigDecimal.valueOf(0); + String wechat = (String) jobArgs.getWfContext("wechat"); + if (StrUtil.isNotBlank(wechat)) { + BillDto wechatBillDto = JsonUtils.parseObject(wechat, BillDto.class); + wechatAmount = wechatBillDto.getBillAmount(); + } + // 获得支付宝账单 + BigDecimal alipayAmount = BigDecimal.valueOf(0); + String alipay = (String) jobArgs.getWfContext("alipay"); + if (StrUtil.isNotBlank(alipay)) { + BillDto alipayBillDto = JsonUtils.parseObject(alipay, BillDto.class); + alipayAmount = alipayBillDto.getBillAmount(); + } + // 汇总账单 + BigDecimal totalAmount = wechatAmount.add(alipayAmount); + SnailJobLog.REMOTE.info("总金额: {}", totalAmount); + return ExecuteResult.success(totalAmount); + } + +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestAnnoJobExecutor.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestAnnoJobExecutor.java new file mode 100644 index 0000000..fe3b53a --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestAnnoJobExecutor.java @@ -0,0 +1,25 @@ +package org.dromara.job.snailjob; + +import com.aizuda.snailjob.client.job.core.annotation.JobExecutor; +import com.aizuda.snailjob.client.job.core.dto.JobArgs; +import com.aizuda.snailjob.client.model.ExecuteResult; +import com.aizuda.snailjob.common.core.util.JsonUtil; +import com.aizuda.snailjob.common.log.SnailJobLog; +import org.springframework.stereotype.Component; + +/** + * 正常任务 + * + * + * @author pengles + */ +@Component +@JobExecutor(name = "testJobExecutor") +public class TestAnnoJobExecutor { + + public ExecuteResult jobExecute(JobArgs jobArgs) { + SnailJobLog.LOCAL.info("testJobExecutor. jobArgs:{}", JsonUtil.toJsonString(jobArgs)); + SnailJobLog.REMOTE.info("testJobExecutor. jobArgs:{}", JsonUtil.toJsonString(jobArgs)); + return ExecuteResult.success("测试成功"); + } +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestBroadcastJob.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestBroadcastJob.java new file mode 100644 index 0000000..6526566 --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestBroadcastJob.java @@ -0,0 +1,37 @@ +package org.dromara.job.snailjob; + +import cn.hutool.core.util.RandomUtil; +import com.aizuda.snailjob.client.job.core.annotation.JobExecutor; +import com.aizuda.snailjob.client.job.core.dto.JobArgs; +import com.aizuda.snailjob.client.model.ExecuteResult; +import com.aizuda.snailjob.common.log.SnailJobLog; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 广播任务 + * + * + * @author pengles + */ +@Slf4j +@Component +@JobExecutor(name = "testBroadcastJob") +public class TestBroadcastJob { + + @Value("${snail-job.port}") + private int clientPort; + + public ExecuteResult jobExecute(JobArgs jobArgs) { + int randomInt = RandomUtil.randomInt(100); + log.info("随机数: {}", randomInt); + SnailJobLog.REMOTE.info("随机数: {},客户端端口:{}", randomInt, clientPort); + if (randomInt < 50) { + throw new RuntimeException("随机数小于50,收集日志任务执行失败"); + } + // 获得jobArgs 中传入的相加的两个数 + return ExecuteResult.success("随机数大于50,收集日志任务执行成功"); + } + +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestClassJobExecutor.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestClassJobExecutor.java new file mode 100644 index 0000000..47c731b --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestClassJobExecutor.java @@ -0,0 +1,20 @@ +package org.dromara.job.snailjob; + +import com.aizuda.snailjob.client.job.core.dto.JobArgs; +import com.aizuda.snailjob.client.job.core.executor.AbstractJobExecutor; +import com.aizuda.snailjob.client.model.ExecuteResult; +import org.springframework.stereotype.Component; + +/** + * 测试类 + * + * @author pengles + */ +@Component +public class TestClassJobExecutor extends AbstractJobExecutor { + + @Override + protected ExecuteResult doJobExecute(JobArgs jobArgs) { + return ExecuteResult.success("TestJobExecutor测试成功"); + } +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestMapJobAnnotation.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestMapJobAnnotation.java new file mode 100644 index 0000000..2f3eef5 --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestMapJobAnnotation.java @@ -0,0 +1,53 @@ +package org.dromara.job.snailjob; + +import cn.hutool.core.thread.ThreadUtil; +import cn.hutool.extra.spring.SpringUtil; +import com.aizuda.snailjob.client.job.core.MapHandler; +import com.aizuda.snailjob.client.job.core.annotation.JobExecutor; +import com.aizuda.snailjob.client.job.core.annotation.MapExecutor; +import com.aizuda.snailjob.client.job.core.dto.MapArgs; +import com.aizuda.snailjob.client.model.ExecuteResult; +import com.aizuda.snailjob.common.log.SnailJobLog; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * Map任务 动态分配 只分片不关注结果 + * + * + * @author pengles + */ +@Component +@JobExecutor(name = "testMapJobAnnotation") +public class TestMapJobAnnotation { + + @MapExecutor + public ExecuteResult doJobMapExecute(MapArgs mapArgs, MapHandler mapHandler) { + // 生成1~200数值并分片 + int partitionSize = 50; + List> partition = IntStream.rangeClosed(1, 200) + .boxed() + .collect(Collectors.groupingBy(i -> (i - 1) / partitionSize)) + .values() + .stream() + .toList(); + SnailJobLog.REMOTE.info("端口:{}完成分配任务", SpringUtil.getProperty("server.port")); + return mapHandler.doMap(partition, "doCalc"); + } + + @MapExecutor(taskName = "doCalc") + public ExecuteResult doCalc(MapArgs mapArgs) { + List sourceList = (List) mapArgs.getMapResult(); + // 遍历sourceList的每一个元素,计算出一个累加值partitionTotal + int partitionTotal = sourceList.stream().mapToInt(i -> i).sum(); + // 打印日志到服务器 + ThreadUtil.sleep(3, TimeUnit.SECONDS); + SnailJobLog.REMOTE.info("端口:{},partitionTotal:{}", SpringUtil.getProperty("server.port"), partitionTotal); + return ExecuteResult.success(partitionTotal); + } + +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestMapReduceAnnotation1.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestMapReduceAnnotation1.java new file mode 100644 index 0000000..e54ef0a --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestMapReduceAnnotation1.java @@ -0,0 +1,60 @@ +package org.dromara.job.snailjob; + +import cn.hutool.core.thread.ThreadUtil; +import cn.hutool.extra.spring.SpringUtil; +import com.aizuda.snailjob.client.job.core.MapHandler; +import com.aizuda.snailjob.client.job.core.annotation.JobExecutor; +import com.aizuda.snailjob.client.job.core.annotation.MapExecutor; +import com.aizuda.snailjob.client.job.core.annotation.ReduceExecutor; +import com.aizuda.snailjob.client.job.core.dto.MapArgs; +import com.aizuda.snailjob.client.job.core.dto.ReduceArgs; +import com.aizuda.snailjob.client.model.ExecuteResult; +import com.aizuda.snailjob.common.log.SnailJobLog; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * MapReduce任务 动态分配 分片后合并结果 + * + * + * @author pengles + */ +@Component +@JobExecutor(name = "testMapReduceAnnotation1") +public class TestMapReduceAnnotation1 { + + @MapExecutor + public ExecuteResult rootMapExecute(MapArgs mapArgs, MapHandler mapHandler) { + int partitionSize = 50; + List> partition = IntStream.rangeClosed(1, 200) + .boxed() + .collect(Collectors.groupingBy(i -> (i - 1) / partitionSize)) + .values() + .stream() + .toList(); + SnailJobLog.REMOTE.info("端口:{}完成分配任务", SpringUtil.getProperty("server.port")); + return mapHandler.doMap(partition, "doCalc"); + } + + @MapExecutor(taskName = "doCalc") + public ExecuteResult doCalc(MapArgs mapArgs) { + List sourceList = (List) mapArgs.getMapResult(); + // 遍历sourceList的每一个元素,计算出一个累加值partitionTotal + int partitionTotal = sourceList.stream().mapToInt(i -> i).sum(); + // 打印日志到服务器 + ThreadUtil.sleep(3, TimeUnit.SECONDS); + SnailJobLog.REMOTE.info("端口:{},partitionTotal:{}", SpringUtil.getProperty("server.port"), partitionTotal); + return ExecuteResult.success(partitionTotal); + } + + @ReduceExecutor + public ExecuteResult reduceExecute(ReduceArgs reduceArgs) { + int reduceTotal = reduceArgs.getMapResult().stream().mapToInt(i -> Integer.parseInt((String) i)).sum(); + SnailJobLog.REMOTE.info("端口:{},reduceTotal:{}", SpringUtil.getProperty("server.port"), reduceTotal); + return ExecuteResult.success(reduceTotal); + } +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestStaticShardingJob.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestStaticShardingJob.java new file mode 100644 index 0000000..b8fcf17 --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/TestStaticShardingJob.java @@ -0,0 +1,36 @@ +package org.dromara.job.snailjob; + +import com.aizuda.snailjob.client.job.core.annotation.JobExecutor; +import com.aizuda.snailjob.client.job.core.dto.JobArgs; +import com.aizuda.snailjob.client.model.ExecuteResult; +import com.aizuda.snailjob.common.log.SnailJobLog; +import org.springframework.stereotype.Component; + +/** + * 静态分片 根据服务端任务参数分片 + * + * + * @author pengles + */ +@Component +@JobExecutor(name = "testStaticShardingJob") +public class TestStaticShardingJob { + + public ExecuteResult jobExecute(JobArgs jobArgs) { + String jobParams = String.valueOf(jobArgs.getJobParams()); + SnailJobLog.LOCAL.info("开始执行分片任务,参数:{}", jobParams); + // 获得jobArgs 中传入的开始id和结束id + String[] split = jobParams.split(","); + Long fromId = Long.parseLong(split[0]); + Long toId = Long.parseLong(split[1]); + // 模拟数据库操作,对范围id,进行加密处理 + try { + SnailJobLog.REMOTE.info("开始对id范围:{}进行加密处理", fromId + "-" + toId); + Thread.sleep(3000); + SnailJobLog.REMOTE.info("对id范围:{}进行加密处理完成", fromId + "-" + toId); + } catch (InterruptedException e) { + return ExecuteResult.failure("任务执行失败"); + } + return ExecuteResult.success("执行分片任务完成"); + } +} diff --git a/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/WechatBillTask.java b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/WechatBillTask.java new file mode 100644 index 0000000..cd60e44 --- /dev/null +++ b/ruoyi-modules/ruoyi-job/src/main/java/org/dromara/job/snailjob/WechatBillTask.java @@ -0,0 +1,43 @@ +package org.dromara.job.snailjob; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.aizuda.snailjob.client.job.core.annotation.JobExecutor; +import com.aizuda.snailjob.client.job.core.dto.JobArgs; +import com.aizuda.snailjob.client.model.ExecuteResult; +import com.aizuda.snailjob.common.log.SnailJobLog; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.job.entity.BillDto; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +/** + * DAG工作流任务-模拟微信账单任务 + * + * + * @author pengles + */ +@Component +@JobExecutor(name = "wechatBillTask") +public class WechatBillTask { + + public ExecuteResult jobExecute(JobArgs jobArgs) throws InterruptedException { + BillDto billDto = new BillDto(); + billDto.setBillId(123456789L); + billDto.setBillChannel("wechat"); + // 从上下文中获得清算日期并设置,如果上下文中清算日期 + // 是sysdate设置为当前日期;否则取管理页面设置的值 + String settlementDate = (String) jobArgs.getWfContext().get("settlementDate"); + if (StrUtil.equals(settlementDate, "sysdate")) { + settlementDate = DateUtil.today(); + } + billDto.setBillDate(settlementDate); + billDto.setBillAmount(new BigDecimal("1234.56")); + // 把billDto对象放入上下文进行传递 + jobArgs.appendContext("wechat", JsonUtils.toJsonString(billDto)); + SnailJobLog.REMOTE.info("上下文: {}", jobArgs.getWfContext()); + return ExecuteResult.success(billDto); + } + +} diff --git a/ruoyi-modules/ruoyi-knowledge/README.md b/ruoyi-modules/ruoyi-knowledge/README.md new file mode 100644 index 0000000..ab1a1ac --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/README.md @@ -0,0 +1,1553 @@ +# RuoYi Knowledge 模块 + +## 概述 + +RuoYi Knowledge 模块是一个智能知识库管理系统,提供知识库的创建、管理、向量化处理和智能检索功能。该模块支持多种文档格式,具备强大的向量搜索能力,为AI问答系统提供知识基础。 + +## 目录结构 + +``` +ruoyi-knowledge/ +├─ src/main/java/org/dromara/knowledge/ +│ ├─ data/ # 文档解析与数据清洗(PDF/Word/Excel等) +│ ├─ embedding/ # 文本向量化引擎接口(多模型可插拔) +│ ├─ retrieval/ # 检索器与索引更新(支持多后端) +│ ├─ generation/ # 响应生成(LLM集成与后处理) +│ ├─ version/ # 版本管理与回滚接口 +│ └─ config/ # RAG 配置属性(rag.*) +├─ docs/ # API/设计/配置文档 +└─ src/test/java/org/dromara/knowledge/ + ├─ unit/ # 单元测试 + ├─ integration/ # 集成测试 + └─ mock/ # 测试用 Mock 数据 +``` + +## 核心特性 + +### 📚 知识库管理 + +- ✅ 知识库创建和配置(名称、描述、向量模型、分片参数等) +- ✅ 知识库列表查询(分页、筛选) +- ✅ 知识库信息更新和删除 +- ✅ 知识库状态查询 +- ✅ 用户知识库列表查询 +- ✅ 知识库公开/私有设置 + +### 📄 文档处理 + +- ✅ 文档上传(单文件、批量上传) +- ✅ 多格式文档支持(PDF、Word、TXT、Markdown、Excel、CSV、HTML等) +- ✅ 自动文档解析(通过DocumentParser接口,支持多种格式) +- ✅ 智能分片处理(可配置分片大小、重叠字符数) +- ✅ 文档解析状态跟踪(拆解图片状态、分析图片状态、向量化状态) +- ✅ 文档列表查询和管理 + +### 📑 知识片段管理 + +- ✅ 片段列表查询(分页、筛选) +- ✅ 片段信息查询和管理 +- ✅ 片段创建、更新、删除 +- ✅ 根据知识库/文档ID查询片段列表 + +### ⚡ 向量化处理 + +- ✅ 向量生成(单片段、批量生成) +- ✅ 多种嵌入模型支持(通过EmbeddingEngine接口,支持模型扩展) +- ✅ 文档批量向量化 +- ✅ 向量列表查询和管理 +- ✅ 向量索引重建 +- ✅ 向量统计信息查询 + +### 🔍 智能检索 + +- ✅ 基于向量的语义搜索(向量相似度搜索) +- ✅ 相似度排序 +- ✅ 检索结果过滤(按文档去重) +- ✅ 检索器接口可插拔(`Retriever`),默认实现调用向量服务 +- ✅ 检索命中日志记录和分析 + +### 📊 任务管理 + +- ✅ 任务创建和查询(支持多种任务类型:upload、process、delete、export、embedding、sync) +- ✅ 任务状态跟踪(等待、进行中、成功、失败、暂停) +- ✅ 任务进度管理(进度百分比、总项目数、已处理项目数等) +- ✅ 任务重试机制(重试次数、最大重试次数) +- ✅ 任务错误信息记录 +- ✅ 任务通知机制(通知URL、通知状态) + +### 💡 提示词模板管理 + +- ✅ 模板创建和配置 +- ✅ 模板列表查询(分页、筛选) +- ✅ 模板更新和删除 +- ✅ 按分类查询模板(knowledge、chat、draw) +- ✅ 按模板名称查询 + +### 🎯 数据模型管理 + +- ✅ 模型分组管理(分组创建、查询、更新、删除) +- ✅ 数据模型管理(模型创建、查询、更新、删除) +- ✅ 模型字段管理(字段创建、查询、更新、删除) +- ✅ 灵活的Schema定义(字段类型、属性配置) +- ✅ 字段类型支持(文本、数字、日期等) +- ✅ 字段属性配置(是否主键、是否必填、是否唯一、默认值、长度等) +- ✅ 字段显示配置(是否列表显示、是否查询字段、是否插入字段、是否编辑字段) +- ✅ 查询方式配置(EQ、NE、GT、LT、LIKE、BETWEEN) +- ✅ 显示类型配置(input、textarea、select、checkbox、radio、datetime、image、upload、editor) +- ✅ 字典类型支持 + +### 👥 角色与权限管理 + +- ✅ 角色组管理(Service层已实现) +- ✅ 角色管理(Service层已实现) +- ✅ 角色与知识库关联管理(Service层已实现) +- ⚠️ 角色管理Controller(待实现,目前只有Service层) + +### 📈 检索日志与分析 + +- ✅ 命中日志记录(记录每次检索的详细信息) +- ✅ 命中日志查询(按知识库、会话、片段、查询文本筛选) +- ✅ 热门查询统计 +- ✅ 平均响应时间分析 +- ✅ 日志导出功能 + +### 🔧 RAG架构组件 + +- ✅ 文档解析器接口(`DocumentParser`)及多种实现(PDF、Word、Excel、Text、Markdown、CSV、HTML) +- ✅ 向量化引擎接口(`EmbeddingEngine`)及HTTP实现(`HttpEmbeddingService`) +- ✅ 检索器接口(`Retriever`)及默认实现(`DefaultRetriever`) +- ✅ 索引更新器接口(`IndexUpdater`)及默认实现(`DefaultIndexUpdater`) +- ✅ 响应生成器接口(`Generator`)及默认实现(`DefaultGenerator`) +- ✅ 版本管理器接口(`VersionManager`)及默认实现(`DefaultVersionManager`) +- ✅ 向量存储服务接口(`VectorStoreService`)及多种实现(`DbVectorStoreService`、`QdrantVectorStoreService`、`WeaviateVectorStoreService`) + +## 架构设计 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Knowledge Controller Layer │ +├─────────────────────────────────────────────────────────────┤ +│ KnowledgeInfoController │ KnowledgeAttachController │ +│ KnowledgeFragmentController │ KnowledgeSchemaController │ +│ KnowledgeRoleController │ KnowledgeVectorController │ +└─────────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────────▼───────────────────────────────────────┐ +│ Service Layer │ +├─────────────────────────────────────────────────────────────┤ +│ KnowledgeInfoService │ KnowledgeAttachService │ +│ KnowledgeFragmentService │ KnowledgeSchemaService │ +│ KnowledgeVectorService │ KnowledgeRoleService │ +└─────────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────────▼───────────────────────────────────────┐ +│ Processing Layer │ +├─────────────────────────────────────────────────────────────┤ +│ Document Parser │ Vector Processor │ Search Engine │ +│ Embedding Models │ Index Manager │ Cache Manager │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 主要组件 + +### 控制器 (Controllers) + +#### KnowledgeInfoController + +知识库信息管理控制器,路径:`/knowledge/info` + +**主要接口:** + +- `GET /knowledge/info/list` - 查询知识库信息列表(分页,支持筛选) +- `POST /knowledge/info/export` - 导出知识库信息列表(需要权限 `knowledge:info:export`) +- `GET /knowledge/info/{knowledgeId}` - 获取知识库信息详细信息(需要权限 `knowledge:info:query`) +- `POST /knowledge/info` - 新增知识库信息(需要权限 `knowledge:info:add`) +- `PUT /knowledge/info` - 修改知识库信息(需要权限 `knowledge:info:edit`) +- `DELETE /knowledge/info/{knowledgeIds}` - 删除知识库信息(需要权限 `knowledge:info:remove`) +- `GET /knowledge/info/user/{userId}` - 根据用户ID查询知识库信息列表(需要权限 `knowledge:info:query`) +- `GET /knowledge/info/knowledge/{knowledgeId}` - 根据知识库ID查询知识库信息(需要权限 `knowledge:info:query`) +- `POST /knowledge/info/{knowledgeId}/upload` - 上传文档到知识库(需要权限 `knowledge:info:upload`) +- `POST /knowledge/info/{knowledgeId}/batch-upload` - 批量上传文档到知识库(需要权限 `knowledge:info:upload`) +- `POST /knowledge/info/{knowledgeId}/search` - 检索知识库(需要权限 `knowledge:info:search`) +- `GET /knowledge/info/{knowledgeId}/status` - 获取知识库处理状态(需要权限 `knowledge:info:query`) + +#### KnowledgeAttachController + +知识库附件管理控制器,路径:`/knowledge/attach` + +**主要接口:** + +- `GET /knowledge/attach/list` - 查询知识库附件列表(分页,需要权限 `knowledge:attach:list`) +- `POST /knowledge/attach/export` - 导出知识库附件列表(需要权限 `knowledge:attach:export`) +- `GET /knowledge/attach/{attachId}` - 获取知识库附件详细信息(需要权限 `knowledge:attach:query`) +- `POST /knowledge/attach` - 新增知识库附件(需要权限 `knowledge:attach:add`) +- `PUT /knowledge/attach` - 修改知识库附件(需要权限 `knowledge:attach:edit`) +- `DELETE /knowledge/attach/{attachIds}` - 删除知识库附件(需要权限 `knowledge:attach:remove`) +- `GET /knowledge/attach/knowledge/{knowledgeId}` - 根据知识库ID查询附件列表(需要权限 `knowledge:attach:query`) +- `GET /knowledge/attach/document/{documentId}` - 根据文档ID查询附件信息(需要权限 `knowledge:attach:query`) + +#### KnowledgeFragmentController + +知识库片段管理控制器,路径:`/knowledge/fragment` + +**主要接口:** + +- `GET /knowledge/fragment/list` - 查询知识库片段列表(分页,需要权限 `knowledge:fragment:list`) +- `POST /knowledge/fragment/export` - 导出知识库片段列表(需要权限 `knowledge:fragment:export`) +- `GET /knowledge/fragment/{fragmentId}` - 获取知识库片段详细信息(需要权限 `knowledge:fragment:query`) +- `POST /knowledge/fragment` - 新增知识库片段(需要权限 `knowledge:fragment:add`) +- `PUT /knowledge/fragment` - 修改知识库片段(需要权限 `knowledge:fragment:edit`) +- `DELETE /knowledge/fragment/{fragmentIds}` - 删除知识库片段(需要权限 `knowledge:fragment:remove`) +- `GET /knowledge/fragment/knowledge/{knowledgeId}` - 根据知识库ID查询片段列表(需要权限 `knowledge:fragment:query`) +- `GET /knowledge/fragment/document/{documentId}` - 根据文档ID查询片段列表(需要权限 `knowledge:fragment:query`) +- `GET /knowledge/fragment/fragment/{fragmentId}` - 根据片段ID查询片段信息(需要权限 `knowledge:fragment:query`) + +#### KnowledgeVectorController + +向量管理控制器,路径:`/knowledge/vector` + +**主要接口:** + +- `POST /knowledge/vector/generate` - 生成向量(批量生成片段向量) +- `POST /knowledge/vector/search` - 向量搜索(向量相似度搜索,支持按文档去重) +- `GET /knowledge/vector/stats/{knowledgeId}` - 向量统计(获取向量统计信息) +- `POST /knowledge/vector/rebuild/{knowledgeId}` - 重建索引(重建向量索引) +- `POST /knowledge/vector/vectorize-doc` - 文档批量向量化(对文档的所有片段进行向量化) +- `GET /knowledge/vector/list` - 查询知识库向量列表(分页,需要权限 `knowledge:vector:list`) +- `POST /knowledge/vector/export` - 导出知识库向量列表(需要权限 `knowledge:vector:export`) +- `GET /knowledge/vector/{vectorId}` - 获取知识库向量详细信息(需要权限 `knowledge:vector:query`) +- `POST /knowledge/vector` - 新增知识库向量(需要权限 `knowledge:vector:add`) +- `PUT /knowledge/vector` - 修改知识库向量(需要权限 `knowledge:vector:edit`) +- `DELETE /knowledge/vector/{vectorIds}` - 删除知识库向量(需要权限 `knowledge:vector:remove`) +- `GET /knowledge/vector/fragment/{fragmentId}` - 根据片段ID查询向量列表(需要权限 `knowledge:vector:query`) +- `GET /knowledge/vector/knowledge/{knowledgeId}` - 根据知识库ID查询向量列表(需要权限 `knowledge:vector:query`) + +#### KnowledgeHitLogController + +知识库命中日志控制器,路径:`/knowledge/hitlog` + +**主要接口:** + +- `GET /knowledge/hitlog/list` - 查询命中日志列表(分页,需要权限 `knowledge:hitlog:list`) +- `POST /knowledge/hitlog/export` - 导出命中日志列表(需要权限 `knowledge:hitlog:export`) +- `GET /knowledge/hitlog/{logId}` - 获取命中日志详细信息(需要权限 `knowledge:hitlog:query`) +- `POST /knowledge/hitlog` - 新增命中日志(需要权限 `knowledge:hitlog:add`) +- `PUT /knowledge/hitlog` - 修改命中日志(需要权限 `knowledge:hitlog:edit`) +- `DELETE /knowledge/hitlog/{logIds}` - 删除命中日志(需要权限 `knowledge:hitlog:remove`) +- `GET /knowledge/hitlog/knowledge/{knowledgeId}` - 根据知识库ID查询命中日志列表(需要权限 `knowledge:hitlog:query`) +- `GET /knowledge/hitlog/session/{sessionId}` - 根据会话ID查询命中日志列表(需要权限 `knowledge:hitlog:query`) +- `GET /knowledge/hitlog/fragment/{fragmentId}` - 根据片段ID查询命中日志列表(需要权限 `knowledge:hitlog:query`) +- `GET /knowledge/hitlog/query/{queryText}` - 根据查询文本查询命中日志列表(需要权限 `knowledge:hitlog:query`) +- `GET /knowledge/hitlog/popular` - 获取热门查询列表(需要权限 `knowledge:hitlog:query`,参数:`knowledgeId`(可选)、`limit`(默认10)) +- `GET /knowledge/hitlog/avg-response-time` - 获取平均响应时间(需要权限 `knowledge:hitlog:query`,参数:`knowledgeId`(可选)) + +#### KnowledgeJobController + +知识库任务管理控制器,路径:`/knowledge/job` + +**主要接口:** + +- `GET /knowledge/job/list` - 查询知识库任务列表(分页,需要权限 `knowledge:job:list`) +- `POST /knowledge/job/export` - 导出知识库任务列表(需要权限 `knowledge:job:export`) +- `GET /knowledge/job/{jobId}` - 获取知识库任务详细信息(需要权限 `knowledge:job:query`) +- `POST /knowledge/job` - 新增知识库任务(需要权限 `knowledge:job:add`) +- `PUT /knowledge/job` - 修改知识库任务(需要权限 `knowledge:job:edit`) +- `DELETE /knowledge/job/{jobIds}` - 删除知识库任务(需要权限 `knowledge:job:remove`) +- `GET /knowledge/job/knowledge/{knowledgeId}` - 根据知识库ID查询任务列表(需要权限 `knowledge:job:query`) +- `GET /knowledge/job/status/{status}` - 根据任务状态查询任务列表(需要权限 `knowledge:job:query`) +- `GET /knowledge/job/type/{jobType}` - 根据任务类型查询任务列表(需要权限 `knowledge:job:query`) + +#### KnowledgePromptTemplateController + +知识库提示词模板管理控制器,路径:`/knowledge/prompt` + +**主要接口:** + +- `GET /knowledge/prompt/list` - 查询知识库提示模板列表(分页,需要权限 `knowledge:prompt:list`) +- `POST /knowledge/prompt/export` - 导出知识库提示模板列表(需要权限 `knowledge:prompt:export`) +- `GET /knowledge/prompt/{templateId}` - 获取知识库提示模板详细信息(需要权限 `knowledge:prompt:query`) +- `POST /knowledge/prompt` - 新增知识库提示模板(需要权限 `knowledge:prompt:add`) +- `PUT /knowledge/prompt` - 修改知识库提示模板(需要权限 `knowledge:prompt:edit`) +- `DELETE /knowledge/prompt/{templateIds}` - 删除知识库提示模板(需要权限 `knowledge:prompt:remove`) +- `GET /knowledge/prompt/category/{category}` - 根据分类查询提示模板列表(需要权限 `knowledge:prompt:query`) +- `GET /knowledge/prompt/name/{templateName}` - 根据模板名称查询提示模板(需要权限 `knowledge:prompt:query`) + +#### KnowledgeSchemaController + +知识库数据模型管理控制器,路径:`/knowledge/schema` + +**主要接口:** + +- `GET /knowledge/schema/list` - 查询知识库模式列表(分页,需要权限 `knowledge:schema:list`) +- `POST /knowledge/schema/export` - 导出知识库模式列表(需要权限 `knowledge:schema:export`) +- `GET /knowledge/schema/{schemaId}` - 获取知识库模式详细信息(需要权限 `knowledge:schema:query`) +- `POST /knowledge/schema` - 新增知识库模式(需要权限 `knowledge:schema:add`) +- `PUT /knowledge/schema` - 修改知识库模式(需要权限 `knowledge:schema:edit`) +- `DELETE /knowledge/schema/{schemaIds}` - 删除知识库模式(需要权限 `knowledge:schema:remove`) +- `GET /knowledge/schema/id/{id}` - 根据ID查询模式信息(需要权限 `knowledge:schema:query`) +- `GET /knowledge/schema/group/{schemaGroupId}` - 根据模式组ID查询模式列表(需要权限 `knowledge:schema:query`) +- `GET /knowledge/schema/code/{code}` - 根据编码查询模式信息(需要权限 `knowledge:schema:query`) +- `GET /knowledge/schema/table/{tableName}` - 根据表名查询模式信息(需要权限 `knowledge:schema:query`) + +#### KnowledgeSchemaGroupController + +知识库数据模型分组管理控制器,路径:`/knowledge/schema/group` + +**主要接口:** + +- `GET /knowledge/schema/group/list` - 查询知识库模式组列表(分页,需要权限 `knowledge:schema:group:list`) +- `POST /knowledge/schema/group/export` - 导出知识库模式组列表(需要权限 `knowledge:schema:group:export`) +- `GET /knowledge/schema/group/{groupId}` - 获取知识库模式组详细信息(需要权限 `knowledge:schema:group:query`) +- `POST /knowledge/schema/group` - 新增知识库模式组(需要权限 `knowledge:schema:group:add`) +- `PUT /knowledge/schema/group` - 修改知识库模式组(需要权限 `knowledge:schema:group:edit`) +- `DELETE /knowledge/schema/group/{groupIds}` - 删除知识库模式组(需要权限 `knowledge:schema:group:remove`) +- `GET /knowledge/schema/group/id/{id}` - 根据ID查询模式组信息(需要权限 `knowledge:schema:group:query`) +- `GET /knowledge/schema/group/code/{code}` - 根据编码查询模式组信息(需要权限 `knowledge:schema:group:query`) +- `GET /knowledge/schema/group/name/{name}` - 根据名称查询模式组信息(需要权限 `knowledge:schema:group:query`) + +#### KnowledgeSchemaFieldController + +知识库数据模型字段管理控制器,路径:`/knowledge/schema/field` + +**主要接口:** + +- `GET /knowledge/schema/field/list` - 查询知识库模式字段列表(分页,需要权限 `knowledge:schema:field:list`) +- `POST /knowledge/schema/field/export` - 导出知识库模式字段列表(需要权限 `knowledge:schema:field:export`) +- `GET /knowledge/schema/field/{fieldId}` - 获取知识库模式字段详细信息(需要权限 `knowledge:schema:field:query`) +- `POST /knowledge/schema/field` - 新增知识库模式字段(需要权限 `knowledge:schema:field:add`) +- `PUT /knowledge/schema/field` - 修改知识库模式字段(需要权限 `knowledge:schema:field:edit`) +- `DELETE /knowledge/schema/field/{fieldIds}` - 删除知识库模式字段(需要权限 `knowledge:schema:field:remove`) +- `GET /knowledge/schema/field/id/{id}` - 根据ID查询字段信息(需要权限 `knowledge:schema:field:query`) +- `GET /knowledge/schema/field/schema/{schemaId}` - 根据模式ID查询字段列表(需要权限 `knowledge:schema:field:query`) +- `GET /knowledge/schema/field/code/{code}` - 根据编码查询字段信息(需要权限 `knowledge:schema:field:query`) +- `GET /knowledge/schema/field/schema/{schemaId}/code/{code}` - 根据模式ID和编码查询字段信息(需要权限 `knowledge:schema:field:query`) + +### 服务层 (Services) + +#### KnowledgeInfoService + +知识库信息服务,核心业务逻辑实现。 + +**主要方法:** + +```java +// 查询知识库列表 +TableDataInfo queryPageList(KnowledgeInfoBo bo, PageQuery pageQuery); +List queryList(KnowledgeInfoBo bo); + +// 查询知识库 +KnowledgeInfoVo queryById(Long id); +KnowledgeInfoVo queryByKnowledgeId(Long knowledgeId); + +// 创建/更新/删除知识库 +Boolean insertByBo(KnowledgeInfoBo bo); +Boolean updateByBo(KnowledgeInfoBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据用户ID查询知识库列表 +List queryListByUserId(Long userId); + +// 上传文档 +String uploadDocument(String knowledgeId, MultipartFile file); + +// 批量上传文档 +List batchUploadDocuments(String knowledgeId, MultipartFile[] files); + +// 搜索知识库 +List searchKnowledge(String knowledgeId, String query, Integer limit); + +// 获取知识库处理状态 +KnowledgeStatusVo getKnowledgeStatus(String knowledgeId); + +// 处理文档并生成知识片段 +String processDocument(String knowledgeId, String docId, String content); + +// 对知识片段进行向量化 +String vectorizeFragment(String knowledgeId, String fragmentId); +``` + +#### KnowledgeVectorService + +向量化服务,处理文档向量化和搜索。 + +**主要方法:** + +```java +// 查询向量列表 +TableDataInfo queryPageList(KnowledgeVectorBo bo, PageQuery pageQuery); +List queryList(KnowledgeVectorBo bo); + +// 查询向量 +KnowledgeVectorVo queryById(Long vectorId); + +// 创建/更新/删除向量 +Boolean insertByBo(KnowledgeVectorBo bo); +Boolean updateByBo(KnowledgeVectorBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 向量相似度搜索 +List vectorSimilaritySearch(String knowledgeId, String queryText, + String modelName, Integer topK, Double threshold); + +// 单片段向量生成 +String generateVector(String knowledgeId, String docId, String fragmentId, String content, String modelName); + +// 批量向量生成 +List batchGenerateVectors(String knowledgeId, String docId, + Map fragmentContents, String modelName); + +// 统计与可用性 +Map getVectorStatistics(String knowledgeId); +boolean isModelAvailable(String modelName); +List getAvailableModels(); + +// 根据片段ID/知识库ID/文档ID/模型名称/状态查询向量列表 +List queryByFragmentId(String fragmentId); +List queryByKnowledgeId(String knowledgeId); +List queryByDocId(String docId); +List queryByModelName(String modelName); +List queryByStatus(Integer status); +List queryByKnowledgeIdAndStatus(String knowledgeId, Integer status); + +// 根据向量ID查询向量 +KnowledgeVectorVo queryByVectorId(String vectorId); + +// 批量更新向量状态 +Boolean updateStatusByIds(Collection ids, Integer status); + +// 文本转向量和相似度计算 +double[] textToVector(String text, String modelName); +double calculateSimilarity(double[] vector1, double[] vector2); + +// 批量删除向量 +int deleteVectorsByKnowledgeId(String knowledgeId); +int deleteVectorsByDocId(String docId); +``` + +#### KnowledgeFragmentService + +知识片段管理服务。 + +**主要方法:** + +```java +// 查询知识片段列表 +TableDataInfo queryPageList(KnowledgeFragmentBo bo, PageQuery pageQuery); +List queryList(KnowledgeFragmentBo bo); + +// 查询知识片段 +KnowledgeFragmentVo queryById(Long fragmentId); +KnowledgeFragmentVo queryByFid(String fid); + +// 创建/更新/删除知识片段 +Boolean insertByBo(KnowledgeFragmentBo bo); +Boolean updateByBo(KnowledgeFragmentBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据知识库ID/文档ID查询片段列表 +List queryListByKnowledgeId(Long knowledgeId); +List queryListByDocId(String docId); +``` + +#### KnowledgeAttachService + +知识库附件管理服务。 + +**主要方法:** + +```java +// 查询附件列表 +TableDataInfo queryPageList(KnowledgeAttachBo bo, PageQuery pageQuery); +List queryList(KnowledgeAttachBo bo); + +// 查询附件 +KnowledgeAttachVo queryById(Long attachId); +KnowledgeAttachVo queryByDocId(String docId); + +// 创建/更新/删除附件 +Boolean insertByBo(KnowledgeAttachBo bo); +Boolean updateByBo(KnowledgeAttachBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据知识库ID查询附件列表 +List queryListByKnowledgeId(String knowledgeId); +``` + +#### KnowledgeDocumentService + +文档处理服务,处理文档上传、解析和分片。 + +**主要方法:** + +```java +// 上传文档并处理 +String uploadDocument(String knowledgeId, MultipartFile file); + +// 批量上传文档 +List batchUploadDocuments(String knowledgeId, MultipartFile[] files); + +// 分割文档为片段 +List splitDocumentIntoFragments(String knowledgeId, String docId, + String content, Integer chunkSize, Integer overlapSize); +``` + +#### KnowledgeJobService + +任务管理服务,处理知识库处理任务的创建、查询和状态管理。 + +**主要方法:** + +```java +// 查询任务列表 +TableDataInfo queryPageList(KnowledgeJobBo bo, PageQuery pageQuery); +List queryList(KnowledgeJobBo bo); + +// 查询任务 +KnowledgeJobVo queryById(Long jobId); + +// 创建/更新/删除任务 +Boolean insertByBo(KnowledgeJobBo bo); +Boolean updateByBo(KnowledgeJobBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据知识库ID/状态/类型查询任务 +List queryByKnowledgeId(String knowledgeId); +List queryByStatus(Integer status); +List queryByJobType(String jobType); +``` + +#### KnowledgePromptTemplateService + +提示词模板管理服务。 + +**主要方法:** + +```java +// 查询模板列表 +TableDataInfo queryPageList(KnowledgePromptTemplateBo bo, PageQuery pageQuery); +List queryList(KnowledgePromptTemplateBo bo); + +// 查询模板 +KnowledgePromptTemplateVo queryById(Long templateId); +KnowledgePromptTemplateVo queryByTemplateName(String templateName); + +// 创建/更新/删除模板 +Boolean insertByBo(KnowledgePromptTemplateBo bo); +Boolean updateByBo(KnowledgePromptTemplateBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据分类查询模板列表 +List queryListByCategory(String category); +``` + +#### KnowledgeSchemaService + +数据模型管理服务。 + +**主要方法:** + +```java +// 查询模型列表 +TableDataInfo queryPageList(KnowledgeSchemaBo bo, PageQuery pageQuery); +List queryList(KnowledgeSchemaBo bo); + +// 查询模型 +KnowledgeSchemaVo queryById(Long schemaId); +KnowledgeSchemaVo queryByCode(String code); +KnowledgeSchemaVo queryByTableName(String tableName); + +// 创建/更新/删除模型 +Boolean insertByBo(KnowledgeSchemaBo bo); +Boolean updateByBo(KnowledgeSchemaBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据分组ID查询模型列表 +List queryListBySchemaGroupId(Long schemaGroupId); +``` + +#### KnowledgeSchemaGroupService + +数据模型分组管理服务。 + +**主要方法:** + +```java +// 查询分组列表 +TableDataInfo queryPageList(KnowledgeSchemaGroupBo bo, PageQuery pageQuery); +List queryList(KnowledgeSchemaGroupBo bo); + +// 查询分组 +KnowledgeSchemaGroupVo queryById(Long groupId); +KnowledgeSchemaGroupVo queryByCode(String code); +KnowledgeSchemaGroupVo queryByName(String name); + +// 创建/更新/删除分组 +Boolean insertByBo(KnowledgeSchemaGroupBo bo); +Boolean updateByBo(KnowledgeSchemaGroupBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +``` + +#### KnowledgeSchemaFieldService + +数据模型字段管理服务。 + +**主要方法:** + +```java +// 查询字段列表 +TableDataInfo queryPageList(KnowledgeSchemaFieldBo bo, PageQuery pageQuery); +List queryList(KnowledgeSchemaFieldBo bo); + +// 查询字段 +KnowledgeSchemaFieldVo queryById(Long fieldId); +KnowledgeSchemaFieldVo queryByCode(String code); +KnowledgeSchemaFieldVo queryBySchemaIdAndCode(Long schemaId, String code); + +// 创建/更新/删除字段 +Boolean insertByBo(KnowledgeSchemaFieldBo bo); +Boolean updateByBo(KnowledgeSchemaFieldBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据模式ID查询字段列表 +List queryListBySchemaId(Long schemaId); +``` + +#### KnowledgeHitLogService + +命中日志管理服务。 + +**主要方法:** + +```java +// 查询日志列表 +TableDataInfo queryPageList(KnowledgeHitLogBo bo, PageQuery pageQuery); +List queryList(KnowledgeHitLogBo bo); + +// 查询日志 +KnowledgeHitLogVo queryById(Long logId); + +// 创建/更新/删除日志 +Boolean insertByBo(KnowledgeHitLogBo bo); +Boolean updateByBo(KnowledgeHitLogBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据知识库ID/会话ID/片段ID/查询文本查询日志 +List queryByKnowledgeId(String knowledgeId); +List queryBySessionId(String sessionId); +List queryByFragmentId(String fragmentId); +List queryByQueryText(String queryText); + +// 统计分析 +List> getPopularQueries(String knowledgeId, Integer limit); +Double getAverageResponseTime(String knowledgeId); +``` + +#### KnowledgeRoleService + +角色管理服务(Service层已实现,Controller层待实现)。 + +**主要方法:** + +```java +// 查询角色列表 +TableDataInfo queryPageList(KnowledgeRoleBo bo, PageQuery pageQuery); +List queryList(KnowledgeRoleBo bo); +List queryEnabledList(); // 查询启用状态的角色列表 + +// 查询角色 +KnowledgeRoleVo queryById(Long id); +KnowledgeRoleVo queryByRoleCode(String roleCode); + +// 创建/更新/删除角色 +Boolean insertByBo(KnowledgeRoleBo bo); +Boolean updateByBo(KnowledgeRoleBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +``` + +#### KnowledgeRoleGroupService + +角色组管理服务(Service层已实现,Controller层待实现)。 + +**主要方法:** + +```java +// 查询角色组列表 +TableDataInfo queryPageList(KnowledgeRoleGroupBo bo, PageQuery pageQuery); +List queryList(KnowledgeRoleGroupBo bo); + +// 查询角色组 +KnowledgeRoleGroupVo queryById(Long groupId); + +// 创建/更新/删除角色组 +Boolean insertByBo(KnowledgeRoleGroupBo bo); +Boolean updateByBo(KnowledgeRoleGroupBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +``` + +#### KnowledgeRoleRelationService + +角色关联管理服务(Service层已实现,Controller层待实现)。 + +**主要方法:** + +```java +// 查询关联列表 +TableDataInfo queryPageList(KnowledgeRoleRelationBo bo, PageQuery pageQuery); +List queryList(KnowledgeRoleRelationBo bo); + +// 查询关联 +KnowledgeRoleRelationVo queryById(Long id); +KnowledgeRoleRelationVo queryByRoleIdAndGroupId(Long roleId, Long groupId); + +// 创建/更新/删除关联 +Boolean insertByBo(KnowledgeRoleRelationBo bo); +Boolean updateByBo(KnowledgeRoleRelationBo bo); +Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +// 根据角色ID/分组ID查询关联列表 +List queryByRoleId(Long roleId); +List queryByGroupId(Long groupId); +``` + +## 数据库表结构 + +知识库模块包含以下数据表(详细SQL请参考 `script/sql/ry_knowledge.sql`): + +### 数据库表清单 + +1. `knowledge_info` - 知识库信息表 +2. `knowledge_attach` - 知识库附件表 +3. `knowledge_fragment` - 知识库片段表 +4. `knowledge_vector` - 知识片段向量表 +5. `knowledge_prompt_template` - 知识库提示词模板表 +6. `knowledge_schema_group` - 知识库数据模型分组表 +7. `knowledge_schema` - 知识库数据模型表 +8. `knowledge_schema_field` - 知识库数据模型字段表 +9. `knowledge_role` - 知识库角色表 +10. `knowledge_role_group` - 知识库角色组表 +11. `knowledge_role_relation` - 知识库角色与知识库关联表 +12. `knowledge_job` - 知识库任务进度表 +13. `knowledge_hit_log` - 知识检索命中日志表 + +### 主要数据表说明 + +#### 1. knowledge_info (知识库信息表) + +存储知识库的基本信息和配置。 + +**主要字段:** +- `id` - 主键ID +- `user_id` - 用户ID +- `knowledge_id` - 知识库ID(唯一标识) +- `knowledge_name` - 知识库名称 +- `share` - 是否公开知识库(0否,1是) +- `description` - 描述 +- `knowledge_separator` - 知识分隔符 +- `question_separator` - 提问分隔符 +- `overlap_char` - 重叠字符数 +- `retrieve_limit` - 知识库中检索的条数 +- `text_block_size` - 文本块大小 +- `vector_model_name` - 向量库 +- `embedding_model_name` - 向量模型 +- `system_prompt` - 系统提示词 +- `del_flag` - 删除标志(0存在,1删除) + +#### 2. knowledge_attach (知识库附件表) + +存储上传到知识库的文档文件信息。 + +**主要字段:** +- `id` - 主键ID +- `knowledge_id` - 知识库ID +- `doc_id` - 文档ID(唯一标识) +- `doc_name` - 文档名称 +- `doc_type` - 文档类型(pdf/docx/txt/md等) +- `oss_id` - 对象存储ID +- `pic_status` - 拆解图片状态(10未开始,20进行中,30已完成) +- `pic_anys_status` - 分析图片状态(10未开始,20进行中,30已完成) +- `vector_status` - 写入向量数据库状态(10未开始,20进行中,30已完成) +- `content` - 文档内容(长文本) + +#### 3. knowledge_fragment (知识库片段表) + +存储文档分割后的知识片段。 + +**主要字段:** +- `id` - 主键ID +- `knowledge_id` - 知识库ID +- `doc_id` - 文档ID +- `fid` - 知识片段ID(唯一标识) +- `idx` - 片段索引下标 +- `content` - 文档内容(文本) + +#### 4. knowledge_vector (知识片段向量表) + +存储知识片段的向量化数据。 + +**主要字段:** +- `id` - 主键ID +- `knowledge_id` - 知识库ID +- `doc_id` - 文档ID +- `fragment_id` - 知识片段ID +- `vector_id` - 向量唯一标识 +- `vector_data` - 向量数据(BLOB) +- `dimension` - 向量维度 +- `model_name` - 向量模型名称 +- `status` - 状态(0启用,1禁用) + +#### 5. knowledge_prompt_template (知识库提示词模板表) + +存储提示词模板信息。 + +**主要字段:** +- `id` - 主键ID +- `template_name` - 提示词模板名称 +- `template_content` - 提示词模板内容 +- `category` - 提示词分类(knowledge知识库类型,chat对话类型,draw绘画类型) +- `del_flag` - 删除标志(0存在,1删除) + +#### 6. knowledge_schema_group (知识库数据模型分组表) + +存储数据模型分组信息。 + +**主要字段:** +- `id` - 主键ID +- `name` - 分组名称 +- `code` - 分组编码(唯一) +- `icon` - 图标 +- `del_flag` - 删除标志(0存在,1删除) + +#### 7. knowledge_schema (知识库数据模型表) + +存储数据模型定义信息。 + +**主要字段:** +- `id` - 主键ID +- `schema_group_id` - 分组ID +- `name` - 模型名称 +- `code` - 模型编码(唯一) +- `table_name` - 表名(唯一) +- `del_flag` - 删除标志(0存在,1删除) + +#### 8. knowledge_schema_field (知识库数据模型字段表) + +存储数据模型的字段定义信息。 + +**主要字段:** +- `id` - 主键ID +- `schema_id` - 模型ID +- `schema_name` - 模型名称 +- `name` - 字段名称 +- `code` - 字段编码 +- `type` - 字段类型 +- `comment` - 字段注释 +- `is_pk` - 是否主键(0否,1是) +- `is_required` - 是否必填(0否,1是) +- `is_unique` - 是否唯一(0否,1是) +- `default_value` - 默认值 +- `length` - 字段长度 +- `scale` - 小数位数 +- `is_list` - 是否列表显示(0否,1是) +- `is_query` - 是否查询字段(0否,1是) +- `is_insert` - 是否插入字段(0否,1是) +- `is_edit` - 是否编辑字段(0否,1是) +- `query_type` - 查询方式(EQ,NE,GT,LT,LIKE,BETWEEN) +- `html_type` - 显示类型(input,textarea,select,checkbox,radio,datetime,image,upload,editor) +- `dict_type` - 字典类型 +- `sort` - 排序 +- `del_flag` - 删除标志(0存在,1删除) + +#### 9. knowledge_role (知识库角色表) + +存储知识库角色信息。 + +**主要字段:** +- `id` - 知识库角色id(主键) +- `name` - 知识库角色名称(注意:SQL建表语句中字段名为`name`,但Java实体类中通过MyBatis-Plus字段映射使用`roleName`、`roleCode`、`roleDesc`等字段,实际运行时通过字段映射或XML映射处理) +- `group_id` - 知识库角色组id +- `remark` - 备注 +- `del_flag` - 删除标志(0存在,1删除) +- `create_by` - 创建者 +- `create_time` - 创建时间 +- `update_by` - 更新者 +- `update_time` - 更新时间 + +**注意**: +1. SQL建表语句与Java实体类字段可能存在不一致(SQL中仅定义了`name`字段,但Java实体类中有`roleName`、`roleCode`、`roleDesc`、`status`、`sort`等字段),实际使用时会通过MyBatis-Plus的字段映射或XML映射处理。 +2. 角色管理功能的Service层已实现(`KnowledgeRoleService`、`KnowledgeRoleGroupService`、`KnowledgeRoleRelationService`),但Controller层尚未实现,可通过Service层调用或后续补充Controller。 + +#### 10. knowledge_role_group (知识库角色组表) + +存储知识库角色组信息。 + +**主要字段(基于SQL建表语句):** +- `id` - 知识库角色组id(主键) +- `name` - 知识库角色组名称(在Java实体类中映射为`groupName`) +- `remark` - 备注 +- `del_flag` - 删除标志(0存在,1删除) +- `create_by` - 创建者 +- `create_time` - 创建时间 +- `update_by` - 更新者 +- `update_time` - 更新时间 + +**注意**:SQL建表语句仅定义了基本字段,但Java实体类(`KnowledgeRoleGroup`)中还包含`groupCode`、`groupDesc`、`status`、`sort`等字段。这些字段可能在SQL中未定义或通过后续ALTER TABLE添加,实际使用时会通过MyBatis-Plus的字段映射或XML映射处理。 + +#### 11. knowledge_role_relation (知识库角色与知识库关联表) + +存储角色与知识库的关联关系。 + +**主要字段(基于SQL建表语句和XML映射):** +- `id` - 主键ID +- `knowledge_role_id` - 知识库角色id(在Java实体类中映射为`knowledgeRoleId`,通过XML映射) +- `knowledge_id` - 知识库id(在Java实体类中映射为`knowledgeId`,通过XML映射) +- `remark` - 备注 +- `del_flag` - 删除标志(0存在,1删除) +- `create_by` - 创建者 +- `create_time` - 创建时间 +- `update_by` - 更新者 +- `update_time` - 更新时间 +- 唯一索引:`uk_role_knowledge` (`knowledge_role_id`, `knowledge_id`) + +**注意**:SQL建表语句仅定义了基本字段(`knowledge_role_id`和`knowledge_id`),但Java实体类(`KnowledgeRoleRelation`)中还包含`roleId`、`groupId`、`relationType`、`status`、`sort`等字段。XML映射文件(`KnowledgeRoleRelationMapper.xml`)中只映射了`knowledgeRoleId`和`knowledgeId`字段,其他字段(`roleId`、`groupId`、`relationType`、`status`、`sort`)可能在SQL中未定义或通过后续ALTER TABLE添加,实际使用时会通过MyBatis-Plus的字段映射处理。 + +#### 12. knowledge_job (知识库任务进度表) + +存储知识库处理任务的进度和状态信息。 + +**主要字段:** +- `id` - 任务ID +- `job_id` - 任务唯一标识 +- `job_type` - 任务类型(upload,process,delete,export,embedding,sync) +- `job_sub_type` - 任务子类型 +- `knowledge_id` - 知识库ID +- `doc_id` - 文档ID +- `user_id` - 用户ID +- `status` - 任务状态(0等待,1进行中,2成功,3失败,4暂停) +- `progress` - 进度(0-100) +- `total_items` - 总项目数 +- `processed_items` - 已处理项目数 +- `success_items` - 成功项目数 +- `failed_items` - 失败项目数 +- `priority` - 优先级(1低,2中,3高) +- `retry_count` - 重试次数 +- `max_retry` - 最大重试次数 +- `start_time` - 开始时间 +- `end_time` - 结束时间 +- `timeout` - 超时时间(秒) +- `error_message` - 错误信息 +- `error_stack` - 错误堆栈 +- `result_data` - 结果数据(JSON格式) +- `notify_url` - 通知URL +- `notify_status` - 通知状态(0未通知,1已通知) + +#### 13. knowledge_hit_log (知识检索命中日志表) + +存储知识检索的命中日志,用于分析检索效果和热门查询。 + +**主要字段:** +- `id` - 主键ID +- `session_id` - 会话ID +- `knowledge_id` - 知识库ID +- `doc_id` - 文档ID +- `fragment_id` - 知识片段ID +- `query_text` - 查询文本 +- `query_vector` - 查询向量(BLOB) +- `similarity_score` - 相似度分数 +- `ranking` - 排名位置 +- `model_name` - 向量模型名称 +- `search_params` - 搜索参数(JSON格式) +- `response_time` - 响应时间(毫秒) +- `hit_count` - 命中次数 + +## 使用示例 + +### 1. 创建知识库 + +```bash +curl -X POST "http://localhost:8080/knowledge/info" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "knowledgeName": "技术文档库", + "description": "存储技术相关文档", + "share": 0, + "textBlockSize": 1000, + "overlapChar": 200, + "retrieveLimit": 5, + "embeddingModelName": "bge-large-zh", + "vectorModelName": "db" + }' +``` + +### 2. 上传文档 + +```bash +curl -X POST "http://localhost:8080/knowledge/info/kb123/upload" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "file=@document.pdf" +``` + +### 3. 批量上传文档 + +```bash +curl -X POST "http://localhost:8080/knowledge/info/kb123/batch-upload" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "files=@document1.pdf" \ + -F "files=@document2.docx" +``` + +### 4. 搜索知识库 + +```bash +curl -X POST "http://localhost:8080/knowledge/info/kb123/search?query=Spring Boot&limit=5" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 5. 向量搜索 + +```bash +curl -X POST "http://localhost:8080/knowledge/vector/search?knowledgeId=kb123&query=什么是微服务架构?&model=bge-large-zh&topK=5&threshold=0.7" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 6. 生成向量 + +```bash +curl -X POST "http://localhost:8080/knowledge/vector/generate?knowledgeId=kb123&docId=doc456&model=bge-large-zh" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '[ + { + "fid": "fragment1", + "content": "这是第一个片段的内容..." + }, + { + "fid": "fragment2", + "content": "这是第二个片段的内容..." + } + ]' +``` + +### 7. 文档批量向量化 + +```bash +curl -X POST "http://localhost:8080/knowledge/vector/vectorize-doc?knowledgeId=kb123&docId=doc456&model=bge-large-zh" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 8. 获取向量统计 + +```bash +curl -X GET "http://localhost:8080/knowledge/vector/stats/kb123" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 9. 重建向量索引 + +```bash +curl -X POST "http://localhost:8080/knowledge/vector/rebuild/kb123" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 10. 获取知识库状态 + +```bash +curl -X GET "http://localhost:8080/knowledge/info/kb123/status" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 11. 查询知识库列表 + +```bash +curl -X GET "http://localhost:8080/knowledge/info/list?pageNum=1&pageSize=10&knowledgeName=技术" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 12. 查询附件列表 + +```bash +curl -X GET "http://localhost:8080/knowledge/attach/list?knowledgeId=kb123&pageNum=1&pageSize=10" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 13. 查询片段列表 + +```bash +curl -X GET "http://localhost:8080/knowledge/fragment/list?knowledgeId=kb123&pageNum=1&pageSize=10" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 14. 查询任务列表 + +```bash +curl -X GET "http://localhost:8080/knowledge/job/list?knowledgeId=kb123&status=1&pageNum=1&pageSize=10" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 15. 获取热门查询列表 + +```bash +curl -X GET "http://localhost:8080/knowledge/hitlog/popular?knowledgeId=kb123&limit=10" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 16. 获取平均响应时间 + +```bash +curl -X GET "http://localhost:8080/knowledge/hitlog/avg-response-time?knowledgeId=kb123" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 17. 查询命中日志 + +```bash +curl -X GET "http://localhost:8080/knowledge/hitlog/list?knowledgeId=kb123&pageNum=1&pageSize=10" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 18. 创建提示词模板 + +```bash +curl -X POST "http://localhost:8080/knowledge/prompt" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "templateName": "技术问答模板", + "templateContent": "你是一个专业的技术助手,请基于以下知识回答问题:\n{knowledge}\n\n问题:{question}", + "category": "knowledge" + }' +``` + +### 19. 创建数据模型分组 + +```bash +curl -X POST "http://localhost:8080/knowledge/schema/group" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "name": "业务模型组", + "code": "business_models", + "icon": "icon-business" + }' +``` + +### 20. 创建数据模型 + +```bash +curl -X POST "http://localhost:8080/knowledge/schema" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "schemaGroupId": 1, + "name": "产品模型", + "code": "product_model", + "tableName": "product_data" + }' +``` + +## 配置说明 + +### 应用配置 + +```yaml +# 知识库模块配置 +knowledge: + # 默认嵌入模型 + default-embedding-model: text-embedding-ada-002 + # 默认分片大小 + default-chunk-size: 1000 + # 默认分片重叠 + default-chunk-overlap: 200 + # 支持的文件类型 + supported-file-types: + - pdf + - docx + - txt + - md + # 最大文件大小(MB) + max-file-size: 50 + # 向量搜索配置 + vector-search: + # 默认返回数量 + default-top-k: 5 + # 默认相似度阈值 + default-threshold: 0.7 + # 缓存过期时间(秒) + cache-expire-time: 3600 + +# RAG 配置(统一前缀 rag.*) +rag: + enabled: true + default-embedding-model: text-embedding + default-vector-backend: db # 可扩展 qdrant/weaviate 等 +``` + +### 文件存储配置 + +```yaml +# OSS配置 +oss: + # 知识库文件存储桶 + knowledge-bucket: knowledge-files + # 文件访问路径前缀 + file-url-prefix: /knowledge/files/ +``` + +## 支持的文档格式 + +### 文本格式 + +- **TXT**: 纯文本文件 +- **MD**: Markdown文档 +- **CSV**: 逗号分隔值文件 + +### 办公文档 + +- **PDF**: Adobe PDF文档 +- **DOCX**: Microsoft Word文档 +- **PPTX**: Microsoft PowerPoint文档 +- **XLSX**: Microsoft Excel文档 + +### 网页格式 + +- **HTML**: 网页文档 +- **XML**: 结构化文档 + +## 嵌入模型支持 + +### OpenAI模型 + +- `text-embedding-ada-002` +- `text-embedding-3-small` +- `text-embedding-3-large` + +### 其他模型 + +- `sentence-transformers/all-MiniLM-L6-v2` +- `sentence-transformers/all-mpnet-base-v2` +- `text2vec-chinese` + +## 开发指南 + +### 添加新的文档解析器 + +1. 实现 `DocumentParser` 接口(签名:`String parse(byte[] bytes, String filename)`) +2. 注册解析器到Spring容器 +3. 配置文件类型映射 +4. 添加单元测试 + +```java +@Component +public class CustomDocumentParser implements DocumentParser { + @Override + public String parse(byte[] bytes, String filename) { + // 实现解析逻辑并返回纯文本 + return new String(bytes); + } +} +``` + +### 集成新的嵌入模型 + +1. 实现 `EmbeddingEngine` 接口 +2. 配置模型参数 +3. 添加模型验证 +4. 更新配置文档 + +```java +@Component +public class CustomEmbeddingEngine implements EmbeddingEngine { + @Override + public double[] embed(String text, String model) { + // 实现嵌入逻辑 + return new double[]{0.1, 0.2, 0.3}; + } +} +``` + +### 自定义搜索策略 + +1. 实现 `Retriever` 接口 +2. 配置搜索权重 +3. 添加性能测试 +4. 文档化搜索算法 + +```java +@Component +public class HybridRetriever implements Retriever { + public List search(String knowledgeId, String query, String model, int topK, double threshold) { + // 实现混合搜索逻辑:关键词 + 向量 + return java.util.Collections.emptyList(); + } +} +``` + +### 增量索引与版本控制 + +- 索引更新:`IndexUpdater` 接口,默认实现 `DefaultIndexUpdater` 触发分片重建与任务追踪 +- 版本管理:`VersionManager` 接口,默认实现 `DefaultVersionManager`(创建版本与回滚占位) +- 推荐将版本信息与片段/向量表做快照/变更日志以支持精准回滚 + +## 性能优化 + +### 向量索引优化 + +1. **索引策略** + - 使用适当的向量索引算法(HNSW、IVF等) + - 定期重建索引 + - 增量索引更新 + +2. **内存管理** + - 向量数据分页加载 + - LRU缓存策略 + - 内存使用监控 + +### 搜索性能 + +1. **缓存策略** + - 热门查询结果缓存 + - 向量计算结果缓存 + - 分布式缓存支持 + +2. **并发处理** + - 异步向量化处理 + - 并行搜索执行 + - 线程池优化 + +### 存储优化 + +1. **数据压缩** + - 向量数据压缩 + - 文档内容压缩 + - 索引数据优化 + +2. **分库分表** + - 按知识库分表 + - 向量数据分片 + - 读写分离 + +## 监控和运维 + +### 关键指标 + +- 知识库数量和大小 +- 文档处理成功率 +- 向量化处理时间 +- 搜索响应时间 +- 搜索准确率 +- 存储使用量 + +### 日志配置 + +```yaml +logging: + level: + org.dromara.knowledge: DEBUG + org.dromara.knowledge.service.impl.KnowledgeVectorServiceImpl: INFO +``` + +### 健康检查 + +```bash +# 检查知识库状态 +curl -X GET "http://localhost:8080/knowledge/health" + +# 检查向量服务状态 +curl -X GET "http://localhost:8080/knowledge/vector/health" +``` + +## 故障排除 + +### 常见问题 + +1. **文档上传失败** + - 检查文件格式支持 + - 验证文件大小限制 + - 查看存储空间 + +2. **向量化处理失败** + - 检查嵌入模型配置 + - 验证API密钥 + - 查看网络连接 + +3. **搜索结果不准确** + - 调整相似度阈值 + - 优化分片策略 + - 重建向量索引 + +4. **性能问题** + - 检查索引状态 + - 优化查询条件 + - 增加缓存配置 + +### 数据恢复 + +1. **备份策略** + - 定期备份知识库数据 + - 向量索引备份 + - 配置文件备份 + +2. **恢复流程** + - 恢复数据库数据 + - 重建向量索引 + - 验证数据完整性 + +## API文档 + +完整的API文档可通过Swagger UI访问: +`http://localhost:8080/doc.html#/knowledge` + +## 功能清单 + +### 已实现功能 ✅ + +#### 知识库管理 +- ✅ 知识库创建和配置 +- ✅ 知识库列表查询(分页、筛选) +- ✅ 知识库信息更新和删除 +- ✅ 知识库状态查询 +- ✅ 用户知识库列表查询 + +#### 文档管理 +- ✅ 文档上传(单文件) +- ✅ 文档批量上传 +- ✅ 文档列表查询 +- ✅ 文档信息查询 +- ✅ 文档删除 +- ✅ 多格式文档支持(PDF、Word、TXT、Markdown、Excel、CSV、HTML等) +- ✅ 文档解析状态跟踪(拆解图片状态、分析图片状态、向量化状态) + +#### 知识片段管理 +- ✅ 片段列表查询(分页、筛选) +- ✅ 片段信息查询 +- ✅ 片段创建、更新、删除 +- ✅ 根据知识库ID查询片段列表 +- ✅ 根据文档ID查询片段列表 +- ✅ 片段内容管理 + +#### 向量化处理 +- ✅ 向量生成(单片段、批量) +- ✅ 向量搜索(相似度搜索) +- ✅ 向量列表查询 +- ✅ 向量统计 +- ✅ 向量索引重建 +- ✅ 文档批量向量化 +- ✅ 多向量模型支持 + +#### 智能检索 +- ✅ 基于向量的语义搜索 +- ✅ 相似度排序 +- ✅ 检索结果过滤 +- ✅ 检索命中日志记录 + +#### 任务管理 +- ✅ 任务创建和查询 +- ✅ 任务状态跟踪(等待、进行中、成功、失败、暂停) +- ✅ 任务进度管理 +- ✅ 任务重试机制 +- ✅ 根据知识库ID查询任务 +- ✅ 根据任务状态/类型查询任务 + +#### 提示词模板管理 +- ✅ 模板创建和配置 +- ✅ 模板列表查询 +- ✅ 模板更新和删除 +- ✅ 按分类查询模板 +- ✅ 按模板名称查询 + +#### 数据模型管理 +- ✅ 模型分组管理(分组创建、查询、更新、删除) +- ✅ 数据模型管理(模型创建、查询、更新、删除) +- ✅ 模型字段管理(字段创建、查询、更新、删除) +- ✅ 模型与分组的关联管理 +- ✅ 字段类型和属性配置 + +#### 角色与权限管理 +- ✅ 角色组管理(Service层已实现) +- ✅ 角色管理(Service层已实现) +- ✅ 角色与知识库关联管理(Service层已实现) + +#### 检索日志与分析 +- ✅ 命中日志记录 +- ✅ 命中日志查询(按知识库、会话、片段、查询文本) +- ✅ 热门查询统计 +- ✅ 平均响应时间分析 +- ✅ 日志导出 + +#### RAG架构组件 +- ✅ 文档解析器接口(DocumentParser)及多种实现 +- ✅ 向量化引擎接口(EmbeddingEngine)及HTTP实现 +- ✅ 检索器接口(Retriever)及默认实现 +- ✅ 索引更新器接口(IndexUpdater)及默认实现 +- ✅ 响应生成器接口(Generator)及默认实现 +- ✅ 版本管理器接口(VersionManager)及默认实现 +- ✅ 向量存储服务接口(VectorStoreService)及多种实现(DB、Qdrant、Weaviate) + +### 待实现功能 + +#### 文档处理增强 +- [ ] 文档版本管理(版本对比、回滚) +- [ ] 文档预览功能(PDF预览、图片预览) +- [ ] 文档在线编辑 +- [ ] 文档标签和分类管理 +- [ ] 文档批量操作(批量删除、批量导出) + +#### 检索功能增强 +- [ ] 关键词搜索(全文检索) +- [ ] 混合搜索策略(关键词+向量) +- [ ] 检索结果排序优化(多种排序策略) +- [ ] 检索结果高亮显示 +- [ ] 检索历史记录 + +#### 权限控制增强 +- [ ] 角色管理Controller(目前只有Service层) +- [ ] 知识库访问权限控制(读写权限、分享权限) +- [ ] 用户权限验证和拦截 +- [ ] 权限审计日志 + +#### 数据分析与统计 +- [ ] 知识库使用统计(文档数量、检索次数、用户数等) +- [ ] 检索效果分析(准确率、召回率) +- [ ] 热门知识库统计 +- [ ] 用户行为分析 + +#### 性能优化 +- [ ] 向量索引优化(HNSW、IVF等算法) +- [ ] 检索结果缓存 +- [ ] 批量处理优化 +- [ ] 异步任务队列优化 + +#### 集成功能 +- [ ] 与AI模块深度集成(RAG问答优化) +- [ ] 与聊天模块集成(知识库聊天增强) +- [ ] 知识库导入导出(批量导入、格式转换) + +## 版本历史 + +- **v1.0.0**: 初始版本,基础知识库功能 +- **v1.1.0**: 添加向量搜索支持 +- **v1.2.0**: 支持多种文档格式 +- **v1.3.0**: 增加批量处理功能 +- **v1.4.0**: 引入 RAG 模块化目录、解析/检索接口与增量索引/版本控制 +- **v1.5.0**: 完善命中日志功能,新增热门查询统计和平均响应时间分析 +- **v1.6.0**: 完善文档,补充完整的API接口列表、数据库表结构说明、Service层方法说明和使用示例(2025-01-09) + +## 贡献指南 + +1. Fork 项目 +2. 创建功能分支 (`git checkout -b feature/AmazingFeature`) +3. 提交代码变更 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 创建 Pull Request + +## 许可证 + +本项目采用 MIT 许可证,详见 LICENSE 文件。 + +## 技术支持 + +如有问题或建议,请通过以下方式联系: + +- 提交 Issue +- 发送邮件至技术支持 +- 参与社区讨论 diff --git a/ruoyi-modules/ruoyi-knowledge/pom.xml b/ruoyi-modules/ruoyi-knowledge/pom.xml new file mode 100644 index 0000000..39cfc31 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/pom.xml @@ -0,0 +1,134 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + + ruoyi-knowledge + + + knowledge知识库模块 + + + + + + com.squareup.okhttp3 + okhttp + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-translation + + + + + org.dromara + ruoyi-common-oss + + + + org.dromara + ruoyi-common-log + + + + + org.dromara + ruoyi-common-excel + + + + + org.dromara + ruoyi-common-sms + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-idempotent + + + + org.dromara + ruoyi-common-sensitive + + + + org.dromara + ruoyi-common-encrypt + + + + org.dromara + ruoyi-common-websocket + + + + org.dromara + ruoyi-common-sse + + + + + org.apache.poi + poi + + + + org.apache.poi + poi-ooxml + + + + org.apache.poi + poi-scratchpad + + + + + com.itextpdf + kernel + + + + com.itextpdf + layout + + + + + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/config/RagConfigProperties.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/config/RagConfigProperties.java new file mode 100644 index 0000000..30562e2 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/config/RagConfigProperties.java @@ -0,0 +1,35 @@ +package org.dromara.knowledge.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "rag") +public class RagConfigProperties { + private boolean enabled = true; + private String defaultEmbeddingModel = "text-embedding"; + private String defaultVectorBackend = "db"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDefaultEmbeddingModel() { + return defaultEmbeddingModel; + } + + public void setDefaultEmbeddingModel(String defaultEmbeddingModel) { + this.defaultEmbeddingModel = defaultEmbeddingModel; + } + + public String getDefaultVectorBackend() { + return defaultVectorBackend; + } + + public void setDefaultVectorBackend(String defaultVectorBackend) { + this.defaultVectorBackend = defaultVectorBackend; + } +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/config/RagConfiguration.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/config/RagConfiguration.java new file mode 100644 index 0000000..5dec6a5 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/config/RagConfiguration.java @@ -0,0 +1,10 @@ +package org.dromara.knowledge.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(RagConfigProperties.class) +public class RagConfiguration { +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeAttachController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeAttachController.java new file mode 100644 index 0000000..57cc6ca --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeAttachController.java @@ -0,0 +1,118 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeAttachBo; +import org.dromara.knowledge.domain.vo.KnowledgeAttachVo; +import org.dromara.knowledge.service.KnowledgeAttachService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库附件控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/attach") +public class KnowledgeAttachController extends BaseController { + + private final KnowledgeAttachService knowledgeAttachService; + + /** + * 查询知识库附件列表 + */ + @SaCheckPermission("knowledge:attach:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeAttachBo bo, PageQuery pageQuery) { + return knowledgeAttachService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库附件列表 + */ + @SaCheckPermission("knowledge:attach:export") + @Log(title = "知识库附件", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeAttachBo bo, HttpServletResponse response) { + List list = knowledgeAttachService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库附件数据", KnowledgeAttachVo.class, response); + } + + /** + * 获取知识库附件详细信息 + * + * @param attachId 附件ID + */ + @SaCheckPermission("knowledge:attach:query") + @GetMapping("/{attachId}") + public R getInfo(@PathVariable Long attachId) { + return R.ok(knowledgeAttachService.queryById(attachId)); + } + + /** + * 新增知识库附件 + */ + @SaCheckPermission("knowledge:attach:add") + @Log(title = "知识库附件", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeAttachBo bo) { + return toAjax(knowledgeAttachService.insertByBo(bo)); + } + + /** + * 修改知识库附件 + */ + @SaCheckPermission("knowledge:attach:edit") + @Log(title = "知识库附件", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeAttachBo bo) { + return toAjax(knowledgeAttachService.updateByBo(bo)); + } + + /** + * 删除知识库附件 + * + * @param attachIds 附件ID串 + */ + @SaCheckPermission("knowledge:attach:remove") + @Log(title = "知识库附件", businessType = BusinessType.DELETE) + @DeleteMapping("/{attachIds}") + public R remove(@PathVariable Long[] attachIds) { + return toAjax(knowledgeAttachService.deleteWithValidByIds(List.of(attachIds), true)); + } + + /** + * 根据知识库ID查询附件列表 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:attach:query") + @GetMapping("/knowledge/{knowledgeId}") + public R> getByKnowledgeId(@PathVariable String knowledgeId) { + return R.ok(knowledgeAttachService.queryListByKnowledgeId(knowledgeId)); + } + + /** + * 根据文档ID查询附件信息 + * + * @param documentId 文档ID + */ + @SaCheckPermission("knowledge:attach:query") + @GetMapping("/document/{documentId}") + public R getByDocumentId(@PathVariable String documentId) { + return R.ok(knowledgeAttachService.queryByDocId(documentId)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeFragmentController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeFragmentController.java new file mode 100644 index 0000000..18355f4 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeFragmentController.java @@ -0,0 +1,129 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeFragmentBo; +import org.dromara.knowledge.domain.vo.KnowledgeFragmentVo; +import org.dromara.knowledge.service.KnowledgeFragmentService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库片段控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/fragment") +public class KnowledgeFragmentController extends BaseController { + + private final KnowledgeFragmentService knowledgeFragmentService; + + /** + * 查询知识库片段列表 + */ + @SaCheckPermission("knowledge:fragment:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeFragmentBo bo, PageQuery pageQuery) { + return knowledgeFragmentService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库片段列表 + */ + @SaCheckPermission("knowledge:fragment:export") + @Log(title = "知识库片段", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeFragmentBo bo, HttpServletResponse response) { + List list = knowledgeFragmentService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库片段数据", KnowledgeFragmentVo.class, response); + } + + /** + * 获取知识库片段详细信息 + * + * @param fragmentId 片段ID + */ + @SaCheckPermission("knowledge:fragment:query") + @GetMapping("/{fragmentId}") + public R getInfo(@PathVariable Long fragmentId) { + return R.ok(knowledgeFragmentService.queryById(fragmentId)); + } + + /** + * 新增知识库片段 + */ + @SaCheckPermission("knowledge:fragment:add") + @Log(title = "知识库片段", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeFragmentBo bo) { + return toAjax(knowledgeFragmentService.insertByBo(bo)); + } + + /** + * 修改知识库片段 + */ + @SaCheckPermission("knowledge:fragment:edit") + @Log(title = "知识库片段", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeFragmentBo bo) { + return toAjax(knowledgeFragmentService.updateByBo(bo)); + } + + /** + * 删除知识库片段 + * + * @param fragmentIds 片段ID串 + */ + @SaCheckPermission("knowledge:fragment:remove") + @Log(title = "知识库片段", businessType = BusinessType.DELETE) + @DeleteMapping("/{fragmentIds}") + public R remove(@PathVariable Long[] fragmentIds) { + return toAjax(knowledgeFragmentService.deleteWithValidByIds(List.of(fragmentIds), true)); + } + + /** + * 根据知识库ID查询片段列表 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:fragment:query") + @GetMapping("/knowledge/{knowledgeId}") + public R> getByKnowledgeId(@PathVariable Long knowledgeId) { + return R.ok(knowledgeFragmentService.queryListByKnowledgeId(knowledgeId)); + } + + /** + * 根据文档ID查询片段列表 + * + * @param documentId 文档ID + */ + @SaCheckPermission("knowledge:fragment:query") + @GetMapping("/document/{documentId}") + public R> getByDocumentId(@PathVariable String documentId) { + return R.ok(knowledgeFragmentService.queryListByDocId(documentId)); + } + + /** + * 根据片段ID查询片段信息 + * + * @param fragmentId 片段ID + */ + @SaCheckPermission("knowledge:fragment:query") + @GetMapping("/fragment/{fragmentId}") + public R getByFragmentId(@PathVariable String fragmentId) { + return R.ok(knowledgeFragmentService.queryByFid(fragmentId)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeHitLogController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeHitLogController.java new file mode 100644 index 0000000..e854110 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeHitLogController.java @@ -0,0 +1,165 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeHitLogBo; +import org.dromara.knowledge.domain.vo.KnowledgeHitLogVo; +import org.dromara.knowledge.service.KnowledgeHitLogService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库命中日志控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/hitlog") +public class KnowledgeHitLogController extends BaseController { + + private final KnowledgeHitLogService knowledgeHitLogService; + + /** + * 查询知识库命中日志列表 + */ + @SaCheckPermission("knowledge:hitlog:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeHitLogBo bo, PageQuery pageQuery) { + return knowledgeHitLogService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库命中日志列表 + */ + @SaCheckPermission("knowledge:hitlog:export") + @Log(title = "知识库命中日志", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeHitLogBo bo, HttpServletResponse response) { + List list = knowledgeHitLogService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库命中日志", KnowledgeHitLogVo.class, response); + } + + /** + * 获取知识库命中日志详细信息 + * + * @param logId 日志ID + */ + @SaCheckPermission("knowledge:hitlog:query") + @GetMapping("/{logId}") + public R getInfo(@PathVariable Long logId) { + return R.ok(knowledgeHitLogService.queryById(logId)); + } + + /** + * 新增知识库命中日志 + */ + @SaCheckPermission("knowledge:hitlog:add") + @Log(title = "知识库命中日志", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeHitLogBo bo) { + return toAjax(knowledgeHitLogService.insertByBo(bo)); + } + + /** + * 修改知识库命中日志 + */ + @SaCheckPermission("knowledge:hitlog:edit") + @Log(title = "知识库命中日志", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeHitLogBo bo) { + return toAjax(knowledgeHitLogService.updateByBo(bo)); + } + + /** + * 删除知识库命中日志 + * + * @param logIds 日志ID数组 + */ + @SaCheckPermission("knowledge:hitlog:remove") + @Log(title = "知识库命中日志", businessType = BusinessType.DELETE) + @DeleteMapping("/{logIds}") + public R remove(@PathVariable Long[] logIds) { + return toAjax(knowledgeHitLogService.deleteWithValidByIds(List.of(logIds), true)); + } + + /** + * 根据知识库ID查询命中日志列表 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:hitlog:query") + @GetMapping("/knowledge/{knowledgeId}") + public R> getByKnowledgeId(@PathVariable String knowledgeId) { + return R.ok(knowledgeHitLogService.queryByKnowledgeId(knowledgeId)); + } + + /** + * 根据会话ID查询命中日志列表 + * + * @param sessionId 会话ID + */ + @SaCheckPermission("knowledge:hitlog:query") + @GetMapping("/session/{sessionId}") + public R> getBySessionId(@PathVariable String sessionId) { + return R.ok(knowledgeHitLogService.queryBySessionId(sessionId)); + } + + /** + * 根据片段ID查询命中日志列表 + * + * @param fragmentId 片段ID + */ + @SaCheckPermission("knowledge:hitlog:query") + @GetMapping("/fragment/{fragmentId}") + public R> getByFragmentId(@PathVariable String fragmentId) { + return R.ok(knowledgeHitLogService.queryByFragmentId(fragmentId)); + } + + /** + * 根据查询文本查询命中日志列表 + * + * @param queryText 查询文本 + */ + @SaCheckPermission("knowledge:hitlog:query") + @GetMapping("/query/{queryText}") + public R> getByQueryText(@PathVariable String queryText) { + return R.ok(knowledgeHitLogService.queryByQueryText(queryText)); + } + + /** + * 获取热门查询列表 + * + * @param knowledgeId 知识库ID + * @param limit 限制数量 + */ + @SaCheckPermission("knowledge:hitlog:query") + @GetMapping("/popular") + public R>> getPopularQueries( + @RequestParam(required = false) String knowledgeId, + @RequestParam(defaultValue = "10") Integer limit) { + return R.ok(knowledgeHitLogService.getPopularQueries(knowledgeId, limit)); + } + + /** + * 获取平均响应时间 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:hitlog:query") + @GetMapping("/avg-response-time") + public R getAverageResponseTime(@RequestParam(required = false) String knowledgeId) { + return R.ok(knowledgeHitLogService.getAverageResponseTime(knowledgeId)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeInfoController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeInfoController.java new file mode 100644 index 0000000..2cad463 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeInfoController.java @@ -0,0 +1,175 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeInfoBo; +import org.dromara.knowledge.domain.vo.KnowledgeInfoVo; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.domain.vo.KnowledgeStatusVo; +import org.dromara.knowledge.service.KnowledgeInfoService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 知识库信息控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/info") +public class KnowledgeInfoController extends BaseController { + + private final KnowledgeInfoService knowledgeInfoService; + + /** + * 查询知识库信息列表 + */ + @SaCheckPermission("knowledge:info:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeInfoBo bo, PageQuery pageQuery) { + return knowledgeInfoService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库信息列表 + */ + @SaCheckPermission("knowledge:info:export") + @Log(title = "知识库信息", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeInfoBo bo, HttpServletResponse response) { + List list = knowledgeInfoService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库信息数据", KnowledgeInfoVo.class, response); + } + + /** + * 获取知识库信息详细信息 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:info:query") + @GetMapping("/{knowledgeId}") + public R getInfo(@PathVariable Long knowledgeId) { + return R.ok(knowledgeInfoService.queryById(knowledgeId)); + } + + /** + * 新增知识库信息 + */ + @SaCheckPermission("knowledge:info:add") + @Log(title = "知识库信息", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeInfoBo bo) { + return toAjax(knowledgeInfoService.insertByBo(bo)); + } + + /** + * 修改知识库信息 + */ + @SaCheckPermission("knowledge:info:edit") + @Log(title = "知识库信息", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeInfoBo bo) { + return toAjax(knowledgeInfoService.updateByBo(bo)); + } + + /** + * 删除知识库信息 + * + * @param knowledgeIds 知识库ID串 + */ + @SaCheckPermission("knowledge:info:remove") + @Log(title = "知识库信息", businessType = BusinessType.DELETE) + @DeleteMapping("/{knowledgeIds}") + public R remove(@PathVariable Long[] knowledgeIds) { + return toAjax(knowledgeInfoService.deleteWithValidByIds(List.of(knowledgeIds), true)); + } + + /** + * 根据用户ID查询知识库信息列表 + * + * @param userId 用户ID + */ + @SaCheckPermission("knowledge:info:query") + @GetMapping("/user/{userId}") + public R> getByUserId(@PathVariable Long userId) { + return R.ok(knowledgeInfoService.queryListByUserId(userId)); + } + + /** + * 根据知识库ID查询知识库信息 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:info:query") + @GetMapping("/knowledge/{knowledgeId}") + public R getByKnowledgeId(@PathVariable Long knowledgeId) { + return R.ok(knowledgeInfoService.queryByKnowledgeId(knowledgeId)); + } + + /** + * 上传文档到知识库 + * + * @param knowledgeId 知识库ID + * @param file 上传的文件 + */ + @SaCheckPermission("knowledge:info:upload") + @Log(title = "知识库文档上传", businessType = BusinessType.INSERT) + @PostMapping("/{knowledgeId}/upload") + public R uploadDocument(@PathVariable String knowledgeId, + @RequestParam("file") MultipartFile file) { + return R.ok(knowledgeInfoService.uploadDocument(knowledgeId, file)); + } + + /** + * 批量上传文档到知识库 + * + * @param knowledgeId 知识库ID + * @param files 上传的文件列表 + */ + @SaCheckPermission("knowledge:info:upload") + @Log(title = "知识库批量文档上传", businessType = BusinessType.INSERT) + @PostMapping("/{knowledgeId}/batch-upload") + public R> batchUploadDocuments(@PathVariable String knowledgeId, + @RequestParam("files") MultipartFile[] files) { + return R.ok(knowledgeInfoService.batchUploadDocuments(knowledgeId, files)); + } + + /** + * 检索知识库 + * + * @param knowledgeId 知识库ID + * @param query 查询文本 + * @param limit 返回结果数量限制 + */ + @SaCheckPermission("knowledge:info:search") + @PostMapping("/{knowledgeId}/search") + public R> searchKnowledge(@PathVariable String knowledgeId, + @RequestParam String query, + @RequestParam(defaultValue = "10") Integer limit) { + return R.ok(knowledgeInfoService.searchKnowledge(knowledgeId, query, limit)); + } + + /** + * 获取知识库处理状态 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:info:query") + @GetMapping("/{knowledgeId}/status") + public R getKnowledgeStatus(@PathVariable String knowledgeId) { + return R.ok(knowledgeInfoService.getKnowledgeStatus(knowledgeId)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeJobController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeJobController.java new file mode 100644 index 0000000..f9b7d26 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeJobController.java @@ -0,0 +1,129 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeJobBo; +import org.dromara.knowledge.domain.vo.KnowledgeJobVo; +import org.dromara.knowledge.service.KnowledgeJobService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库任务控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/job") +public class KnowledgeJobController extends BaseController { + + private final KnowledgeJobService knowledgeJobService; + + /** + * 查询知识库任务列表 + */ + @SaCheckPermission("knowledge:job:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeJobBo bo, PageQuery pageQuery) { + return knowledgeJobService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库任务列表 + */ + @SaCheckPermission("knowledge:job:export") + @Log(title = "知识库任务", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeJobBo bo, HttpServletResponse response) { + List list = knowledgeJobService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库任务", KnowledgeJobVo.class, response); + } + + /** + * 获取知识库任务详细信息 + * + * @param jobId 任务ID + */ + @SaCheckPermission("knowledge:job:query") + @GetMapping("/{jobId}") + public R getInfo(@PathVariable Long jobId) { + return R.ok(knowledgeJobService.queryById(jobId)); + } + + /** + * 新增知识库任务 + */ + @SaCheckPermission("knowledge:job:add") + @Log(title = "知识库任务", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeJobBo bo) { + return toAjax(knowledgeJobService.insertByBo(bo)); + } + + /** + * 修改知识库任务 + */ + @SaCheckPermission("knowledge:job:edit") + @Log(title = "知识库任务", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeJobBo bo) { + return toAjax(knowledgeJobService.updateByBo(bo)); + } + + /** + * 删除知识库任务 + * + * @param jobIds 任务ID数组 + */ + @SaCheckPermission("knowledge:job:remove") + @Log(title = "知识库任务", businessType = BusinessType.DELETE) + @DeleteMapping("/{jobIds}") + public R remove(@PathVariable Long[] jobIds) { + return toAjax(knowledgeJobService.deleteWithValidByIds(List.of(jobIds), true)); + } + + /** + * 根据知识库ID查询任务列表 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:job:query") + @GetMapping("/knowledge/{knowledgeId}") + public R> getByKnowledgeId(@PathVariable String knowledgeId) { + return R.ok(knowledgeJobService.queryByKnowledgeId(knowledgeId)); + } + + /** + * 根据任务状态查询任务列表 + * + * @param status 任务状态 + */ + @SaCheckPermission("knowledge:job:query") + @GetMapping("/status/{status}") + public R> getByStatus(@PathVariable Integer status) { + return R.ok(knowledgeJobService.queryByStatus(status)); + } + + /** + * 根据任务类型查询任务列表 + * + * @param jobType 任务类型 + */ + @SaCheckPermission("knowledge:job:query") + @GetMapping("/type/{jobType}") + public R> getByJobType(@PathVariable String jobType) { + return R.ok(knowledgeJobService.queryByJobType(jobType)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgePromptTemplateController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgePromptTemplateController.java new file mode 100644 index 0000000..97daf1f --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgePromptTemplateController.java @@ -0,0 +1,118 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgePromptTemplateBo; +import org.dromara.knowledge.domain.vo.KnowledgePromptTemplateVo; +import org.dromara.knowledge.service.KnowledgePromptTemplateService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库提示模板控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/prompt") +public class KnowledgePromptTemplateController extends BaseController { + + private final KnowledgePromptTemplateService knowledgePromptTemplateService; + + /** + * 查询知识库提示模板列表 + */ + @SaCheckPermission("knowledge:prompt:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgePromptTemplateBo bo, PageQuery pageQuery) { + return knowledgePromptTemplateService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库提示模板列表 + */ + @SaCheckPermission("knowledge:prompt:export") + @Log(title = "知识库提示模板", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgePromptTemplateBo bo, HttpServletResponse response) { + List list = knowledgePromptTemplateService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库提示模板数据", KnowledgePromptTemplateVo.class, response); + } + + /** + * 获取知识库提示模板详细信息 + * + * @param templateId 模板ID + */ + @SaCheckPermission("knowledge:prompt:query") + @GetMapping("/{templateId}") + public R getInfo(@PathVariable Long templateId) { + return R.ok(knowledgePromptTemplateService.queryById(templateId)); + } + + /** + * 新增知识库提示模板 + */ + @SaCheckPermission("knowledge:prompt:add") + @Log(title = "知识库提示模板", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgePromptTemplateBo bo) { + return toAjax(knowledgePromptTemplateService.insertByBo(bo)); + } + + /** + * 修改知识库提示模板 + */ + @SaCheckPermission("knowledge:prompt:edit") + @Log(title = "知识库提示模板", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgePromptTemplateBo bo) { + return toAjax(knowledgePromptTemplateService.updateByBo(bo)); + } + + /** + * 删除知识库提示模板 + * + * @param templateIds 模板ID串 + */ + @SaCheckPermission("knowledge:prompt:remove") + @Log(title = "知识库提示模板", businessType = BusinessType.DELETE) + @DeleteMapping("/{templateIds}") + public R remove(@PathVariable Long[] templateIds) { + return toAjax(knowledgePromptTemplateService.deleteWithValidByIds(List.of(templateIds), true)); + } + + /** + * 根据分类查询提示模板列表 + * + * @param category 分类 + */ + @SaCheckPermission("knowledge:prompt:query") + @GetMapping("/category/{category}") + public R> getByCategory(@PathVariable String category) { + return R.ok(knowledgePromptTemplateService.queryListByCategory(category)); + } + + /** + * 根据模板名称查询提示模板 + * + * @param templateName 模板名称 + */ + @SaCheckPermission("knowledge:prompt:query") + @GetMapping("/name/{templateName}") + public R getByTemplateName(@PathVariable String templateName) { + return R.ok(knowledgePromptTemplateService.queryByTemplateName(templateName)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaController.java new file mode 100644 index 0000000..c043b06 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaController.java @@ -0,0 +1,140 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaVo; +import org.dromara.knowledge.service.KnowledgeSchemaService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库模式控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/schema") +public class KnowledgeSchemaController extends BaseController { + + private final KnowledgeSchemaService knowledgeSchemaService; + + /** + * 查询知识库模式列表 + */ + @SaCheckPermission("knowledge:schema:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeSchemaBo bo, PageQuery pageQuery) { + return knowledgeSchemaService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库模式列表 + */ + @SaCheckPermission("knowledge:schema:export") + @Log(title = "知识库模式", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeSchemaBo bo, HttpServletResponse response) { + List list = knowledgeSchemaService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库模式数据", KnowledgeSchemaVo.class, response); + } + + /** + * 获取知识库模式详细信息 + * + * @param schemaId 模式ID + */ + @SaCheckPermission("knowledge:schema:query") + @GetMapping("/{schemaId}") + public R getInfo(@PathVariable Long schemaId) { + return R.ok(knowledgeSchemaService.queryById(schemaId)); + } + + /** + * 新增知识库模式 + */ + @SaCheckPermission("knowledge:schema:add") + @Log(title = "知识库模式", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeSchemaBo bo) { + return toAjax(knowledgeSchemaService.insertByBo(bo)); + } + + /** + * 修改知识库模式 + */ + @SaCheckPermission("knowledge:schema:edit") + @Log(title = "知识库模式", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeSchemaBo bo) { + return toAjax(knowledgeSchemaService.updateByBo(bo)); + } + + /** + * 删除知识库模式 + * + * @param schemaIds 模式ID串 + */ + @SaCheckPermission("knowledge:schema:remove") + @Log(title = "知识库模式", businessType = BusinessType.DELETE) + @DeleteMapping("/{schemaIds}") + public R remove(@PathVariable Long[] schemaIds) { + return toAjax(knowledgeSchemaService.deleteWithValidByIds(List.of(schemaIds), true)); + } + + /** + * 根据ID查询模式信息 + * + * @param id ID + */ + @SaCheckPermission("knowledge:schema:query") + @GetMapping("/id/{id}") + public R getById(@PathVariable Long id) { + return R.ok(knowledgeSchemaService.queryById(id)); + } + + /** + * 根据模式组ID查询模式列表 + * + * @param schemaGroupId 模式组ID + */ + @SaCheckPermission("knowledge:schema:query") + @GetMapping("/group/{schemaGroupId}") + public R> getBySchemaGroupId(@PathVariable Long schemaGroupId) { + return R.ok(knowledgeSchemaService.queryListBySchemaGroupId(schemaGroupId)); + } + + /** + * 根据编码查询模式信息 + * + * @param code 编码 + */ + @SaCheckPermission("knowledge:schema:query") + @GetMapping("/code/{code}") + public R getByCode(@PathVariable String code) { + return R.ok(knowledgeSchemaService.queryByCode(code)); + } + + /** + * 根据表名查询模式信息 + * + * @param tableName 表名 + */ + @SaCheckPermission("knowledge:schema:query") + @GetMapping("/table/{tableName}") + public R getByTableName(@PathVariable String tableName) { + return R.ok(knowledgeSchemaService.queryByTableName(tableName)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaFieldController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaFieldController.java new file mode 100644 index 0000000..578df08 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaFieldController.java @@ -0,0 +1,141 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaFieldBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaFieldVo; +import org.dromara.knowledge.service.KnowledgeSchemaFieldService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库模式字段控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/schema/field") +public class KnowledgeSchemaFieldController extends BaseController { + + private final KnowledgeSchemaFieldService knowledgeSchemaFieldService; + + /** + * 查询知识库模式字段列表 + */ + @SaCheckPermission("knowledge:schema:field:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeSchemaFieldBo bo, PageQuery pageQuery) { + return knowledgeSchemaFieldService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库模式字段列表 + */ + @SaCheckPermission("knowledge:schema:field:export") + @Log(title = "知识库模式字段", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeSchemaFieldBo bo, HttpServletResponse response) { + List list = knowledgeSchemaFieldService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库模式字段数据", KnowledgeSchemaFieldVo.class, response); + } + + /** + * 获取知识库模式字段详细信息 + * + * @param fieldId 字段ID + */ + @SaCheckPermission("knowledge:schema:field:query") + @GetMapping("/{fieldId}") + public R getInfo(@PathVariable Long fieldId) { + return R.ok(knowledgeSchemaFieldService.queryById(fieldId)); + } + + /** + * 新增知识库模式字段 + */ + @SaCheckPermission("knowledge:schema:field:add") + @Log(title = "知识库模式字段", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeSchemaFieldBo bo) { + return toAjax(knowledgeSchemaFieldService.insertByBo(bo)); + } + + /** + * 修改知识库模式字段 + */ + @SaCheckPermission("knowledge:schema:field:edit") + @Log(title = "知识库模式字段", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeSchemaFieldBo bo) { + return toAjax(knowledgeSchemaFieldService.updateByBo(bo)); + } + + /** + * 删除知识库模式字段 + * + * @param fieldIds 字段ID串 + */ + @SaCheckPermission("knowledge:schema:field:remove") + @Log(title = "知识库模式字段", businessType = BusinessType.DELETE) + @DeleteMapping("/{fieldIds}") + public R remove(@PathVariable Long[] fieldIds) { + return toAjax(knowledgeSchemaFieldService.deleteWithValidByIds(List.of(fieldIds), true)); + } + + /** + * 根据ID查询字段信息 + * + * @param id ID + */ + @SaCheckPermission("knowledge:schema:field:query") + @GetMapping("/id/{id}") + public R getById(@PathVariable Long id) { + return R.ok(knowledgeSchemaFieldService.queryById(id)); + } + + /** + * 根据模式ID查询字段列表 + * + * @param schemaId 模式ID + */ + @SaCheckPermission("knowledge:schema:field:query") + @GetMapping("/schema/{schemaId}") + public R> getBySchemaId(@PathVariable Long schemaId) { + return R.ok(knowledgeSchemaFieldService.queryListBySchemaId(schemaId)); + } + + /** + * 根据编码查询字段信息 + * + * @param code 编码 + */ + @SaCheckPermission("knowledge:schema:field:query") + @GetMapping("/code/{code}") + public R getByCode(@PathVariable String code) { + return R.ok(knowledgeSchemaFieldService.queryByCode(code)); + } + + /** + * 根据模式ID和编码查询字段信息 + * + * @param schemaId 模式ID + * @param code 编码 + */ + @SaCheckPermission("knowledge:schema:field:query") + @GetMapping("/schema/{schemaId}/code/{code}") + public R getBySchemaIdAndCode(@PathVariable Long schemaId, @PathVariable String code) { + return R.ok(knowledgeSchemaFieldService.queryBySchemaIdAndCode(schemaId, code)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaGroupController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaGroupController.java new file mode 100644 index 0000000..08d4cfc --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeSchemaGroupController.java @@ -0,0 +1,129 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaGroupBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaGroupVo; +import org.dromara.knowledge.service.KnowledgeSchemaGroupService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库模式组控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/schema/group") +public class KnowledgeSchemaGroupController extends BaseController { + + private final KnowledgeSchemaGroupService knowledgeSchemaGroupService; + + /** + * 查询知识库模式组列表 + */ + @SaCheckPermission("knowledge:schema:group:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeSchemaGroupBo bo, PageQuery pageQuery) { + return knowledgeSchemaGroupService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库模式组列表 + */ + @SaCheckPermission("knowledge:schema:group:export") + @Log(title = "知识库模式组", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeSchemaGroupBo bo, HttpServletResponse response) { + List list = knowledgeSchemaGroupService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库模式组数据", KnowledgeSchemaGroupVo.class, response); + } + + /** + * 获取知识库模式组详细信息 + * + * @param groupId 模式组ID + */ + @SaCheckPermission("knowledge:schema:group:query") + @GetMapping("/{groupId}") + public R getInfo(@PathVariable Long groupId) { + return R.ok(knowledgeSchemaGroupService.queryById(groupId)); + } + + /** + * 新增知识库模式组 + */ + @SaCheckPermission("knowledge:schema:group:add") + @Log(title = "知识库模式组", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeSchemaGroupBo bo) { + return toAjax(knowledgeSchemaGroupService.insertByBo(bo)); + } + + /** + * 修改知识库模式组 + */ + @SaCheckPermission("knowledge:schema:group:edit") + @Log(title = "知识库模式组", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeSchemaGroupBo bo) { + return toAjax(knowledgeSchemaGroupService.updateByBo(bo)); + } + + /** + * 删除知识库模式组 + * + * @param groupIds 模式组ID串 + */ + @SaCheckPermission("knowledge:schema:group:remove") + @Log(title = "知识库模式组", businessType = BusinessType.DELETE) + @DeleteMapping("/{groupIds}") + public R remove(@PathVariable Long[] groupIds) { + return toAjax(knowledgeSchemaGroupService.deleteWithValidByIds(List.of(groupIds), true)); + } + + /** + * 根据ID查询模式组信息 + * + * @param id ID + */ + @SaCheckPermission("knowledge:schema:group:query") + @GetMapping("/id/{id}") + public R getById(@PathVariable Long id) { + return R.ok(knowledgeSchemaGroupService.queryById(id)); + } + + /** + * 根据编码查询模式组信息 + * + * @param code 编码 + */ + @SaCheckPermission("knowledge:schema:group:query") + @GetMapping("/code/{code}") + public R getByCode(@PathVariable String code) { + return R.ok(knowledgeSchemaGroupService.queryByCode(code)); + } + + /** + * 根据名称查询模式组信息 + * + * @param name 名称 + */ + @SaCheckPermission("knowledge:schema:group:query") + @GetMapping("/name/{name}") + public R getByName(@PathVariable String name) { + return R.ok(knowledgeSchemaGroupService.queryByName(name)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeVectorController.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeVectorController.java new file mode 100644 index 0000000..4b41b35 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/controller/KnowledgeVectorController.java @@ -0,0 +1,180 @@ +package org.dromara.knowledge.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.knowledge.domain.bo.KnowledgeVectorBo; +import org.dromara.knowledge.domain.vo.KnowledgeVectorVo; +import org.dromara.knowledge.service.KnowledgeVectorService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 知识库向量控制器 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/knowledge/vector") +public class KnowledgeVectorController extends BaseController { + + private final KnowledgeVectorService knowledgeVectorService; + private final org.dromara.knowledge.vectorstore.VectorStoreManager vectorStoreManager; + + @PostMapping("/generate") + @Log(title = "向量生成", businessType = BusinessType.OTHER) + public R> generate(@RequestParam String knowledgeId, + @RequestParam String docId, + @RequestBody List fragments, + @RequestParam(defaultValue = "bge-large-zh") String model) { + java.util.Map map = new java.util.HashMap<>(); + for (var f : fragments) { + map.put(f.getFid(), f.getContent()); + } + List ids = knowledgeVectorService.batchGenerateVectors(knowledgeId, docId, map, model); + return R.ok(ids); + } + + @PostMapping("/search") + @Log(title = "向量检索", businessType = BusinessType.OTHER) + public R> search(@RequestParam String knowledgeId, + @RequestParam String query, + @RequestParam(defaultValue = "bge-large-zh") String model, + @RequestParam(defaultValue = "5") Integer topK, + @RequestParam(defaultValue = "0.5") Double threshold, + @RequestParam(defaultValue = "false") boolean distinctByDoc) { + var res = knowledgeVectorService.vectorSimilaritySearch(knowledgeId, query, model, topK, threshold); + if (distinctByDoc) { + java.util.Set seen = new java.util.HashSet<>(); + res = res.stream().filter(r -> { + if (seen.contains(r.getDocId())) return false; + seen.add(r.getDocId()); + return true; + }).toList(); + } + return R.ok(res); + } + + @GetMapping("/stats/{knowledgeId}") + @Log(title = "向量统计", businessType = BusinessType.OTHER) + public R> stats(@PathVariable String knowledgeId) { + return R.ok(knowledgeVectorService.getVectorStatistics(knowledgeId)); + } + + @PostMapping("/rebuild/{knowledgeId}") + @Log(title = "重建向量索引", businessType = BusinessType.OTHER) + public R rebuild(@PathVariable String knowledgeId) { + vectorStoreManager.current().rebuildIndex(knowledgeId); + return R.ok(); + } + + @PostMapping("/vectorize-doc") + @Log(title = "文档批量向量化", businessType = BusinessType.OTHER) + public R> vectorizeDoc(@RequestParam String knowledgeId, + @RequestParam String docId, + @RequestParam(defaultValue = "bge-large-zh") String model) { + java.util.List fragments = + org.dromara.common.core.utils.SpringUtils.getBean(org.dromara.knowledge.service.KnowledgeFragmentService.class) + .queryListByDocId(docId); + java.util.Map map = new java.util.HashMap<>(); + for (var f : fragments) map.put(f.getFid(), f.getContent()); + java.util.List ids = knowledgeVectorService.batchGenerateVectors(knowledgeId, docId, map, model); + return R.ok(ids); + } + + /** + * 查询知识库向量列表 + */ + @SaCheckPermission("knowledge:vector:list") + @GetMapping("/list") + public TableDataInfo list(KnowledgeVectorBo bo, PageQuery pageQuery) { + return knowledgeVectorService.queryPageList(bo, pageQuery); + } + + /** + * 导出知识库向量列表 + */ + @SaCheckPermission("knowledge:vector:export") + @Log(title = "知识库向量", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(KnowledgeVectorBo bo, HttpServletResponse response) { + List list = knowledgeVectorService.queryList(bo); + ExcelUtil.exportExcel(list, "知识库向量", KnowledgeVectorVo.class, response); + } + + /** + * 获取知识库向量详细信息 + * + * @param vectorId 向量ID + */ + @SaCheckPermission("knowledge:vector:query") + @GetMapping("/{vectorId}") + public R getInfo(@PathVariable Long vectorId) { + return R.ok(knowledgeVectorService.queryById(vectorId)); + } + + /** + * 新增知识库向量 + */ + @SaCheckPermission("knowledge:vector:add") + @Log(title = "知识库向量", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody KnowledgeVectorBo bo) { + return toAjax(knowledgeVectorService.insertByBo(bo)); + } + + /** + * 修改知识库向量 + */ + @SaCheckPermission("knowledge:vector:edit") + @Log(title = "知识库向量", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody KnowledgeVectorBo bo) { + return toAjax(knowledgeVectorService.updateByBo(bo)); + } + + /** + * 删除知识库向量 + * + * @param vectorIds 向量ID数组 + */ + @SaCheckPermission("knowledge:vector:remove") + @Log(title = "知识库向量", businessType = BusinessType.DELETE) + @DeleteMapping("/{vectorIds}") + public R remove(@PathVariable Long[] vectorIds) { + return toAjax(knowledgeVectorService.deleteWithValidByIds(List.of(vectorIds), true)); + } + + /** + * 根据片段ID查询向量列表 + * + * @param fragmentId 片段ID + */ + @SaCheckPermission("knowledge:vector:query") + @GetMapping("/fragment/{fragmentId}") + public R> getByFragmentId(@PathVariable String fragmentId) { + return R.ok(knowledgeVectorService.queryByFragmentId(fragmentId)); + } + + /** + * 根据知识库ID查询向量列表 + * + * @param knowledgeId 知识库ID + */ + @SaCheckPermission("knowledge:vector:query") + @GetMapping("/knowledge/{knowledgeId}") + public R> getByKnowledgeId(@PathVariable String knowledgeId) { + return R.ok(knowledgeVectorService.queryByKnowledgeId(knowledgeId)); + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/CsvDocumentParser.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/CsvDocumentParser.java new file mode 100644 index 0000000..f94ef43 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/CsvDocumentParser.java @@ -0,0 +1,44 @@ +package org.dromara.knowledge.data; + +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.StringJoiner; + +/** + * CSV 文档解析器 + * 将 CSV 文件转换为文本格式,保留表格结构信息 + * 使用制表符分隔,便于后续处理 + * + * @author pengles + */ +@Component +public class CsvDocumentParser implements DocumentParser { + @Override + public String parse(byte[] bytes, String filename) { + try { + String csv = new String(bytes, StandardCharsets.UTF_8); + + // CSV 文件通常使用逗号分隔,但可能包含引号和换行符 + // 简单处理:将逗号替换为制表符,使其更易读 + // 注意:这种方法适用于简单的 CSV,复杂 CSV(包含引号内的逗号)需要专门的 CSV 解析库 + String[] lines = csv.split("\\r?\\n"); + StringJoiner result = new StringJoiner("\n"); + + for (String line : lines) { + if (line.trim().isEmpty()) { + continue; + } + // 简单替换:将逗号替换为制表符 + // 更复杂的 CSV 解析可以使用 Apache Commons CSV 等库 + String processedLine = line.replace(",", "\t"); + result.add(processedLine); + } + + return result.toString(); + } catch (Exception e) { + throw new ServiceException("CSV解析失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DataPipeline.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DataPipeline.java new file mode 100644 index 0000000..529bc09 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DataPipeline.java @@ -0,0 +1,10 @@ +package org.dromara.knowledge.data; + +public interface DataPipeline { + String ingest(String sourceUri); + + String transform(String raw); + + String store(String transformed); +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DocumentParser.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DocumentParser.java new file mode 100644 index 0000000..6fc1338 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DocumentParser.java @@ -0,0 +1,6 @@ +package org.dromara.knowledge.data; + +public interface DocumentParser { + String parse(byte[] bytes, String filename); +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DocumentParserFactory.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DocumentParserFactory.java new file mode 100644 index 0000000..ac2ebac --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/DocumentParserFactory.java @@ -0,0 +1,152 @@ +package org.dromara.knowledge.data; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 文档解析器工厂 + * 统一管理所有文档解析器,根据文件扩展名选择合适的解析器 + * + * @author pengles + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DocumentParserFactory { + + private final List parsers; + private final Map parserMap = new HashMap<>(); + + /** + * 初始化解析器映射 + * 通过构造函数注入所有 DocumentParser 实现,并建立文件扩展名到解析器的映射 + */ + private void initParserMap() { + if (!parserMap.isEmpty()) { + return; + } + + // PDF 解析器 + parserMap.put("pdf", findParserByClass(PdfDocumentParser.class)); + // Word 解析器 + parserMap.put("doc", findParserByClass(WordDocumentParser.class)); + parserMap.put("docx", findParserByClass(WordDocumentParser.class)); + // Excel 解析器 + parserMap.put("xls", findParserByClass(ExcelDocumentParser.class)); + parserMap.put("xlsx", findParserByClass(ExcelDocumentParser.class)); + // Markdown 解析器 + parserMap.put("md", findParserByClass(MarkdownDocumentParser.class)); + // HTML 解析器 + parserMap.put("html", findParserByClass(HtmlDocumentParser.class)); + parserMap.put("htm", findParserByClass(HtmlDocumentParser.class)); + // CSV 解析器 + parserMap.put("csv", findParserByClass(CsvDocumentParser.class)); + // 文本解析器(作为默认) + parserMap.put("txt", findParserByClass(TextDocumentParser.class)); + parserMap.put("json", findParserByClass(TextDocumentParser.class)); + parserMap.put("xml", findParserByClass(TextDocumentParser.class)); + + log.info("文档解析器工厂初始化完成,已注册 {} 种文件类型", parserMap.size()); + } + + /** + * 根据类类型查找解析器 + */ + private DocumentParser findParserByClass(Class clazz) { + return parsers.stream() + .filter(parser -> clazz.isInstance(parser)) + .findFirst() + .orElseGet(() -> { + try { + DocumentParser instance = clazz.getDeclaredConstructor().newInstance(); + log.warn("解析器 {} 未注册为 Spring Bean,使用默认实例", clazz.getSimpleName()); + return instance; + } catch (Exception e) { + throw new ServiceException("无法创建解析器实例: " + clazz.getName() + ": " + e.getMessage()); + } + }); + } + + /** + * 根据文件扩展名获取解析器 + * + * @param extension 文件扩展名(不包含点号) + * @return 文档解析器 + */ + public DocumentParser getParser(String extension) { + initParserMap(); + String ext = extension != null ? extension.toLowerCase() : ""; + DocumentParser parser = parserMap.get(ext); + if (parser == null) { + // 如果没有找到对应的解析器,使用文本解析器作为默认 + parser = parserMap.get("txt"); + log.warn("未找到文件扩展名 {} 的解析器,使用默认文本解析器", extension); + } + return parser; + } + + /** + * 根据文件名获取解析器 + * + * @param filename 文件名 + * @return 文档解析器 + */ + public DocumentParser getParserByFilename(String filename) { + String extension = extractExtension(filename); + return getParser(extension); + } + + /** + * 解析文档 + * + * @param bytes 文件字节数组 + * @param filename 文件名 + * @return 解析后的文本内容 + */ + public String parse(byte[] bytes, String filename) { + DocumentParser parser = getParserByFilename(filename); + return parser.parse(bytes, filename); + } + + /** + * 检查是否支持该文件类型 + * + * @param extension 文件扩展名 + * @return 是否支持 + */ + public boolean isSupported(String extension) { + initParserMap(); + String ext = extension != null ? extension.toLowerCase() : ""; + return parserMap.containsKey(ext); + } + + /** + * 获取支持的文件扩展名列表 + * + * @return 支持的文件扩展名列表 + */ + public List getSupportedExtensions() { + initParserMap(); + return parserMap.keySet().stream().sorted().toList(); + } + + /** + * 从文件名中提取扩展名 + */ + private String extractExtension(String filename) { + if (filename == null || filename.isEmpty()) { + return ""; + } + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < filename.length() - 1) { + return filename.substring(lastDotIndex + 1); + } + return ""; + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/ExcelDocumentParser.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/ExcelDocumentParser.java new file mode 100644 index 0000000..02d90c8 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/ExcelDocumentParser.java @@ -0,0 +1,57 @@ +package org.dromara.knowledge.data; + +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.util.StringJoiner; + +/** + * Excel 文档解析器(支持 .xls 和 .xlsx 格式) + * + * @author pengles + */ +@Component +public class ExcelDocumentParser implements DocumentParser { + @Override + public String parse(byte[] bytes, String filename) { + String ext = filename != null && filename.contains(".") ? filename.substring(filename.lastIndexOf('.') + 1).toLowerCase() : "xlsx"; + try (ByteArrayInputStream is = new ByteArrayInputStream(bytes)) { + StringJoiner joiner = new StringJoiner("\n"); + if ("xls".equals(ext)) { + HSSFWorkbook wb = new HSSFWorkbook(is); + for (Sheet sheet : wb) { + for (Row row : sheet) { + StringJoiner rowJoin = new StringJoiner("\t"); + for (Cell cell : row) { + rowJoin.add(cell.toString()); + } + joiner.add(rowJoin.toString()); + } + } + wb.close(); + } else { + XSSFWorkbook wb = new XSSFWorkbook(is); + for (Sheet sheet : wb) { + for (Row row : sheet) { + StringJoiner rowJoin = new StringJoiner("\t"); + for (Cell cell : row) { + rowJoin.add(cell.toString()); + } + joiner.add(rowJoin.toString()); + } + } + wb.close(); + } + return joiner.toString(); + } catch (Exception e) { + throw new ServiceException("Excel解析失败: " + e.getMessage()); + } + } +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/HtmlDocumentParser.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/HtmlDocumentParser.java new file mode 100644 index 0000000..3b3cedd --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/HtmlDocumentParser.java @@ -0,0 +1,53 @@ +package org.dromara.knowledge.data; + +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +/** + * HTML 文档解析器 + * 提取 HTML 中的纯文本内容,移除标签和脚本 + * + * @author pengles + */ +@Component +public class HtmlDocumentParser implements DocumentParser { + + // 匹配 HTML 标签的正则表达式 + private static final Pattern HTML_TAG_PATTERN = Pattern.compile("<[^>]+>"); + // 匹配脚本和样式标签及其内容 + private static final Pattern SCRIPT_STYLE_PATTERN = Pattern.compile( + "(?i)<(script|style)[^>]*>.*?", Pattern.DOTALL); + // 匹配 HTML 实体 + private static final Pattern HTML_ENTITY_PATTERN = Pattern.compile("&[a-zA-Z]+;|&#\\d+;"); + + @Override + public String parse(byte[] bytes, String filename) { + try { + String html = new String(bytes, StandardCharsets.UTF_8); + + // 移除脚本和样式标签及其内容 + html = SCRIPT_STYLE_PATTERN.matcher(html).replaceAll(""); + + // 移除 HTML 标签 + html = HTML_TAG_PATTERN.matcher(html).replaceAll(" "); + + // 简单处理 HTML 实体(可以扩展更完整的实体映射) + html = html.replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'"); + + // 清理多余的空白字符 + html = html.replaceAll("\\s+", " ").trim(); + + return html; + } catch (Exception e) { + throw new ServiceException("HTML解析失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/MarkdownDocumentParser.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/MarkdownDocumentParser.java new file mode 100644 index 0000000..a19a5d0 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/MarkdownDocumentParser.java @@ -0,0 +1,24 @@ +package org.dromara.knowledge.data; + +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +/** + * Markdown 文档解析器 + * 直接返回 Markdown 文本内容,保留格式供后续处理 + * + * @author pengles + */ +@Component +public class MarkdownDocumentParser implements DocumentParser { + @Override + public String parse(byte[] bytes, String filename) { + try { + return new String(bytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new ServiceException("Markdown解析失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/PdfDocumentParser.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/PdfDocumentParser.java new file mode 100644 index 0000000..57abe2d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/PdfDocumentParser.java @@ -0,0 +1,37 @@ +package org.dromara.knowledge.data; + +import com.itextpdf.kernel.pdf.PdfDocument; +import com.itextpdf.kernel.pdf.PdfReader; +import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; +import com.itextpdf.kernel.pdf.canvas.parser.listener.LocationTextExtractionStrategy; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; + +/** + * PDF 文档解析器 + * + * @author pengles + */ +@Component +public class PdfDocumentParser implements DocumentParser { + @Override + public String parse(byte[] bytes, String filename) { + try (ByteArrayInputStream is = new ByteArrayInputStream(bytes)) { + PdfReader reader = new PdfReader(is); + PdfDocument pdf = new PdfDocument(reader); + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= pdf.getNumberOfPages(); i++) { + String text = PdfTextExtractor.getTextFromPage(pdf.getPage(i), new LocationTextExtractionStrategy()); + if (text != null && !text.isEmpty()) { + sb.append(text).append("\n"); + } + } + pdf.close(); + return sb.toString(); + } catch (Exception e) { + throw new ServiceException("PDF解析失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/TextDocumentParser.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/TextDocumentParser.java new file mode 100644 index 0000000..b5b0392 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/TextDocumentParser.java @@ -0,0 +1,32 @@ +package org.dromara.knowledge.data; + +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * 纯文本文档解析器 + * 支持 TXT、JSON、XML 等纯文本格式 + * + * @author pengles + */ +@Component +public class TextDocumentParser implements DocumentParser { + @Override + public String parse(byte[] bytes, String filename) { + try { + // 尝试使用 UTF-8 编码,如果失败则使用系统默认编码 + String text; + try { + text = new String(bytes, StandardCharsets.UTF_8); + } catch (Exception e) { + text = new String(bytes, Charset.defaultCharset()); + } + return text; + } catch (Exception e) { + throw new ServiceException("文本解析失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/WordDocumentParser.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/WordDocumentParser.java new file mode 100644 index 0000000..b819256 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/data/WordDocumentParser.java @@ -0,0 +1,35 @@ +package org.dromara.knowledge.data; + +import org.apache.poi.hwpf.extractor.WordExtractor; +import org.apache.poi.xwpf.extractor.XWPFWordExtractor; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; + +/** + * Word 文档解析器(支持 .doc 和 .docx 格式) + * + * @author pengles + */ +@Component +public class WordDocumentParser implements DocumentParser { + @Override + public String parse(byte[] bytes, String filename) { + String ext = filename != null && filename.contains(".") ? filename.substring(filename.lastIndexOf('.') + 1).toLowerCase() : "docx"; + try (ByteArrayInputStream is = new ByteArrayInputStream(bytes)) { + if ("doc".equals(ext)) { + WordExtractor extractor = new WordExtractor(is); + return extractor.getText(); + } else { + XWPFDocument docx = new XWPFDocument(is); + XWPFWordExtractor extractor = new XWPFWordExtractor(docx); + return extractor.getText(); + } + } catch (Exception e) { + throw new org.dromara.common.core.exception.ServiceException("Word解析失败: " + e.getMessage()); + } + } +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeAttach.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeAttach.java new file mode 100644 index 0000000..8ce92be --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeAttach.java @@ -0,0 +1,106 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库附件表 knowledge_attach + * + * @author pengles + */ +@Data +@TableName("knowledge_attach") +public class KnowledgeAttach implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 知识库ID + */ + private String knowledgeId; + + /** + * 文档ID + */ + private String docId; + + /** + * 文档名称 + */ + private String docName; + + /** + * 文档类型 + */ + private String docType; + + /** + * 对象存储ID + */ + private Integer ossId; + + /** + * 拆解图片状态:10未开始,20进行中,30已完成 + */ + private Integer picStatus; + + /** + * 分析图片状态:10未开始,20进行中,30已完成 + */ + private Integer picAnysStatus; + + /** + * 写入向量数据库状态:10未开始,20进行中,30已完成 + */ + private Integer vectorStatus; + + /** + * 文档内容 + */ + private String content; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeFragment.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeFragment.java new file mode 100644 index 0000000..42b0b7e --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeFragment.java @@ -0,0 +1,86 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库片段表 knowledge_fragment + * + * @author pengles + */ +@Data +@TableName("knowledge_fragment") +public class KnowledgeFragment implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 知识库ID + */ + private String knowledgeId; + + /** + * 文档ID + */ + private String docId; + + /** + * 知识片段ID + */ + private String fid; + + /** + * 片段索引下标 + */ + private Integer idx; + + /** + * 文档内容 + */ + private String content; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeHitLog.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeHitLog.java new file mode 100644 index 0000000..f834d9c --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeHitLog.java @@ -0,0 +1,96 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 知识库命中日志对象 knowledge_hit_log + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("knowledge_hit_log") +public class KnowledgeHitLog extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 会话ID + */ + private String sessionId; + + /** + * 知识库ID + */ + private String knowledgeId; + + /** + * 文档ID + */ + private String docId; + + /** + * 知识片段ID + */ + private String fragmentId; + + /** + * 查询文本 + */ + private String queryText; + + /** + * 查询向量 + */ + private String queryVector; + + /** + * 相似度分数 + */ + private Double similarityScore; + + /** + * 排名 + */ + private Integer ranking; + + /** + * 模型名称 + */ + private String modelName; + + /** + * 搜索参数 + */ + private String searchParams; + + /** + * 响应时间(毫秒) + */ + private Long responseTime; + + /** + * 命中次数 + */ + private Integer hitCount; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeInfo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeInfo.java new file mode 100644 index 0000000..aeab3a2 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeInfo.java @@ -0,0 +1,126 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库信息表 knowledge_info + * + * @author pengles + */ +@Data +@TableName("knowledge_info") +public class KnowledgeInfo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 知识库ID + */ + private String knowledgeId; + + /** + * 知识库名称 + */ + private String knowledgeName; + + /** + * 是否公开知识库:0否,1是 + */ + private Integer share; + + /** + * 描述 + */ + private String description; + + /** + * 知识分隔符 + */ + private String knowledgeSeparator; + + /** + * 提问分隔符 + */ + private String questionSeparator; + + /** + * 重叠字符数 + */ + private Integer overlapChar; + + /** + * 知识库中检索的条数 + */ + private Integer retrieveLimit; + + /** + * 文本块大小 + */ + private Integer textBlockSize; + + /** + * 向量库 + */ + private String vectorModelName; + + /** + * 向量模型 + */ + private String embeddingModelName; + + /** + * 系统提示词 + */ + private String systemPrompt; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeJob.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeJob.java new file mode 100644 index 0000000..433e402 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeJob.java @@ -0,0 +1,147 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 知识库任务对象 knowledge_job + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("knowledge_job") +public class KnowledgeJob extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 任务ID + */ + private String jobId; + + /** + * 任务类型 + */ + private String jobType; + + /** + * 任务子类型 + */ + private String jobSubType; + + /** + * 知识库ID + */ + private String knowledgeId; + + /** + * 文档ID + */ + private String docId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 任务状态 + */ + private Integer status; + + /** + * 任务进度 + */ + private Integer progress; + + /** + * 总项目数 + */ + private Integer totalItems; + + /** + * 已处理项目数 + */ + private Integer processedItems; + + /** + * 成功项目数 + */ + private Integer successItems; + + /** + * 失败项目数 + */ + private Integer failedItems; + + /** + * 任务优先级 + */ + private Integer priority; + + /** + * 重试次数 + */ + private Integer retryCount; + + /** + * 最大重试次数 + */ + private Integer maxRetryCount; + + /** + * 任务开始时间 + */ + private Date startTime; + + /** + * 任务结束时间 + */ + private Date endTime; + + /** + * 任务超时时间(秒) + */ + private Integer timeout; + + /** + * 错误信息 + */ + private String errorInfo; + + /** + * 任务结果数据 + */ + private String resultData; + + /** + * 通知配置 + */ + private String notificationConfig; + + /** + * 通知状态 + */ + private Integer notificationStatus; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgePromptTemplate.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgePromptTemplate.java new file mode 100644 index 0000000..31e7a25 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgePromptTemplate.java @@ -0,0 +1,76 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库提示模板表 knowledge_prompt_template + * + * @author pengles + */ +@Data +@TableName("knowledge_prompt_template") +public class KnowledgePromptTemplate implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 提示词模板名称 + */ + private String templateName; + + /** + * 提示词模板内容 + */ + private String templateContent; + + /** + * 提示词分类:knowledge知识库类型,chat对话类型,draw绘画类型 + */ + private String category; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRole.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRole.java new file mode 100644 index 0000000..2c25e36 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRole.java @@ -0,0 +1,86 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库角色表 knowledge_role + * + * @author pengles + */ +@Data +@TableName("knowledge_role") +public class KnowledgeRole implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 角色名称 + */ + private String roleName; + + /** + * 角色编码 + */ + private String roleCode; + + /** + * 角色描述 + */ + private String roleDesc; + + /** + * 角色状态:0禁用,1启用 + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRoleGroup.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRoleGroup.java new file mode 100644 index 0000000..2cb8859 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRoleGroup.java @@ -0,0 +1,86 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库角色分组表 knowledge_role_group + * + * @author pengles + */ +@Data +@TableName("knowledge_role_group") +public class KnowledgeRoleGroup implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 分组名称 + */ + private String groupName; + + /** + * 分组编码 + */ + private String groupCode; + + /** + * 分组描述 + */ + private String groupDesc; + + /** + * 分组状态:0禁用,1启用 + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRoleRelation.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRoleRelation.java new file mode 100644 index 0000000..ee45c5a --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeRoleRelation.java @@ -0,0 +1,86 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库角色关系表 knowledge_role_relation + * + * @author pengles + */ +@Data +@TableName("knowledge_role_relation") +public class KnowledgeRoleRelation implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 角色ID + */ + private Long roleId; + + /** + * 分组ID + */ + private Long groupId; + + /** + * 关系类型:1角色属于分组,2分组包含角色 + */ + private Integer relationType; + + /** + * 关系状态:0禁用,1启用 + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchema.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchema.java new file mode 100644 index 0000000..be05e77 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchema.java @@ -0,0 +1,81 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库数据模型表 knowledge_schema + * + * @author pengles + */ +@Data +@TableName("knowledge_schema") +public class KnowledgeSchema implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 分组ID + */ + private Long schemaGroupId; + + /** + * 模型名称 + */ + private String name; + + /** + * 模型编码 + */ + private String code; + + /** + * 表名 + */ + private String tableName; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchemaField.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchemaField.java new file mode 100644 index 0000000..d3cfc70 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchemaField.java @@ -0,0 +1,161 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库数据模型字段表 knowledge_schema_field + * + * @author pengles + */ +@Data +@TableName("knowledge_schema_field") +public class KnowledgeSchemaField implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 模型ID + */ + private Long schemaId; + + /** + * 模型名称 + */ + private String schemaName; + + /** + * 字段名称 + */ + private String name; + + /** + * 字段编码 + */ + private String code; + + /** + * 字段类型 + */ + private String type; + + /** + * 字段注释 + */ + private String comment; + + /** + * 是否主键:0否,1是 + */ + private Integer isPk; + + /** + * 是否必填:0否,1是 + */ + private Integer isRequired; + + /** + * 是否唯一:0否,1是 + */ + private Integer isUnique; + + /** + * 默认值 + */ + private String defaultValue; + + /** + * 字段长度 + */ + private Integer length; + + /** + * 小数位数 + */ + private Integer scale; + + /** + * 是否列表显示:0否,1是 + */ + private Integer isList; + + /** + * 是否查询字段:0否,1是 + */ + private Integer isQuery; + + /** + * 是否插入字段:0否,1是 + */ + private Integer isInsert; + + /** + * 是否编辑字段:0否,1是 + */ + private Integer isEdit; + + /** + * 查询方式(EQ,NE,GT,LT,LIKE,BETWEEN) + */ + private String queryType; + + /** + * 显示类型(input,textarea,select,checkbox,radio,datetime,image,upload,editor) + */ + private String htmlType; + + /** + * 字典类型 + */ + private String dictType; + + /** + * 排序 + */ + private Integer sort; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 备注 + */ + private String remark; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchemaGroup.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchemaGroup.java new file mode 100644 index 0000000..329f71f --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeSchemaGroup.java @@ -0,0 +1,76 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库数据模型分组表 knowledge_schema_group + * + * @author pengles + */ +@Data +@TableName("knowledge_schema_group") +public class KnowledgeSchemaGroup implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 分组名称 + */ + private String name; + + /** + * 分组编码 + */ + private String code; + + /** + * 图标 + */ + private String icon; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志:0存在,1删除 + */ + @TableLogic + private Integer delFlag; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private Long updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeVector.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeVector.java new file mode 100644 index 0000000..76d02f9 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/KnowledgeVector.java @@ -0,0 +1,76 @@ +package org.dromara.knowledge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 知识片段向量对象 knowledge_vector + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("knowledge_vector") +public class KnowledgeVector extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 知识库ID + */ + private String knowledgeId; + + /** + * 文档ID + */ + private String docId; + + /** + * 知识片段ID + */ + private String fragmentId; + + /** + * 向量ID + */ + private String vectorId; + + /** + * 向量数据 + */ + private String vectorData; + + /** + * 向量维度 + */ + private Integer dimension; + + /** + * 模型名称 + */ + private String modelName; + + /** + * 状态(0正常 1停用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeAttachBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeAttachBo.java new file mode 100644 index 0000000..0d1cfeb --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeAttachBo.java @@ -0,0 +1,82 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeAttach; + +/** + * 知识库附件业务对象 knowledge_attach + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeAttach.class, reverseConvertGenerate = false) +public class KnowledgeAttachBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 知识库ID + */ + @NotBlank(message = "知识库ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String knowledgeId; + + /** + * 文档ID + */ + @NotBlank(message = "文档ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String docId; + + /** + * 文档名称 + */ + @NotBlank(message = "文档名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String docName; + + /** + * 文档类型 + */ + private String docType; + + /** + * OSS文件ID + */ + private Integer ossId; + + /** + * 图片状态 + */ + private Integer picStatus; + + /** + * 图片分析状态 + */ + private Integer picAnysStatus; + + /** + * 向量化状态 + */ + private Integer vectorStatus; + + /** + * 文档内容 + */ + private String content; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeFragmentBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeFragmentBo.java new file mode 100644 index 0000000..d578ecb --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeFragmentBo.java @@ -0,0 +1,63 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeFragment; + +/** + * 知识库片段业务对象 knowledge_fragment + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeFragment.class, reverseConvertGenerate = false) +public class KnowledgeFragmentBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 知识库ID + */ + @NotBlank(message = "知识库ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String knowledgeId; + + /** + * 文档ID + */ + @NotBlank(message = "文档ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String docId; + + /** + * 知识片段ID + */ + @NotBlank(message = "知识片段ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String fid; + + /** + * 片段索引下标 + */ + private Integer idx; + + /** + * 片段内容 + */ + @NotBlank(message = "片段内容不能为空", groups = {AddGroup.class, EditGroup.class}) + private String content; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeHitLogBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeHitLogBo.java new file mode 100644 index 0000000..4526e27 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeHitLogBo.java @@ -0,0 +1,110 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeHitLog; + +/** + * 知识库命中日志业务对象 knowledge_hit_log + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeHitLog.class, reverseConvertGenerate = false) +public class KnowledgeHitLogBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 会话ID + */ + @NotBlank(message = "会话ID不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 64, message = "会话ID长度不能超过64个字符") + private String sessionId; + + /** + * 知识库ID + */ + @NotBlank(message = "知识库ID不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 64, message = "知识库ID长度不能超过64个字符") + private String knowledgeId; + + /** + * 文档ID + */ + @Size(max = 64, message = "文档ID长度不能超过64个字符") + private String docId; + + /** + * 知识片段ID + */ + @Size(max = 64, message = "知识片段ID长度不能超过64个字符") + private String fragmentId; + + /** + * 查询文本 + */ + @NotBlank(message = "查询文本不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 2000, message = "查询文本长度不能超过2000个字符") + private String queryText; + + /** + * 查询向量 + */ + @Size(max = 65535, message = "查询向量长度不能超过65535个字符") + private String queryVector; + + /** + * 相似度分数 + */ + @DecimalMin(value = "0.0", message = "相似度分数不能小于0") + @DecimalMax(value = "1.0", message = "相似度分数不能大于1") + private Double similarityScore; + + /** + * 排名 + */ + @Min(value = 1, message = "排名必须大于0") + private Integer ranking; + + /** + * 模型名称 + */ + @Size(max = 100, message = "模型名称长度不能超过100个字符") + private String modelName; + + /** + * 搜索参数 + */ + @Size(max = 1000, message = "搜索参数长度不能超过1000个字符") + private String searchParams; + + /** + * 响应时间(毫秒) + */ + @Min(value = 0, message = "响应时间不能小于0") + private Long responseTime; + + /** + * 命中次数 + */ + @Min(value = 0, message = "命中次数不能小于0") + private Integer hitCount; + + /** + * 备注 + */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeInfoBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeInfoBo.java new file mode 100644 index 0000000..3c1eb19 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeInfoBo.java @@ -0,0 +1,101 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeInfo; + +/** + * 知识库信息业务对象 knowledge_info + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeInfo.class, reverseConvertGenerate = false) +public class KnowledgeInfoBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 知识库ID + */ + @NotBlank(message = "知识库ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String knowledgeId; + + /** + * 知识库名称 + */ + @NotBlank(message = "知识库名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String knowledgeName; + + /** + * 是否公开知识库:0否,1是 + */ + private Integer share; + + /** + * 描述 + */ + private String description; + + /** + * 知识分隔符 + */ + private String knowledgeSeparator; + + /** + * 问题分隔符 + */ + private String questionSeparator; + + /** + * 重叠字符数 + */ + private Integer overlapChar; + + /** + * 检索限制 + */ + private Integer retrieveLimit; + + /** + * 文本块大小 + */ + private Integer textBlockSize; + + /** + * 向量模型名称 + */ + private String vectorModelName; + + /** + * 嵌入模型名称 + */ + private String embeddingModelName; + + /** + * 系统提示词 + */ + private String systemPrompt; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeJobBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeJobBo.java new file mode 100644 index 0000000..0b58b1b --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeJobBo.java @@ -0,0 +1,167 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeJob; + +import java.util.Date; + +/** + * 知识库任务业务对象 knowledge_job + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeJob.class, reverseConvertGenerate = false) +public class KnowledgeJobBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 任务ID + */ + @NotBlank(message = "任务ID不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 64, message = "任务ID长度不能超过64个字符") + private String jobId; + + /** + * 任务类型 + */ + @NotBlank(message = "任务类型不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 50, message = "任务类型长度不能超过50个字符") + private String jobType; + + /** + * 任务子类型 + */ + @Size(max = 50, message = "任务子类型长度不能超过50个字符") + private String jobSubType; + + /** + * 知识库ID + */ + @Size(max = 64, message = "知识库ID长度不能超过64个字符") + private String knowledgeId; + + /** + * 文档ID + */ + @Size(max = 64, message = "文档ID长度不能超过64个字符") + private String docId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 任务状态 + */ + private Integer status; + + /** + * 任务进度 + */ + @Min(value = 0, message = "任务进度不能小于0") + @Max(value = 100, message = "任务进度不能大于100") + private Integer progress; + + /** + * 总项目数 + */ + @Min(value = 0, message = "总项目数不能小于0") + private Integer totalItems; + + /** + * 已处理项目数 + */ + @Min(value = 0, message = "已处理项目数不能小于0") + private Integer processedItems; + + /** + * 成功项目数 + */ + @Min(value = 0, message = "成功项目数不能小于0") + private Integer successItems; + + /** + * 失败项目数 + */ + @Min(value = 0, message = "失败项目数不能小于0") + private Integer failedItems; + + /** + * 任务优先级 + */ + @Min(value = 1, message = "任务优先级不能小于1") + @Max(value = 10, message = "任务优先级不能大于10") + private Integer priority; + + /** + * 重试次数 + */ + @Min(value = 0, message = "重试次数不能小于0") + private Integer retryCount; + + /** + * 最大重试次数 + */ + @Min(value = 0, message = "最大重试次数不能小于0") + private Integer maxRetryCount; + + /** + * 任务开始时间 + */ + private Date startTime; + + /** + * 任务结束时间 + */ + private Date endTime; + + /** + * 任务超时时间(秒) + */ + @Min(value = 1, message = "任务超时时间必须大于0") + private Integer timeout; + + /** + * 错误信息 + */ + @Size(max = 2000, message = "错误信息长度不能超过2000个字符") + private String errorInfo; + + /** + * 任务结果数据 + */ + @Size(max = 65535, message = "任务结果数据长度不能超过65535个字符") + private String resultData; + + /** + * 通知配置 + */ + @Size(max = 1000, message = "通知配置长度不能超过1000个字符") + private String notificationConfig; + + /** + * 通知状态 + */ + private Integer notificationStatus; + + /** + * 备注 + */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgePromptTemplateBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgePromptTemplateBo.java new file mode 100644 index 0000000..13dc471 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgePromptTemplateBo.java @@ -0,0 +1,51 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgePromptTemplate; + +/** + * 知识库提示模板业务对象 knowledge_prompt_template + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgePromptTemplate.class, reverseConvertGenerate = false) +public class KnowledgePromptTemplateBo extends BaseEntity { + + /** + * 主键 + */ + @NotNull(message = "主键不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 提示词模板名称 + */ + @NotBlank(message = "提示词模板名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String templateName; + + /** + * 提示词模板内容 + */ + @NotBlank(message = "提示词模板内容不能为空", groups = {AddGroup.class, EditGroup.class}) + private String templateContent; + + /** + * 提示词分类:knowledge知识库类型,chat对话类型,draw绘画类型 + */ + private String category; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleBo.java new file mode 100644 index 0000000..2e8ea9e --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleBo.java @@ -0,0 +1,61 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeRole; + +/** + * 知识库角色业务对象 knowledge_role + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeRole.class, reverseConvertGenerate = false) +public class KnowledgeRoleBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 角色名称 + */ + @NotBlank(message = "角色名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String roleName; + + /** + * 角色编码 + */ + @NotBlank(message = "角色编码不能为空", groups = {AddGroup.class, EditGroup.class}) + private String roleCode; + + /** + * 角色描述 + */ + private String roleDesc; + + /** + * 角色状态:0禁用,1启用 + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleGroupBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleGroupBo.java new file mode 100644 index 0000000..e8e94e3 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleGroupBo.java @@ -0,0 +1,61 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeRoleGroup; + +/** + * 知识库角色分组业务对象 knowledge_role_group + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeRoleGroup.class, reverseConvertGenerate = false) +public class KnowledgeRoleGroupBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 分组名称 + */ + @NotBlank(message = "分组名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String groupName; + + /** + * 分组编码 + */ + @NotBlank(message = "分组编码不能为空", groups = {AddGroup.class, EditGroup.class}) + private String groupCode; + + /** + * 分组描述 + */ + private String groupDesc; + + /** + * 分组状态:0禁用,1启用 + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleRelationBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleRelationBo.java new file mode 100644 index 0000000..48cdf94 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeRoleRelationBo.java @@ -0,0 +1,60 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeRoleRelation; + +/** + * 知识库角色关系业务对象 knowledge_role_relation + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeRoleRelation.class, reverseConvertGenerate = false) +public class KnowledgeRoleRelationBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 角色ID + */ + @NotNull(message = "角色ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long roleId; + + /** + * 分组ID + */ + @NotNull(message = "分组ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long groupId; + + /** + * 关系类型:1角色属于分组,2分组包含角色 + */ + private Integer relationType; + + /** + * 关系状态:0禁用,1启用 + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaBo.java new file mode 100644 index 0000000..f6402e3 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaBo.java @@ -0,0 +1,57 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeSchema; + +/** + * 知识库数据模型业务对象 knowledge_schema + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeSchema.class, reverseConvertGenerate = false) +public class KnowledgeSchemaBo extends BaseEntity { + + /** + * 主键 + */ + @NotNull(message = "主键不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 分组ID + */ + private Long schemaGroupId; + + /** + * 模型名称 + */ + @NotBlank(message = "模型名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String name; + + /** + * 模型编码 + */ + @NotBlank(message = "模型编码不能为空", groups = {AddGroup.class, EditGroup.class}) + private String code; + + /** + * 表名 + */ + @NotBlank(message = "表名不能为空", groups = {AddGroup.class, EditGroup.class}) + private String tableName; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaFieldBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaFieldBo.java new file mode 100644 index 0000000..2ee7ba2 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaFieldBo.java @@ -0,0 +1,138 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeSchemaField; + +/** + * 知识库数据模型字段业务对象 knowledge_schema_field + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeSchemaField.class, reverseConvertGenerate = false) +public class KnowledgeSchemaFieldBo extends BaseEntity { + + /** + * 主键 + */ + @NotNull(message = "主键不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 模型ID + */ + @NotNull(message = "模型ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long schemaId; + + /** + * 模型名称 + */ + private String schemaName; + + /** + * 字段名称 + */ + @NotBlank(message = "字段名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String name; + + /** + * 字段编码 + */ + @NotBlank(message = "字段编码不能为空", groups = {AddGroup.class, EditGroup.class}) + private String code; + + /** + * 字段类型 + */ + @NotBlank(message = "字段类型不能为空", groups = {AddGroup.class, EditGroup.class}) + private String type; + + /** + * 字段注释 + */ + private String comment; + + /** + * 是否主键:0否,1是 + */ + private Integer isPk; + + /** + * 是否必填:0否,1是 + */ + private Integer isRequired; + + /** + * 是否唯一:0否,1是 + */ + private Integer isUnique; + + /** + * 默认值 + */ + private String defaultValue; + + /** + * 字段长度 + */ + private Integer length; + + /** + * 小数位数 + */ + private Integer scale; + + /** + * 是否列表:0否,1是 + */ + private Integer isList; + + /** + * 是否查询:0否,1是 + */ + private Integer isQuery; + + /** + * 是否插入:0否,1是 + */ + private Integer isInsert; + + /** + * 是否编辑:0否,1是 + */ + private Integer isEdit; + + /** + * 查询类型 + */ + private String queryType; + + /** + * HTML类型 + */ + private String htmlType; + + /** + * 字典类型 + */ + private String dictType; + + /** + * 排序 + */ + private Integer sort; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaGroupBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaGroupBo.java new file mode 100644 index 0000000..bda29ae --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeSchemaGroupBo.java @@ -0,0 +1,51 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeSchemaGroup; + +/** + * 知识库数据模型分组业务对象 knowledge_schema_group + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeSchemaGroup.class, reverseConvertGenerate = false) +public class KnowledgeSchemaGroupBo extends BaseEntity { + + /** + * 主键 + */ + @NotNull(message = "主键不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 分组名称 + */ + @NotBlank(message = "分组名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String name; + + /** + * 分组编码 + */ + @NotBlank(message = "分组编码不能为空", groups = {AddGroup.class, EditGroup.class}) + private String code; + + /** + * 图标 + */ + private String icon; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeVectorBo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeVectorBo.java new file mode 100644 index 0000000..406efa6 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/bo/KnowledgeVectorBo.java @@ -0,0 +1,86 @@ +package org.dromara.knowledge.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.knowledge.domain.KnowledgeVector; + +/** + * 知识片段向量业务对象 knowledge_vector + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = KnowledgeVector.class, reverseConvertGenerate = false) +public class KnowledgeVectorBo extends BaseEntity { + + /** + * 主键ID + */ + @NotNull(message = "主键ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 知识库ID + */ + @NotBlank(message = "知识库ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String knowledgeId; + + /** + * 文档ID + */ + @NotBlank(message = "文档ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String docId; + + /** + * 知识片段ID + */ + @NotBlank(message = "知识片段ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String fragmentId; + + /** + * 向量ID + */ + @NotBlank(message = "向量ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String vectorId; + + /** + * 向量数据 + */ + @Size(max = 65535, message = "向量数据长度不能超过65535个字符") + private String vectorData; + + /** + * 向量维度 + */ + @NotNull(message = "向量维度不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 1, message = "向量维度必须大于0") + private Integer dimension; + + /** + * 模型名称 + */ + @NotBlank(message = "模型名称不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 100, message = "模型名称长度不能超过100个字符") + private String modelName; + + /** + * 状态(0正常 1停用) + */ + private Integer status; + + /** + * 备注 + */ + @Size(max = 500, message = "备注长度不能超过500个字符") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeAttachVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeAttachVo.java new file mode 100644 index 0000000..c511417 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeAttachVo.java @@ -0,0 +1,107 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgeAttach; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库附件视图对象 knowledge_attach + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeAttach.class) +public class KnowledgeAttachVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 知识库ID + */ + @ExcelProperty(value = "知识库ID") + private String knowledgeId; + + /** + * 文档ID + */ + @ExcelProperty(value = "文档ID") + private String docId; + + /** + * 文档名称 + */ + @ExcelProperty(value = "文档名称") + private String docName; + + /** + * 文档类型 + */ + @ExcelProperty(value = "文档类型") + private String docType; + + /** + * OSS对象存储主键 + */ + @ExcelProperty(value = "OSS对象存储主键") + private Integer ossId; + + /** + * 图片识别状态 + */ + @ExcelProperty(value = "图片识别状态") + private Integer picStatus; + + /** + * 图片分析状态 + */ + @ExcelProperty(value = "图片分析状态") + private Integer picAnysStatus; + + /** + * 向量化状态 + */ + @ExcelProperty(value = "向量化状态") + private Integer vectorStatus; + /** + * 文档内容 + */ + @ExcelProperty(value = "文档内容") + private String content; + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + + /** + * 获取向量化状态 + */ + public Integer getVectorStatus() { + return vectorStatus; + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeFragmentVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeFragmentVo.java new file mode 100644 index 0000000..7eb23d5 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeFragmentVo.java @@ -0,0 +1,80 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgeFragment; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库片段视图对象 knowledge_fragment + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeFragment.class) +public class KnowledgeFragmentVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 知识库ID + */ + @ExcelProperty(value = "知识库ID") + private String knowledgeId; + + /** + * 文档ID + */ + @ExcelProperty(value = "文档ID") + private String docId; + + /** + * 知识片段ID + */ + @ExcelProperty(value = "知识片段ID") + private String fid; + + /** + * 片段索引下标 + */ + @ExcelProperty(value = "片段索引下标") + private Integer idx; + + /** + * 片段内容 + */ + @ExcelProperty(value = "片段内容") + private String content; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeHitLogVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeHitLogVo.java new file mode 100644 index 0000000..74ac9c2 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeHitLogVo.java @@ -0,0 +1,110 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgeHitLog; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 知识库命中日志视图对象 knowledge_hit_log + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeHitLog.class) +public class KnowledgeHitLogVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 会话ID + */ + @ExcelProperty(value = "会话ID") + private String sessionId; + + /** + * 知识库ID + */ + @ExcelProperty(value = "知识库ID") + private String knowledgeId; + + /** + * 文档ID + */ + @ExcelProperty(value = "文档ID") + private String docId; + + /** + * 知识片段ID + */ + @ExcelProperty(value = "知识片段ID") + private String fragmentId; + + /** + * 查询文本 + */ + @ExcelProperty(value = "查询文本") + private String queryText; + + /** + * 相似度分数 + */ + @ExcelProperty(value = "相似度分数") + private Double similarityScore; + + /** + * 排名 + */ + @ExcelProperty(value = "排名") + private Integer ranking; + + /** + * 模型名称 + */ + @ExcelProperty(value = "模型名称") + private String modelName; + + /** + * 搜索参数 + */ + @ExcelProperty(value = "搜索参数") + private String searchParams; + + /** + * 响应时间(毫秒) + */ + @ExcelProperty(value = "响应时间") + private Long responseTime; + + /** + * 命中次数 + */ + @ExcelProperty(value = "命中次数") + private Integer hitCount; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeInfoVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeInfoVo.java new file mode 100644 index 0000000..4e48c3f --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeInfoVo.java @@ -0,0 +1,128 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgeInfo; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库信息视图对象 knowledge_info + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeInfo.class) +public class KnowledgeInfoVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 用户ID + */ + @ExcelProperty(value = "用户ID") + private Long userId; + + /** + * 知识库ID + */ + @ExcelProperty(value = "知识库ID") + private String knowledgeId; + + /** + * 知识库名称 + */ + @ExcelProperty(value = "知识库名称") + private String knowledgeName; + + /** + * 是否公开知识库:0否,1是 + */ + @ExcelProperty(value = "是否公开知识库") + private Integer share; + + /** + * 知识库描述 + */ + @ExcelProperty(value = "知识库描述") + private String description; + + /** + * 知识库分隔符 + */ + @ExcelProperty(value = "知识库分隔符") + private String knowledgeSeparator; + + /** + * 问题分隔符 + */ + @ExcelProperty(value = "问题分隔符") + private String questionSeparator; + + /** + * 重叠字符数 + */ + @ExcelProperty(value = "重叠字符数") + private Integer overlapChar; + + /** + * 检索限制 + */ + @ExcelProperty(value = "检索限制") + private Integer retrieveLimit; + + /** + * 文本块大小 + */ + @ExcelProperty(value = "文本块大小") + private Integer textBlockSize; + + /** + * 向量模型名称 + */ + @ExcelProperty(value = "向量模型名称") + private String vectorModelName; + + /** + * 嵌入模型名称 + */ + @ExcelProperty(value = "嵌入模型名称") + private String embeddingModelName; + + /** + * 系统提示词 + */ + @ExcelProperty(value = "系统提示词") + private String systemPrompt; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeJobVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeJobVo.java new file mode 100644 index 0000000..08456af --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeJobVo.java @@ -0,0 +1,170 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgeJob; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 知识库任务视图对象 knowledge_job + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeJob.class) +public class KnowledgeJobVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 任务ID + */ + @ExcelProperty(value = "任务ID") + private String jobId; + + /** + * 任务类型 + */ + @ExcelProperty(value = "任务类型") + private String jobType; + + /** + * 任务子类型 + */ + @ExcelProperty(value = "任务子类型") + private String jobSubType; + + /** + * 知识库ID + */ + @ExcelProperty(value = "知识库ID") + private String knowledgeId; + + /** + * 文档ID + */ + @ExcelProperty(value = "文档ID") + private String docId; + + /** + * 用户ID + */ + @ExcelProperty(value = "用户ID") + private Long userId; + + /** + * 任务状态 + */ + @ExcelProperty(value = "任务状态") + private Integer status; + + /** + * 任务进度 + */ + @ExcelProperty(value = "任务进度") + private Integer progress; + + /** + * 总项目数 + */ + @ExcelProperty(value = "总项目数") + private Integer totalItems; + + /** + * 已处理项目数 + */ + @ExcelProperty(value = "已处理项目数") + private Integer processedItems; + + /** + * 成功项目数 + */ + @ExcelProperty(value = "成功项目数") + private Integer successItems; + + /** + * 失败项目数 + */ + @ExcelProperty(value = "失败项目数") + private Integer failedItems; + + /** + * 任务优先级 + */ + @ExcelProperty(value = "任务优先级") + private Integer priority; + + /** + * 重试次数 + */ + @ExcelProperty(value = "重试次数") + private Integer retryCount; + + /** + * 最大重试次数 + */ + @ExcelProperty(value = "最大重试次数") + private Integer maxRetryCount; + + /** + * 任务开始时间 + */ + @ExcelProperty(value = "任务开始时间") + private Date startTime; + + /** + * 任务结束时间 + */ + @ExcelProperty(value = "任务结束时间") + private Date endTime; + + /** + * 任务超时时间(秒) + */ + @ExcelProperty(value = "任务超时时间") + private Integer timeout; + + /** + * 错误信息 + */ + @ExcelProperty(value = "错误信息") + private String errorInfo; + + /** + * 通知配置 + */ + @ExcelProperty(value = "通知配置") + private String notificationConfig; + + /** + * 通知状态 + */ + @ExcelProperty(value = "通知状态") + private Integer notificationStatus; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgePromptTemplateVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgePromptTemplateVo.java new file mode 100644 index 0000000..35fef5c --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgePromptTemplateVo.java @@ -0,0 +1,68 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgePromptTemplate; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库提示模板视图对象 knowledge_prompt_template + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgePromptTemplate.class) +public class KnowledgePromptTemplateVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "主键") + private Long id; + + /** + * 提示词模板名称 + */ + @ExcelProperty(value = "提示词模板名称") + private String templateName; + + /** + * 提示词模板内容 + */ + @ExcelProperty(value = "提示词模板内容") + private String templateContent; + + /** + * 提示词分类:knowledge知识库类型,chat对话类型,draw绘画类型 + */ + @ExcelProperty(value = "提示词分类") + private String category; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleGroupVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleGroupVo.java new file mode 100644 index 0000000..40172e8 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleGroupVo.java @@ -0,0 +1,77 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.knowledge.domain.KnowledgeRoleGroup; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库角色分组视图对象 knowledge_role_group + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeRoleGroup.class) +public class KnowledgeRoleGroupVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 分组名称 + */ + @ExcelProperty(value = "分组名称") + private String groupName; + + /** + * 分组编码 + */ + @ExcelProperty(value = "分组编码") + private String groupCode; + + /** + * 分组描述 + */ + @ExcelProperty(value = "分组描述") + private String groupDesc; + + /** + * 分组状态:0禁用,1启用 + */ + @ExcelProperty(value = "分组状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private Integer status; + + /** + * 排序 + */ + @ExcelProperty(value = "排序") + private Integer sort; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleRelationVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleRelationVo.java new file mode 100644 index 0000000..abe16fb --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleRelationVo.java @@ -0,0 +1,77 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.knowledge.domain.KnowledgeRoleRelation; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库角色关系视图对象 knowledge_role_relation + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeRoleRelation.class) +public class KnowledgeRoleRelationVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 角色ID + */ + @ExcelProperty(value = "角色ID") + private Long roleId; + + /** + * 分组ID + */ + @ExcelProperty(value = "分组ID") + private Long groupId; + + /** + * 关系类型:1角色属于分组,2分组包含角色 + */ + @ExcelProperty(value = "关系类型") + private Integer relationType; + + /** + * 关系状态:0禁用,1启用 + */ + @ExcelProperty(value = "关系状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private Integer status; + + /** + * 排序 + */ + @ExcelProperty(value = "排序") + private Integer sort; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleVo.java new file mode 100644 index 0000000..d5d9d7d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeRoleVo.java @@ -0,0 +1,77 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.knowledge.domain.KnowledgeRole; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库角色视图对象 knowledge_role + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeRole.class) +public class KnowledgeRoleVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 角色名称 + */ + @ExcelProperty(value = "角色名称") + private String roleName; + + /** + * 角色编码 + */ + @ExcelProperty(value = "角色编码") + private String roleCode; + + /** + * 角色描述 + */ + @ExcelProperty(value = "角色描述") + private String roleDesc; + + /** + * 角色状态:0禁用,1启用 + */ + @ExcelProperty(value = "角色状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private Integer status; + + /** + * 排序 + */ + @ExcelProperty(value = "排序") + private Integer sort; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaFieldVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaFieldVo.java new file mode 100644 index 0000000..d2831fa --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaFieldVo.java @@ -0,0 +1,170 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgeSchemaField; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库数据模型字段视图对象 knowledge_schema_field + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeSchemaField.class) +public class KnowledgeSchemaFieldVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "主键") + private Long id; + + /** + * 模型ID + */ + @ExcelProperty(value = "模型ID") + private Long schemaId; + + /** + * 模型名称 + */ + @ExcelProperty(value = "模型名称") + private String schemaName; + + /** + * 字段名称 + */ + @ExcelProperty(value = "字段名称") + private String name; + + /** + * 字段编码 + */ + @ExcelProperty(value = "字段编码") + private String code; + + /** + * 字段类型 + */ + @ExcelProperty(value = "字段类型") + private String type; + + /** + * 字段注释 + */ + @ExcelProperty(value = "字段注释") + private String comment; + + /** + * 是否主键 + */ + @ExcelProperty(value = "是否主键") + private Integer isPk; + + /** + * 是否必填 + */ + @ExcelProperty(value = "是否必填") + private Integer isRequired; + + /** + * 是否唯一 + */ + @ExcelProperty(value = "是否唯一") + private Integer isUnique; + + /** + * 默认值 + */ + @ExcelProperty(value = "默认值") + private String defaultValue; + + /** + * 字段长度 + */ + @ExcelProperty(value = "字段长度") + private Integer length; + + /** + * 小数位数 + */ + @ExcelProperty(value = "小数位数") + private Integer scale; + + /** + * 是否列表显示 + */ + @ExcelProperty(value = "是否列表显示") + private Integer isList; + + /** + * 是否查询字段 + */ + @ExcelProperty(value = "是否查询字段") + private Integer isQuery; + + /** + * 是否插入字段 + */ + @ExcelProperty(value = "是否插入字段") + private Integer isInsert; + + /** + * 是否编辑字段 + */ + @ExcelProperty(value = "是否编辑字段") + private Integer isEdit; + + /** + * 查询方式 + */ + @ExcelProperty(value = "查询方式") + private String queryType; + + /** + * 显示类型 + */ + @ExcelProperty(value = "显示类型") + private String htmlType; + + /** + * 字典类型 + */ + @ExcelProperty(value = "字典类型") + private String dictType; + + /** + * 排序 + */ + @ExcelProperty(value = "排序") + private Integer sort; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaGroupVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaGroupVo.java new file mode 100644 index 0000000..426471b --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaGroupVo.java @@ -0,0 +1,68 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgeSchemaGroup; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库数据模型分组视图对象 knowledge_schema_group + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeSchemaGroup.class) +public class KnowledgeSchemaGroupVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "主键") + private Long id; + + /** + * 分组名称 + */ + @ExcelProperty(value = "分组名称") + private String name; + + /** + * 分组编码 + */ + @ExcelProperty(value = "分组编码") + private String code; + + /** + * 图标 + */ + @ExcelProperty(value = "图标") + private String icon; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaVo.java new file mode 100644 index 0000000..fd6a8de --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSchemaVo.java @@ -0,0 +1,74 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.knowledge.domain.KnowledgeSchema; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库数据模型视图对象 knowledge_schema + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeSchema.class) +public class KnowledgeSchemaVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "主键") + private Long id; + + /** + * 分组ID + */ + @ExcelProperty(value = "分组ID") + private Long schemaGroupId; + + /** + * 模型名称 + */ + @ExcelProperty(value = "模型名称") + private String name; + + /** + * 模型编码 + */ + @ExcelProperty(value = "模型编码") + private String code; + + /** + * 表名 + */ + @ExcelProperty(value = "表名") + private String tableName; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSearchResultVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSearchResultVo.java new file mode 100644 index 0000000..b8e4c61 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeSearchResultVo.java @@ -0,0 +1,73 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库检索结果视图对象 knowledge_search_result + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +public class KnowledgeSearchResultVo implements Serializable { + + /** + * 知识片段ID + */ + @ExcelProperty(value = "知识片段ID") + private String fragmentId; + + /** + * 文档ID + */ + @ExcelProperty(value = "文档ID") + private String docId; + + /** + * 文档名称 + */ + @ExcelProperty(value = "文档名称") + private String docName; + + /** + * 知识片段内容 + */ + @ExcelProperty(value = "内容") + private String content; + + /** + * 相似度分数 + */ + @ExcelProperty(value = "相似度分数") + private Double similarityScore; + + /** + * 排名位置 + */ + @ExcelProperty(value = "排名") + private Integer ranking; + + /** + * 片段索引 + */ + @ExcelProperty(value = "片段索引") + private Integer fragmentIndex; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeStatusVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeStatusVo.java new file mode 100644 index 0000000..8cf028f --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeStatusVo.java @@ -0,0 +1,107 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 知识库状态视图对象 knowledge_status + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +public class KnowledgeStatusVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 知识库ID + */ + @ExcelProperty(value = "知识库ID") + private String knowledgeId; + + /** + * 知识库名称 + */ + @ExcelProperty(value = "知识库名称") + private String knowledgeName; + + /** + * 文档总数 + */ + @ExcelProperty(value = "文档总数") + private Integer totalDocuments; + + /** + * 已处理文档数 + */ + @ExcelProperty(value = "已处理文档数") + private Integer processedDocuments; + + /** + * 知识片段总数 + */ + @ExcelProperty(value = "知识片段总数") + private Integer totalFragments; + + /** + * 已向量化片段数 + */ + @ExcelProperty(value = "已向量化片段数") + private Integer vectorizedFragments; + + /** + * 处理进度百分比 + */ + @ExcelProperty(value = "处理进度") + private Double progressPercentage; + + /** + * 当前处理状态 + */ + @ExcelProperty(value = "处理状态") + private String processingStatus; + + /** + * 正在进行的任务数 + */ + @ExcelProperty(value = "进行中任务数") + private Integer runningJobs; + + /** + * 等待中的任务数 + */ + @ExcelProperty(value = "等待中任务数") + private Integer pendingJobs; + + /** + * 失败的任务数 + */ + @ExcelProperty(value = "失败任务数") + private Integer failedJobs; + + /** + * 最后更新时间 + */ + @ExcelProperty(value = "最后更新时间") + private LocalDateTime lastUpdateTime; + + /** + * 错误信息 + */ + @ExcelProperty(value = "错误信息") + private String errorMessage; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeVectorVo.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeVectorVo.java new file mode 100644 index 0000000..0ade238 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/domain/vo/KnowledgeVectorVo.java @@ -0,0 +1,94 @@ +package org.dromara.knowledge.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.knowledge.domain.KnowledgeVector; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 知识片段向量视图对象 knowledge_vector + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = KnowledgeVector.class) +public class KnowledgeVectorVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @ExcelProperty(value = "主键ID") + private Long id; + + /** + * 知识库ID + */ + @ExcelProperty(value = "知识库ID") + private String knowledgeId; + + /** + * 文档ID + */ + @ExcelProperty(value = "文档ID") + private String docId; + + /** + * 知识片段ID + */ + @ExcelProperty(value = "知识片段ID") + private String fragmentId; + + /** + * 向量ID + */ + @ExcelProperty(value = "向量ID") + private String vectorId; + + /** + * 向量数据 + */ + @ExcelProperty(value = "向量数据") + private String vectorData; + + /** + * 向量维度 + */ + @ExcelProperty(value = "向量维度") + private Integer dimension; + + /** + * 模型名称 + */ + @ExcelProperty(value = "模型名称") + private String modelName; + + /** + * 状态(0正常 1停用) + */ + @ExcelProperty(value = "状态", converter = org.dromara.common.excel.convert.ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private Integer status; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/EmbeddingEngine.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/EmbeddingEngine.java new file mode 100644 index 0000000..892c39d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/EmbeddingEngine.java @@ -0,0 +1,13 @@ +package org.dromara.knowledge.embedding; + +/** + * 嵌入引擎接口(已弃用,请使用 EmbeddingService) + * + * @deprecated 请使用 {@link EmbeddingService} 接口 + * @author pengles + */ +@Deprecated +public interface EmbeddingEngine extends EmbeddingService { + // 此接口已弃用,所有实现应该使用 EmbeddingService 接口 +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/EmbeddingService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/EmbeddingService.java new file mode 100644 index 0000000..27fe056 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/EmbeddingService.java @@ -0,0 +1,5 @@ +package org.dromara.knowledge.embedding; + +public interface EmbeddingService { + double[] embed(String text, String modelName); +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/HttpEmbeddingService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/HttpEmbeddingService.java new file mode 100644 index 0000000..0a3a295 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/embedding/HttpEmbeddingService.java @@ -0,0 +1,73 @@ +package org.dromara.knowledge.embedding; + +import okhttp3.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +@Component +public class HttpEmbeddingService implements EmbeddingService { + + @Value("${ruoyi.ai.embedding.base-url:}") + private String baseUrl; + + @Value("${ruoyi.ai.embedding.default-model:bge-large-zh}") + private String defaultModel; + + private final OkHttpClient client = new OkHttpClient(); + private static final int CACHE_MAX = 1024; + private final java.util.Map cache = new java.util.LinkedHashMap<>(CACHE_MAX, 0.75f, true) { + @Override + protected boolean removeEldestEntry(java.util.Map.Entry eldest) { + return size() > CACHE_MAX; + } + }; + + @Override + public double[] embed(String text, String modelName) { + String model = modelName != null && !modelName.isBlank() ? modelName : defaultModel; + if (baseUrl == null || baseUrl.isBlank()) { + return mock(text, model); + } + try { + String key = model + "|" + Integer.toHexString(text.hashCode()); + double[] cached = cache.get(key); + if (cached != null) return cached; + String payload = "{\"model\":\"" + model + "\",\"input\":\"" + text.replace("\"", "\\\"") + "\"}"; + Request req = new Request.Builder() + .url(baseUrl + "/embeddings") + .post(RequestBody.create(MediaType.parse("application/json"), payload)) + .build(); + try (Response resp = client.newCall(req).execute()) { + String body = Objects.requireNonNull(resp.body()).string(); + String arr = body.replaceAll("[^\\d\\.,-]", ""); + String[] parts = arr.split(","); + double[] v = new double[parts.length]; + for (int i = 0; i < parts.length; i++) { + v[i] = Double.parseDouble(parts[i]); + } + cache.put(key, v); + return v; + } + } catch (Exception e) { + return mock(text, model); + } + } + + private double[] mock(String text, String model) { + int dim = switch (model) { + case "bge-large-zh" -> 1024; + case "bge-base-zh" -> 768; + default -> 768; + }; + double[] v = new double[dim]; + java.util.Random r = new java.util.Random(text.hashCode()); + for (int i = 0; i < dim; i++) v[i] = r.nextGaussian(); + double norm = 0; + for (double x : v) norm += x * x; + norm = Math.sqrt(norm); + for (int i = 0; i < v.length; i++) v[i] /= norm; + return v; + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/generation/DefaultGenerator.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/generation/DefaultGenerator.java new file mode 100644 index 0000000..7895c3b --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/generation/DefaultGenerator.java @@ -0,0 +1,97 @@ +package org.dromara.knowledge.generation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * 默认响应生成器实现 + * 基于检索到的上下文和查询生成响应 + * + * @author pengles + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DefaultGenerator implements Generator { + + @Override + public String generate(String query, String context, Map options) { + if (query == null || query.trim().isEmpty()) { + throw new ServiceException("查询内容不能为空"); + } + + // 构建提示词 + String prompt = buildPrompt(query, context, options); + + // 这里可以根据 options 中的配置选择不同的生成策略 + // 例如:调用 LLM API、使用模板生成等 + // 当前实现为简单拼接,实际应用中应该调用 LLM 服务 + + log.debug("生成响应,查询: {}, 上下文长度: {}", query, context != null ? context.length() : 0); + + // 简单的模板生成(实际应该调用 LLM) + return generateSimpleResponse(query, context); + } + + /** + * 构建提示词 + */ + private String buildPrompt(String query, String context, Map options) { + StringBuilder promptBuilder = new StringBuilder(); + + // 系统提示 + String systemPrompt = getOption(options, "systemPrompt", + "你是一个知识库助手,基于提供的上下文回答问题。如果上下文中没有相关信息,请说明。"); + promptBuilder.append(systemPrompt).append("\n\n"); + + // 上下文 + if (context != null && !context.trim().isEmpty()) { + promptBuilder.append("上下文:\n").append(context).append("\n\n"); + } + + // 查询 + promptBuilder.append("问题:").append(query).append("\n\n"); + promptBuilder.append("回答:"); + + return promptBuilder.toString(); + } + + /** + * 简单响应生成(占位实现) + * 实际应用中应该调用 LLM API + */ + private String generateSimpleResponse(String query, String context) { + if (context == null || context.trim().isEmpty()) { + return "抱歉,我无法在知识库中找到相关信息来回答您的问题。"; + } + + // 简单的摘要生成(实际应该使用 LLM) + // 这里只是占位实现,实际应该调用 LLM 服务 + return String.format("基于提供的上下文,相关内容如下:\n\n%s\n\n这是对您问题 '%s' 的回答。", + context.substring(0, Math.min(context.length(), 500)), query); + } + + /** + * 从选项 Map 中获取值 + */ + @SuppressWarnings("unchecked") + private T getOption(Map options, String key, T defaultValue) { + if (options == null) { + return defaultValue; + } + Object value = options.get(key); + return value != null ? (T) value : defaultValue; + } + + /** + * 生成响应(带默认选项) + */ + public String generate(String query, String context) { + return generate(query, context, new HashMap<>()); + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/generation/Generator.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/generation/Generator.java new file mode 100644 index 0000000..87405a0 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/generation/Generator.java @@ -0,0 +1,8 @@ +package org.dromara.knowledge.generation; + +import java.util.Map; + +public interface Generator { + String generate(String query, String context, Map options); +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeAttachMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeAttachMapper.java new file mode 100644 index 0000000..c913edc --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeAttachMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeAttach; +import org.dromara.knowledge.domain.vo.KnowledgeAttachVo; + +/** + * 知识库附件Mapper接口 + * + * @author pengles + */ +public interface KnowledgeAttachMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeFragmentMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeFragmentMapper.java new file mode 100644 index 0000000..796217b --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeFragmentMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeFragment; +import org.dromara.knowledge.domain.vo.KnowledgeFragmentVo; + +/** + * 知识库片段Mapper接口 + * + * @author pengles + */ +public interface KnowledgeFragmentMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeHitLogMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeHitLogMapper.java new file mode 100644 index 0000000..703f627 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeHitLogMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeHitLog; +import org.dromara.knowledge.domain.vo.KnowledgeHitLogVo; + +/** + * 知识库命中日志Mapper接口 + * + * @author pengles + */ +public interface KnowledgeHitLogMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeInfoMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeInfoMapper.java new file mode 100644 index 0000000..1288684 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeInfoMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeInfo; +import org.dromara.knowledge.domain.vo.KnowledgeInfoVo; + +/** + * 知识库信息Mapper接口 + * + * @author pengles + */ +public interface KnowledgeInfoMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeJobMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeJobMapper.java new file mode 100644 index 0000000..b29a098 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeJobMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeJob; +import org.dromara.knowledge.domain.vo.KnowledgeJobVo; + +/** + * 知识库任务Mapper接口 + * + * @author pengles + */ +public interface KnowledgeJobMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgePromptTemplateMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgePromptTemplateMapper.java new file mode 100644 index 0000000..e07fcbc --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgePromptTemplateMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgePromptTemplate; +import org.dromara.knowledge.domain.vo.KnowledgePromptTemplateVo; + +/** + * 知识库提示模板Mapper接口 + * + * @author pengles + */ +public interface KnowledgePromptTemplateMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleGroupMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleGroupMapper.java new file mode 100644 index 0000000..df0a855 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleGroupMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeRoleGroup; +import org.dromara.knowledge.domain.vo.KnowledgeRoleGroupVo; + +/** + * 知识库角色分组Mapper接口 + * + * @author pengles + */ +public interface KnowledgeRoleGroupMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleMapper.java new file mode 100644 index 0000000..55c5ef1 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeRole; +import org.dromara.knowledge.domain.vo.KnowledgeRoleVo; + +/** + * 知识库角色Mapper接口 + * + * @author pengles + */ +public interface KnowledgeRoleMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleRelationMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleRelationMapper.java new file mode 100644 index 0000000..a2f2f5d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeRoleRelationMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeRoleRelation; +import org.dromara.knowledge.domain.vo.KnowledgeRoleRelationVo; + +/** + * 知识库角色关系Mapper接口 + * + * @author pengles + */ +public interface KnowledgeRoleRelationMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaFieldMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaFieldMapper.java new file mode 100644 index 0000000..101699d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaFieldMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeSchemaField; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaFieldVo; + +/** + * 知识库数据模型字段Mapper接口 + * + * @author pengles + */ +public interface KnowledgeSchemaFieldMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaGroupMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaGroupMapper.java new file mode 100644 index 0000000..472e8d5 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaGroupMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeSchemaGroup; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaGroupVo; + +/** + * 知识库数据模型分组Mapper接口 + * + * @author pengles + */ +public interface KnowledgeSchemaGroupMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaMapper.java new file mode 100644 index 0000000..c7f1df3 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeSchemaMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeSchema; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaVo; + +/** + * 知识库数据模型Mapper接口 + * + * @author pengles + */ +public interface KnowledgeSchemaMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeVectorMapper.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeVectorMapper.java new file mode 100644 index 0000000..abcad56 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/mapper/KnowledgeVectorMapper.java @@ -0,0 +1,14 @@ +package org.dromara.knowledge.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.knowledge.domain.KnowledgeVector; +import org.dromara.knowledge.domain.vo.KnowledgeVectorVo; + +/** + * 知识片段向量Mapper接口 + * + * @author pengles + */ +public interface KnowledgeVectorMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/DefaultIndexUpdater.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/DefaultIndexUpdater.java new file mode 100644 index 0000000..6f48c84 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/DefaultIndexUpdater.java @@ -0,0 +1,19 @@ +package org.dromara.knowledge.retrieval; + +import lombok.RequiredArgsConstructor; +import org.dromara.knowledge.service.KnowledgeDocumentService; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DefaultIndexUpdater implements IndexUpdater { + private final KnowledgeDocumentService knowledgeDocumentService; + + @Override + public String updateFragments(String knowledgeId, String docId) { + // 触发分片重建,依赖 KnowledgeDocumentService 的分片逻辑 + // 返回任务ID用于追踪 + return knowledgeDocumentService.processDocumentContent(knowledgeId, docId, "", null); + } +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/DefaultRetriever.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/DefaultRetriever.java new file mode 100644 index 0000000..47426ad --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/DefaultRetriever.java @@ -0,0 +1,20 @@ +package org.dromara.knowledge.retrieval; + +import lombok.RequiredArgsConstructor; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.service.KnowledgeVectorService; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class DefaultRetriever implements Retriever { + private final KnowledgeVectorService knowledgeVectorService; + + @Override + public List search(String knowledgeId, String query, String model, int topK, double threshold) { + return knowledgeVectorService.vectorSimilaritySearch(knowledgeId, query, model, topK, threshold); + } +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/IndexUpdater.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/IndexUpdater.java new file mode 100644 index 0000000..d3afbc8 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/IndexUpdater.java @@ -0,0 +1,6 @@ +package org.dromara.knowledge.retrieval; + +public interface IndexUpdater { + String updateFragments(String knowledgeId, String docId); +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/Retriever.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/Retriever.java new file mode 100644 index 0000000..010832a --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/retrieval/Retriever.java @@ -0,0 +1,10 @@ +package org.dromara.knowledge.retrieval; + +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; + +import java.util.List; + +public interface Retriever { + List search(String knowledgeId, String query, String model, int topK, double threshold); +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeAttachService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeAttachService.java new file mode 100644 index 0000000..e9ff940 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeAttachService.java @@ -0,0 +1,59 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeAttach; +import org.dromara.knowledge.domain.bo.KnowledgeAttachBo; +import org.dromara.knowledge.domain.vo.KnowledgeAttachVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库附件Service接口 + * + * @author pengles + */ +public interface KnowledgeAttachService extends IService { + + /** + * 查询知识库附件 + */ + KnowledgeAttachVo queryById(Long id); + + /** + * 查询知识库附件列表 + */ + TableDataInfo queryPageList(KnowledgeAttachBo bo, PageQuery pageQuery); + + /** + * 查询知识库附件列表 + */ + List queryList(KnowledgeAttachBo bo); + + /** + * 新增知识库附件 + */ + Boolean insertByBo(KnowledgeAttachBo bo); + + /** + * 修改知识库附件 + */ + Boolean updateByBo(KnowledgeAttachBo bo); + + /** + * 校验并批量删除知识库附件 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据知识库ID查询附件列表 + */ + List queryListByKnowledgeId(String knowledgeId); + + /** + * 根据文档ID查询附件信息 + */ + KnowledgeAttachVo queryByDocId(String docId); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeDocumentService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeDocumentService.java new file mode 100644 index 0000000..e69db09 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeDocumentService.java @@ -0,0 +1,97 @@ +package org.dromara.knowledge.service; + +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 知识库文档处理Service接口 + * + * @author pengles + */ +public interface KnowledgeDocumentService { + + /** + * 处理上传的文档 + * + * @param knowledgeId 知识库ID + * @param file 上传的文件 + * @return 文档ID + */ + String processUploadedDocument(String knowledgeId, MultipartFile file); + + /** + * 将文档内容分割成知识片段 + * + * @param knowledgeId 知识库ID + * @param docId 文档ID + * @param content 文档内容 + * @param chunkSize 分片大小 + * @param overlapSize 重叠大小 + * @return 知识片段ID列表 + */ + List splitDocumentIntoFragments(String knowledgeId, String docId, String content, + Integer chunkSize, Integer overlapSize); + + /** + * 提取文档文本内容 + * + * @param file 文件 + * @return 文本内容 + */ + String extractTextContent(MultipartFile file); + + /** + * 清理和预处理文本内容 + * + * @param content 原始内容 + * @return 清理后的内容 + */ + String cleanTextContent(String content); + + /** + * 验证文件类型是否支持 + * + * @param file 文件 + * @return 是否支持 + */ + boolean isSupportedFileType(MultipartFile file); + + /** + * 获取支持的文件类型列表 + * + * @return 支持的文件类型列表 + */ + List getSupportedFileTypes(); + + /** + * 获取知识库处理状态 + * + * @param knowledgeId 知识库ID + * @param knowledgeInfo 知识库信息 + * @return 知识库状态信息 + */ + org.dromara.knowledge.domain.vo.KnowledgeStatusVo getKnowledgeProcessingStatus(Long knowledgeId, org.dromara.knowledge.domain.vo.KnowledgeInfoVo knowledgeInfo); + + /** + * 处理文档内容并生成知识片段 + * + * @param knowledgeId 知识库ID + * @param docId 文档ID + * @param content 文档内容 + * @param knowledgeInfo 知识库信息 + * @return 处理任务ID + */ + String processDocumentContent(String knowledgeId, String docId, String content, org.dromara.knowledge.domain.KnowledgeInfo knowledgeInfo); + + /** + * 对知识片段进行向量化 + * + * @param knowledgeId 知识库ID + * @param fragmentId 知识片段ID + * @param knowledgeInfo 知识库信息 + * @return 向量化任务ID + */ + String vectorizeKnowledgeFragment(String knowledgeId, String fragmentId, org.dromara.knowledge.domain.KnowledgeInfo knowledgeInfo); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeFragmentService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeFragmentService.java new file mode 100644 index 0000000..d30134d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeFragmentService.java @@ -0,0 +1,64 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeFragment; +import org.dromara.knowledge.domain.bo.KnowledgeFragmentBo; +import org.dromara.knowledge.domain.vo.KnowledgeFragmentVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库片段Service接口 + * + * @author pengles + */ +public interface KnowledgeFragmentService extends IService { + + /** + * 查询知识库片段 + */ + KnowledgeFragmentVo queryById(Long id); + + /** + * 查询知识库片段列表 + */ + TableDataInfo queryPageList(KnowledgeFragmentBo bo, PageQuery pageQuery); + + /** + * 查询知识库片段列表 + */ + List queryList(KnowledgeFragmentBo bo); + + /** + * 新增知识库片段 + */ + Boolean insertByBo(KnowledgeFragmentBo bo); + + /** + * 修改知识库片段 + */ + Boolean updateByBo(KnowledgeFragmentBo bo); + + /** + * 校验并批量删除知识库片段 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据知识库ID查询片段列表 + */ + List queryListByKnowledgeId(Long knowledgeId); + + /** + * 根据文档ID查询片段列表 + */ + List queryListByDocId(String docId); + + /** + * 根据片段ID查询片段信息 + */ + KnowledgeFragmentVo queryByFid(String fid); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeHitLogService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeHitLogService.java new file mode 100644 index 0000000..4f3afe5 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeHitLogService.java @@ -0,0 +1,126 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeHitLog; +import org.dromara.knowledge.domain.bo.KnowledgeHitLogBo; +import org.dromara.knowledge.domain.vo.KnowledgeHitLogVo; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 知识库命中日志Service接口 + * + * @author pengles + */ +public interface KnowledgeHitLogService extends IService { + + /** + * 查询知识库命中日志 + */ + KnowledgeHitLogVo queryById(Long id); + + /** + * 查询知识库命中日志列表 + */ + TableDataInfo queryPageList(KnowledgeHitLogBo bo, PageQuery pageQuery); + + /** + * 查询知识库命中日志列表 + */ + List queryList(KnowledgeHitLogBo bo); + + /** + * 新增知识库命中日志 + */ + Boolean insertByBo(KnowledgeHitLogBo bo); + + /** + * 修改知识库命中日志 + */ + Boolean updateByBo(KnowledgeHitLogBo bo); + + /** + * 校验并批量删除知识库命中日志信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据会话ID查询知识库命中日志列表 + */ + List queryBySessionId(String sessionId); + + /** + * 根据知识库ID查询知识库命中日志列表 + */ + List queryByKnowledgeId(String knowledgeId); + + /** + * 根据文档ID查询知识库命中日志列表 + */ + List queryByDocId(String docId); + + /** + * 根据知识片段ID查询知识库命中日志列表 + */ + List queryByFragmentId(String fragmentId); + + /** + * 根据模型名称查询知识库命中日志列表 + */ + List queryByModelName(String modelName); + + /** + * 根据相似度分数范围查询知识库命中日志列表 + */ + List queryBySimilarityRange(Double minScore, Double maxScore); + + /** + * 根据查询文本查询知识库命中日志列表 + */ + List queryByQueryText(String queryText); + + /** + * 统计知识库命中次数 + */ + Long countHitsByKnowledgeId(String knowledgeId); + + /** + * 统计文档命中次数 + */ + Long countHitsByDocId(String docId); + + /** + * 统计知识片段命中次数 + */ + Long countHitsByFragmentId(String fragmentId); + + /** + * 获取热门查询列表 + */ + List> getPopularQueries(String knowledgeId, Integer limit); + + /** + * 获取平均响应时间 + */ + Double getAverageResponseTime(String knowledgeId); + + /** + * 根据排名查询知识库命中日志列表 + */ + List queryByRanking(Integer ranking); + + /** + * 根据响应时间范围查询知识库命中日志列表 + */ + List queryByResponseTimeRange(Long minTime, Long maxTime); + + /** + * 批量插入知识库命中日志 + */ + Boolean batchInsert(List boList); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeInfoService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeInfoService.java new file mode 100644 index 0000000..ae8b298 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeInfoService.java @@ -0,0 +1,117 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeInfo; +import org.dromara.knowledge.domain.bo.KnowledgeInfoBo; +import org.dromara.knowledge.domain.vo.KnowledgeInfoVo; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.domain.vo.KnowledgeStatusVo; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库信息Service接口 + * + * @author pengles + */ +public interface KnowledgeInfoService extends IService { + + /** + * 查询知识库信息 + */ + KnowledgeInfoVo queryById(Long id); + + /** + * 查询知识库信息列表 + */ + TableDataInfo queryPageList(KnowledgeInfoBo bo, PageQuery pageQuery); + + /** + * 查询知识库信息列表 + */ + List queryList(KnowledgeInfoBo bo); + + /** + * 新增知识库信息 + */ + Boolean insertByBo(KnowledgeInfoBo bo); + + /** + * 修改知识库信息 + */ + Boolean updateByBo(KnowledgeInfoBo bo); + + /** + * 校验并批量删除知识库信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据用户ID查询知识库列表 + */ + List queryListByUserId(Long userId); + + /** + * 根据知识库ID查询知识库信息 + */ + KnowledgeInfoVo queryByKnowledgeId(Long knowledgeId); + + /** + * 上传文档到知识库 + * + * @param knowledgeId 知识库ID + * @param file 上传的文件 + * @return 文档ID + */ + String uploadDocument(String knowledgeId, MultipartFile file); + + /** + * 批量上传文档到知识库 + * + * @param knowledgeId 知识库ID + * @param files 上传的文件列表 + * @return 文档ID列表 + */ + List batchUploadDocuments(String knowledgeId, MultipartFile[] files); + + /** + * 检索知识库 + * + * @param knowledgeId 知识库ID + * @param query 查询文本 + * @param limit 返回结果数量限制 + * @return 检索结果列表 + */ + List searchKnowledge(String knowledgeId, String query, Integer limit); + + /** + * 获取知识库处理状态 + * + * @param knowledgeId 知识库ID + * @return 知识库状态信息 + */ + KnowledgeStatusVo getKnowledgeStatus(String knowledgeId); + + /** + * 处理文档并生成知识片段 + * + * @param knowledgeId 知识库ID + * @param docId 文档ID + * @param content 文档内容 + * @return 处理任务ID + */ + String processDocument(String knowledgeId, String docId, String content); + + /** + * 对知识片段进行向量化 + * + * @param knowledgeId 知识库ID + * @param fragmentId 知识片段ID + * @return 向量化任务ID + */ + String vectorizeFragment(String knowledgeId, String fragmentId); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeJobService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeJobService.java new file mode 100644 index 0000000..2d05c7a --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeJobService.java @@ -0,0 +1,125 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeJob; +import org.dromara.knowledge.domain.bo.KnowledgeJobBo; +import org.dromara.knowledge.domain.vo.KnowledgeJobVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库任务Service接口 + * + * @author pengles + */ +public interface KnowledgeJobService extends IService { + + /** + * 查询知识库任务 + */ + KnowledgeJobVo queryById(Long id); + + /** + * 查询知识库任务列表 + */ + TableDataInfo queryPageList(KnowledgeJobBo bo, PageQuery pageQuery); + + /** + * 查询知识库任务列表 + */ + List queryList(KnowledgeJobBo bo); + + /** + * 新增知识库任务 + */ + Boolean insertByBo(KnowledgeJobBo bo); + + /** + * 修改知识库任务 + */ + Boolean updateByBo(KnowledgeJobBo bo); + + /** + * 校验并批量删除知识库任务信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据任务ID查询知识库任务 + */ + KnowledgeJobVo queryByJobId(String jobId); + + /** + * 根据知识库ID查询知识库任务列表 + */ + List queryByKnowledgeId(String knowledgeId); + + /** + * 根据文档ID查询知识库任务列表 + */ + List queryByDocId(String docId); + + /** + * 根据用户ID查询知识库任务列表 + */ + List queryByUserId(Long userId); + + /** + * 根据任务类型查询知识库任务列表 + */ + List queryByJobType(String jobType); + + /** + * 根据任务状态查询知识库任务列表 + */ + List queryByStatus(Integer status); + + /** + * 查询正在运行的任务列表 + */ + List queryRunningJobs(); + + /** + * 查询待处理的任务列表 + */ + List queryPendingJobs(); + + /** + * 查询失败的任务列表 + */ + List queryFailedJobs(); + + /** + * 更新任务进度 + */ + Boolean updateProgress(Long id, Integer progress); + + /** + * 更新任务状态 + */ + Boolean updateStatus(Long id, Integer status); + + /** + * 增加重试次数 + */ + Boolean incrementRetryCount(Long id); + + /** + * 更新任务项目计数 + */ + Boolean updateItemCounts(Long id, Integer totalItems, Integer processedItems, Integer successItems, Integer failedItems); + + /** + * 根据优先级查询任务列表 + */ + List queryByPriority(Integer priority); + + /** + * 查询超时的任务列表 + */ + List queryTimeoutJobs(); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgePromptTemplateService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgePromptTemplateService.java new file mode 100644 index 0000000..f8366c9 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgePromptTemplateService.java @@ -0,0 +1,59 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgePromptTemplate; +import org.dromara.knowledge.domain.bo.KnowledgePromptTemplateBo; +import org.dromara.knowledge.domain.vo.KnowledgePromptTemplateVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库提示模板Service接口 + * + * @author pengles + */ +public interface KnowledgePromptTemplateService extends IService { + + /** + * 查询知识库提示模板 + */ + KnowledgePromptTemplateVo queryById(Long id); + + /** + * 查询知识库提示模板列表 + */ + TableDataInfo queryPageList(KnowledgePromptTemplateBo bo, PageQuery pageQuery); + + /** + * 查询知识库提示模板列表 + */ + List queryList(KnowledgePromptTemplateBo bo); + + /** + * 新增知识库提示模板 + */ + Boolean insertByBo(KnowledgePromptTemplateBo bo); + + /** + * 修改知识库提示模板 + */ + Boolean updateByBo(KnowledgePromptTemplateBo bo); + + /** + * 校验并批量删除知识库提示模板 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据分类查询模板列表 + */ + List queryListByCategory(String category); + + /** + * 根据模板名称查询模板信息 + */ + KnowledgePromptTemplateVo queryByTemplateName(String templateName); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleGroupService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleGroupService.java new file mode 100644 index 0000000..ebc8b2d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleGroupService.java @@ -0,0 +1,60 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeRoleGroup; +import org.dromara.knowledge.domain.bo.KnowledgeRoleGroupBo; +import org.dromara.knowledge.domain.vo.KnowledgeRoleGroupVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库角色分组Service接口 + * + * @author pengles + */ +public interface KnowledgeRoleGroupService extends IService { + + /** + * 查询知识库角色分组 + */ + KnowledgeRoleGroupVo queryById(Long id); + + /** + * 查询知识库角色分组列表 + */ + TableDataInfo queryPageList(KnowledgeRoleGroupBo bo, PageQuery pageQuery); + + /** + * 查询知识库角色分组列表 + */ + List queryList(KnowledgeRoleGroupBo bo); + + /** + * 新增知识库角色分组 + */ + Boolean insertByBo(KnowledgeRoleGroupBo bo); + + /** + * 修改知识库角色分组 + */ + Boolean updateByBo(KnowledgeRoleGroupBo bo); + + /** + * 校验并批量删除知识库角色分组 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据分组编码查询分组信息 + */ + KnowledgeRoleGroupVo queryByGroupCode(String groupCode); + + /** + * 查询启用状态的分组列表 + */ + List queryEnabledList(); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleRelationService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleRelationService.java new file mode 100644 index 0000000..289de67 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleRelationService.java @@ -0,0 +1,65 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeRoleRelation; +import org.dromara.knowledge.domain.bo.KnowledgeRoleRelationBo; +import org.dromara.knowledge.domain.vo.KnowledgeRoleRelationVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库角色关系Service接口 + * + * @author pengles + */ +public interface KnowledgeRoleRelationService extends IService { + + /** + * 查询知识库角色关系 + */ + KnowledgeRoleRelationVo queryById(Long id); + + /** + * 查询知识库角色关系列表 + */ + TableDataInfo queryPageList(KnowledgeRoleRelationBo bo, PageQuery pageQuery); + + /** + * 查询知识库角色关系列表 + */ + List queryList(KnowledgeRoleRelationBo bo); + + /** + * 新增知识库角色关系 + */ + Boolean insertByBo(KnowledgeRoleRelationBo bo); + + /** + * 修改知识库角色关系 + */ + Boolean updateByBo(KnowledgeRoleRelationBo bo); + + /** + * 校验并批量删除知识库角色关系 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据角色ID查询关系列表 + */ + List queryByRoleId(Long roleId); + + /** + * 根据分组ID查询关系列表 + */ + List queryByGroupId(Long groupId); + + /** + * 根据角色ID和分组ID查询关系 + */ + KnowledgeRoleRelationVo queryByRoleIdAndGroupId(Long roleId, Long groupId); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleService.java new file mode 100644 index 0000000..17887c2 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeRoleService.java @@ -0,0 +1,60 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeRole; +import org.dromara.knowledge.domain.bo.KnowledgeRoleBo; +import org.dromara.knowledge.domain.vo.KnowledgeRoleVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库角色Service接口 + * + * @author pengles + */ +public interface KnowledgeRoleService extends IService { + + /** + * 查询知识库角色 + */ + KnowledgeRoleVo queryById(Long id); + + /** + * 查询知识库角色列表 + */ + TableDataInfo queryPageList(KnowledgeRoleBo bo, PageQuery pageQuery); + + /** + * 查询知识库角色列表 + */ + List queryList(KnowledgeRoleBo bo); + + /** + * 新增知识库角色 + */ + Boolean insertByBo(KnowledgeRoleBo bo); + + /** + * 修改知识库角色 + */ + Boolean updateByBo(KnowledgeRoleBo bo); + + /** + * 校验并批量删除知识库角色 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据角色编码查询角色信息 + */ + KnowledgeRoleVo queryByRoleCode(String roleCode); + + /** + * 查询启用状态的角色列表 + */ + List queryEnabledList(); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaFieldService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaFieldService.java new file mode 100644 index 0000000..a4d8e14 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaFieldService.java @@ -0,0 +1,64 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeSchemaField; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaFieldBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaFieldVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库数据模型字段Service接口 + * + * @author pengles + */ +public interface KnowledgeSchemaFieldService extends IService { + + /** + * 查询知识库模式字段 + */ + KnowledgeSchemaFieldVo queryById(Long id); + + /** + * 查询知识库模式字段列表 + */ + TableDataInfo queryPageList(KnowledgeSchemaFieldBo bo, PageQuery pageQuery); + + /** + * 查询知识库模式字段列表 + */ + List queryList(KnowledgeSchemaFieldBo bo); + + /** + * 新增知识库模式字段 + */ + Boolean insertByBo(KnowledgeSchemaFieldBo bo); + + /** + * 修改知识库模式字段 + */ + Boolean updateByBo(KnowledgeSchemaFieldBo bo); + + /** + * 校验并批量删除知识库模式字段 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据模式ID查询字段列表 + */ + List queryListBySchemaId(Long schemaId); + + /** + * 根据编码查询字段信息 + */ + KnowledgeSchemaFieldVo queryByCode(String code); + + /** + * 根据模式ID和字段编码查询字段信息 + */ + KnowledgeSchemaFieldVo queryBySchemaIdAndCode(Long schemaId, String code); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaGroupService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaGroupService.java new file mode 100644 index 0000000..47cb3fc --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaGroupService.java @@ -0,0 +1,59 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeSchemaGroup; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaGroupBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaGroupVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库数据模型分组Service接口 + * + * @author pengles + */ +public interface KnowledgeSchemaGroupService extends IService { + + /** + * 查询知识库模式组 + */ + KnowledgeSchemaGroupVo queryById(Long id); + + /** + * 查询知识库模式组列表 + */ + TableDataInfo queryPageList(KnowledgeSchemaGroupBo bo, PageQuery pageQuery); + + /** + * 查询知识库模式组列表 + */ + List queryList(KnowledgeSchemaGroupBo bo); + + /** + * 新增知识库模式组 + */ + Boolean insertByBo(KnowledgeSchemaGroupBo bo); + + /** + * 修改知识库模式组 + */ + Boolean updateByBo(KnowledgeSchemaGroupBo bo); + + /** + * 校验并批量删除知识库模式组 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据编码查询模式组信息 + */ + KnowledgeSchemaGroupVo queryByCode(String code); + + /** + * 根据名称查询模式组信息 + */ + KnowledgeSchemaGroupVo queryByName(String name); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaService.java new file mode 100644 index 0000000..5c375fb --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeSchemaService.java @@ -0,0 +1,64 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeSchema; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaVo; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库数据模型Service接口 + * + * @author pengles + */ +public interface KnowledgeSchemaService extends IService { + + /** + * 查询知识库模式 + */ + KnowledgeSchemaVo queryById(Long id); + + /** + * 查询知识库模式列表 + */ + TableDataInfo queryPageList(KnowledgeSchemaBo bo, PageQuery pageQuery); + + /** + * 查询知识库模式列表 + */ + List queryList(KnowledgeSchemaBo bo); + + /** + * 新增知识库模式 + */ + Boolean insertByBo(KnowledgeSchemaBo bo); + + /** + * 修改知识库模式 + */ + Boolean updateByBo(KnowledgeSchemaBo bo); + + /** + * 校验并批量删除知识库模式 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据模式组ID查询模式列表 + */ + List queryListBySchemaGroupId(Long schemaGroupId); + + /** + * 根据编码查询模式信息 + */ + KnowledgeSchemaVo queryByCode(String code); + + /** + * 根据表名查询模式信息 + */ + KnowledgeSchemaVo queryByTableName(String tableName); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeVectorService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeVectorService.java new file mode 100644 index 0000000..36f7070 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/KnowledgeVectorService.java @@ -0,0 +1,186 @@ +package org.dromara.knowledge.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeVector; +import org.dromara.knowledge.domain.bo.KnowledgeVectorBo; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.domain.vo.KnowledgeVectorVo; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 知识片段向量Service接口 + * + * @author pengles + */ +public interface KnowledgeVectorService extends IService { + + /** + * 查询知识片段向量 + */ + KnowledgeVectorVo queryById(Long id); + + /** + * 查询知识片段向量列表 + */ + TableDataInfo queryPageList(KnowledgeVectorBo bo, PageQuery pageQuery); + + /** + * 查询知识片段向量列表 + */ + List queryList(KnowledgeVectorBo bo); + + /** + * 新增知识片段向量 + */ + Boolean insertByBo(KnowledgeVectorBo bo); + + /** + * 修改知识片段向量 + */ + Boolean updateByBo(KnowledgeVectorBo bo); + + /** + * 校验并批量删除知识片段向量信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据向量ID查询知识片段向量 + */ + KnowledgeVectorVo queryByVectorId(String vectorId); + + /** + * 根据知识库ID查询知识片段向量列表 + */ + List queryByKnowledgeId(String knowledgeId); + + /** + * 根据文档ID查询知识片段向量列表 + */ + List queryByDocId(String docId); + + /** + * 根据知识片段ID查询知识片段向量列表 + */ + List queryByFragmentId(String fragmentId); + + /** + * 根据模型名称查询知识片段向量列表 + */ + List queryByModelName(String modelName); + + /** + * 根据状态查询知识片段向量列表 + */ + List queryByStatus(Integer status); + + /** + * 批量更新知识片段向量状态 + */ + Boolean updateStatusByIds(Collection ids, Integer status); + + /** + * 根据知识库ID和状态查询知识片段向量列表 + */ + List queryByKnowledgeIdAndStatus(String knowledgeId, Integer status); + + /** + * 为知识片段生成向量 + * + * @param knowledgeId 知识库ID + * @param docId 文档ID + * @param fragmentId 片段ID + * @param content 片段内容 + * @param modelName 向量模型名称 + * @return 向量ID + */ + String generateVector(String knowledgeId, String docId, String fragmentId, String content, String modelName); + + /** + * 批量为知识片段生成向量 + * + * @param knowledgeId 知识库ID + * @param docId 文档ID + * @param fragmentContents 片段内容映射 (fragmentId -> content) + * @param modelName 向量模型名称 + * @return 成功生成向量的片段ID列表 + */ + List batchGenerateVectors(String knowledgeId, String docId, + Map fragmentContents, String modelName); + + /** + * 向量相似度搜索 + * + * @param knowledgeId 知识库ID + * @param queryText 查询文本 + * @param modelName 向量模型名称 + * @param topK 返回结果数量 + * @param threshold 相似度阈值 + * @return 搜索结果列表 + */ + List vectorSimilaritySearch(String knowledgeId, String queryText, + String modelName, Integer topK, Double threshold); + + /** + * 文本转向量 + * + * @param text 文本内容 + * @param modelName 模型名称 + * @return 向量数据 + */ + double[] textToVector(String text, String modelName); + + /** + * 计算向量相似度 + * + * @param vector1 向量1 + * @param vector2 向量2 + * @return 相似度分数 (0-1) + */ + double calculateSimilarity(double[] vector1, double[] vector2); + + /** + * 删除知识库相关的所有向量 + * + * @param knowledgeId 知识库ID + * @return 删除的向量数量 + */ + int deleteVectorsByKnowledgeId(String knowledgeId); + + /** + * 删除文档相关的所有向量 + * + * @param docId 文档ID + * @return 删除的向量数量 + */ + int deleteVectorsByDocId(String docId); + + /** + * 获取知识库向量统计信息 + * + * @param knowledgeId 知识库ID + * @return 统计信息映射 + */ + Map getVectorStatistics(String knowledgeId); + + /** + * 检查向量模型是否可用 + * + * @param modelName 模型名称 + * @return 是否可用 + */ + boolean isModelAvailable(String modelName); + + /** + * 获取可用的向量模型列表 + * + * @return 模型名称列表 + */ + List getAvailableModels(); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeAttachServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeAttachServiceImpl.java new file mode 100644 index 0000000..8c3156c --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeAttachServiceImpl.java @@ -0,0 +1,135 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeAttach; +import org.dromara.knowledge.domain.bo.KnowledgeAttachBo; +import org.dromara.knowledge.domain.vo.KnowledgeAttachVo; +import org.dromara.knowledge.mapper.KnowledgeAttachMapper; +import org.dromara.knowledge.service.KnowledgeAttachService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库附件Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeAttachServiceImpl extends ServiceImpl implements KnowledgeAttachService { + + private final KnowledgeAttachMapper baseMapper; + + /** + * 查询知识库附件 + */ + @Override + public KnowledgeAttachVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库附件列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeAttachBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库附件列表 + */ + @Override + public List queryList(KnowledgeAttachBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeAttachBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getKnowledgeId()), KnowledgeAttach::getKnowledgeId, bo.getKnowledgeId()); + lqw.eq(StringUtils.isNotBlank(bo.getDocId()), KnowledgeAttach::getDocId, bo.getDocId()); + lqw.like(StringUtils.isNotBlank(bo.getDocName()), KnowledgeAttach::getDocName, bo.getDocName()); + lqw.eq(StringUtils.isNotBlank(bo.getDocType()), KnowledgeAttach::getDocType, bo.getDocType()); + lqw.eq(ObjectUtil.isNotNull(bo.getOssId()), KnowledgeAttach::getOssId, bo.getOssId()); + lqw.eq(ObjectUtil.isNotNull(bo.getPicStatus()), KnowledgeAttach::getPicStatus, bo.getPicStatus()); + lqw.eq(ObjectUtil.isNotNull(bo.getPicAnysStatus()), KnowledgeAttach::getPicAnysStatus, bo.getPicAnysStatus()); + lqw.eq(ObjectUtil.isNotNull(bo.getVectorStatus()), KnowledgeAttach::getVectorStatus, bo.getVectorStatus()); + return lqw; + } + + /** + * 新增知识库附件 + */ + @Override + public Boolean insertByBo(KnowledgeAttachBo bo) { + KnowledgeAttach add = BeanUtil.toBean(bo, KnowledgeAttach.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库附件 + */ + @Override + public Boolean updateByBo(KnowledgeAttachBo bo) { + KnowledgeAttach update = BeanUtil.toBean(bo, KnowledgeAttach.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeAttach entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除知识库附件 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据知识库ID查询附件列表 + */ + @Override + public List queryListByKnowledgeId(String knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeAttach::getKnowledgeId, knowledgeId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据文档ID查询附件信息 + */ + @Override + public KnowledgeAttachVo queryByDocId(String docId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeAttach::getDocId, docId); + return baseMapper.selectVoOne(lqw); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeDocumentServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeDocumentServiceImpl.java new file mode 100644 index 0000000..fc19800 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeDocumentServiceImpl.java @@ -0,0 +1,407 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.knowledge.data.DocumentParserFactory; +import org.dromara.knowledge.domain.KnowledgeAttach; +import org.dromara.knowledge.domain.KnowledgeFragment; +import org.dromara.knowledge.domain.KnowledgeInfo; +import org.dromara.knowledge.domain.KnowledgeJob; +import org.dromara.knowledge.domain.vo.*; +import org.dromara.knowledge.service.*; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 知识库文档处理Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class KnowledgeDocumentServiceImpl implements KnowledgeDocumentService { + + // 文本清理正则表达式 + private static final Pattern MULTIPLE_SPACES = Pattern.compile("\\s+"); + private static final Pattern MULTIPLE_NEWLINES = Pattern.compile("\\n{3,}"); + private final KnowledgeAttachService knowledgeAttachService; + private final KnowledgeFragmentService knowledgeFragmentService; + private final KnowledgeJobService knowledgeJobService; + private final KnowledgeVectorService knowledgeVectorService; + private final DocumentParserFactory documentParserFactory; + @org.springframework.beans.factory.annotation.Value("${ruoyi.ai.knowledge.chunk.size:1000}") + private Integer defaultChunkSize; + @org.springframework.beans.factory.annotation.Value("${ruoyi.ai.knowledge.chunk.overlap:100}") + private Integer defaultOverlapSize; + + @Override + public String processUploadedDocument(String knowledgeId, MultipartFile file) { + // 验证文件类型 + if (!isSupportedFileType(file)) { + throw new ServiceException("不支持的文件类型: " + getFileExtension(file.getOriginalFilename())); + } + + // 生成文档ID + String docId = IdUtil.fastSimpleUUID(); + + try { + // 提取文本内容 + String content = extractTextContent(file); + + // 清理文本内容 + String cleanContent = cleanTextContent(content); + + // 创建文档附件记录 + KnowledgeAttach attach = new KnowledgeAttach(); + attach.setKnowledgeId(knowledgeId); + attach.setDocId(docId); + attach.setDocName(file.getOriginalFilename()); + attach.setDocType(getFileExtension(file.getOriginalFilename())); + attach.setContent(cleanContent); + attach.setPicStatus(10); // 未开始 + attach.setPicAnysStatus(10); // 未开始 + attach.setVectorStatus(10); // 未开始 + + // 保存文档记录 + knowledgeAttachService.save(attach); + + splitDocumentIntoFragments(knowledgeId, docId, cleanContent, defaultChunkSize, defaultOverlapSize); + + return docId; + } catch (Exception e) { + log.error("处理文档失败: {}", e.getMessage(), e); + throw new ServiceException("文档处理失败: " + e.getMessage()); + } + } + + @Override + public List splitDocumentIntoFragments(String knowledgeId, String docId, String content, + Integer chunkSize, Integer overlapSize) { + List fragmentIds = new ArrayList<>(); + + if (StrUtil.isBlank(content)) { + return fragmentIds; + } + + // 设置默认值 + int actualChunkSize = chunkSize != null ? chunkSize : 1000; + int actualOverlapSize = overlapSize != null ? overlapSize : 100; + + // 分割文档 + List chunks = splitTextIntoChunks(content, actualChunkSize, actualOverlapSize); + + for (int i = 0; i < chunks.size(); i++) { + String chunk = chunks.get(i); + if (StrUtil.isNotBlank(chunk)) { + String fragmentId = IdUtil.fastSimpleUUID(); + + // 创建知识片段记录 + KnowledgeFragment fragment = new KnowledgeFragment(); + fragment.setKnowledgeId(knowledgeId); + fragment.setDocId(docId); + fragment.setFid(fragmentId); + fragment.setIdx(i); + fragment.setContent(chunk.trim()); + + // 保存知识片段 + knowledgeFragmentService.save(fragment); + fragmentIds.add(fragmentId); + } + } + + log.info("文档 {} 分割完成,共生成 {} 个知识片段", docId, fragmentIds.size()); + return fragmentIds; + } + + @Override + public String extractTextContent(MultipartFile file) { + try { + String fileName = file.getOriginalFilename(); + if (StrUtil.isBlank(fileName)) { + throw new ServiceException("文件名不能为空"); + } + + // 使用文档解析器工厂解析文件 + byte[] fileBytes = file.getBytes(); + String content = documentParserFactory.parse(fileBytes, fileName); + + log.debug("成功解析文件: {}, 内容长度: {}", fileName, content != null ? content.length() : 0); + return content; + } catch (IOException e) { + log.error("读取文件内容失败: {}", file.getOriginalFilename(), e); + throw new ServiceException("读取文件内容失败: " + e.getMessage()); + } catch (Exception e) { + log.error("解析文件失败: {}", file.getOriginalFilename(), e); + throw new ServiceException("解析文件失败: " + e.getMessage()); + } + } + + @Override + public String cleanTextContent(String content) { + if (StrUtil.isBlank(content)) { + return ""; + } + + // 移除多余的空白字符 + String cleaned = MULTIPLE_SPACES.matcher(content).replaceAll(" "); + + // 移除多余的换行符 + cleaned = MULTIPLE_NEWLINES.matcher(cleaned).replaceAll("\n\n"); + + // 移除首尾空白 + cleaned = cleaned.trim(); + + return cleaned; + } + + @Override + public boolean isSupportedFileType(MultipartFile file) { + String fileName = file.getOriginalFilename(); + if (StrUtil.isBlank(fileName)) { + return false; + } + + String extension = getFileExtension(fileName); + return documentParserFactory.isSupported(extension); + } + + @Override + public List getSupportedFileTypes() { + return new ArrayList<>(documentParserFactory.getSupportedExtensions()); + } + + @Override + public KnowledgeStatusVo getKnowledgeProcessingStatus(Long knowledgeId, KnowledgeInfoVo knowledgeInfo) { + KnowledgeStatusVo statusVo = new KnowledgeStatusVo(); + + // 设置基本信息 + statusVo.setKnowledgeId(knowledgeId.toString()); + statusVo.setKnowledgeName(knowledgeInfo.getKnowledgeName()); + statusVo.setLastUpdateTime(LocalDateTime.parse(DateUtil.now())); + + try { + // 获取知识库附件统计 + List attachList = knowledgeAttachService.queryListByKnowledgeId(knowledgeId.toString()); + int totalDocuments = attachList.size(); + int processedDocuments = (int) attachList.stream().filter(attach -> attach.getVectorStatus() != null && attach.getVectorStatus() == 30).count(); + + statusVo.setTotalDocuments(totalDocuments); + statusVo.setProcessedDocuments(processedDocuments); + + // 获取知识片段统计 + List fragmentList = knowledgeFragmentService.queryListByKnowledgeId(knowledgeId); + int totalFragments = fragmentList.size(); + statusVo.setTotalFragments(totalFragments); + + // 获取向量化统计 + List vectorList = knowledgeVectorService.queryByKnowledgeId(knowledgeId.toString()); + int vectorizedFragments = vectorList.size(); + statusVo.setVectorizedFragments(vectorizedFragments); + + // 获取任务统计 + List runningJobs = knowledgeJobService.queryRunningJobs(); + List pendingJobs = knowledgeJobService.queryPendingJobs(); + List failedJobs = knowledgeJobService.queryFailedJobs(); + + // 过滤当前知识库的任务 + long runningCount = runningJobs.stream().filter(job -> knowledgeId.toString().equals(job.getKnowledgeId())).count(); + long pendingCount = pendingJobs.stream().filter(job -> knowledgeId.toString().equals(job.getKnowledgeId())).count(); + long failedCount = failedJobs.stream().filter(job -> knowledgeId.toString().equals(job.getKnowledgeId())).count(); + + statusVo.setRunningJobs((int) runningCount); + statusVo.setPendingJobs((int) pendingCount); + statusVo.setFailedJobs((int) failedCount); + + // 计算进度百分比 + if (totalFragments > 0) { + double progress = (double) vectorizedFragments / totalFragments * 100; + statusVo.setProgressPercentage((double) Math.round(progress)); + } else { + statusVo.setProgressPercentage((double) 0); + } + + // 确定处理状态 + if (runningCount > 0) { + statusVo.setProcessingStatus("PROCESSING"); + } else if (pendingCount > 0) { + statusVo.setProcessingStatus("PENDING"); + } else if (failedCount > 0) { + statusVo.setProcessingStatus("FAILED"); + } else if (totalFragments > 0 && vectorizedFragments == totalFragments) { + statusVo.setProcessingStatus("COMPLETED"); + } else { + statusVo.setProcessingStatus("IDLE"); + } + + } catch (Exception e) { + statusVo.setProcessingStatus("ERROR"); + statusVo.setErrorMessage("获取状态信息时发生错误: " + e.getMessage()); + } + + return statusVo; + } + + @Override + public String processDocumentContent(String knowledgeId, String docId, String content, KnowledgeInfo knowledgeInfo) { + try { + // 生成任务ID + String taskId = IdUtil.fastSimpleUUID(); + + // 创建处理任务记录 + KnowledgeJob job = new KnowledgeJob(); + job.setJobId(taskId); + job.setKnowledgeId(knowledgeId); + job.setDocId(docId); + job.setJobType("DOCUMENT_PROCESSING"); + job.setStatus(1); // 处理中 + job.setProgress(0); + job.setTotalItems(1); + job.setProcessedItems(0); + job.setSuccessItems(0); + job.setFailedItems(0); + job.setRetryCount(0); + job.setPriority(5); + job.setRemark("文档内容处理任务"); + + knowledgeJobService.save(job); + + // 异步处理文档内容分片 + splitDocumentIntoFragments(knowledgeId, docId, content, 1000, 100); + + // 更新任务状态为完成 + job.setStatus(2); // 已完成 + job.setProgress(100); + job.setProcessedItems(1); + job.setSuccessItems(1); + knowledgeJobService.updateById(job); + + return taskId; + } catch (Exception e) { + log.error("处理文档内容失败: {}", e.getMessage(), e); + throw new ServiceException("文档内容处理失败: " + e.getMessage()); + } + } + + @Override + public String vectorizeKnowledgeFragment(String knowledgeId, String fragmentId, KnowledgeInfo knowledgeInfo) { + try { + // 生成任务ID + String taskId = IdUtil.fastSimpleUUID(); + + // 创建向量化任务记录 + KnowledgeJob job = new KnowledgeJob(); + job.setJobId(taskId); + job.setKnowledgeId(knowledgeId); + job.setJobType("FRAGMENT_VECTORIZATION"); + job.setStatus(1); // 处理中 + job.setProgress(0); + job.setTotalItems(1); + job.setProcessedItems(0); + job.setSuccessItems(0); + job.setFailedItems(0); + job.setRetryCount(0); + job.setPriority(5); + job.setRemark("知识片段向量化任务"); + + knowledgeJobService.save(job); + + // 获取知识片段信息 + KnowledgeFragmentVo fragment = knowledgeFragmentService.queryByFid(fragmentId); + if (fragment == null) { + throw new ServiceException("知识片段不存在: " + fragmentId); + } + + // 调用向量化服务 + String vectorId = knowledgeVectorService.generateVector(knowledgeId, fragment.getDocId(), fragmentId, fragment.getContent(), "bge-base-zh"); + + // 更新任务状态为完成 + job.setStatus(2); // 已完成 + job.setProgress(100); + job.setProcessedItems(1); + job.setSuccessItems(1); + job.setRemark("知识片段向量化任务完成,向量ID: " + vectorId); + knowledgeJobService.updateById(job); + + log.info("知识片段 {} 向量化完成,向量ID: {}", fragmentId, vectorId); + + return taskId; + } catch (Exception e) { + log.error("向量化知识片段失败: {}", e.getMessage(), e); + throw new ServiceException("向量化处理失败: " + e.getMessage()); + } + } + + /** + * 获取文件扩展名 + */ + private String getFileExtension(String fileName) { + if (StrUtil.isBlank(fileName)) { + return ""; + } + + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { + return ""; + } + + return fileName.substring(lastDotIndex + 1); + } + + /** + * 将文本分割成指定大小的块 + */ + private List splitTextIntoChunks(String text, int chunkSize, int overlapSize) { + List chunks = new ArrayList<>(); + + if (text.length() <= chunkSize) { + chunks.add(text); + return chunks; + } + + int start = 0; + while (start < text.length()) { + int end = Math.min(start + chunkSize, text.length()); + + // 尝试在句号、问号、感叹号处分割 + if (end < text.length()) { + int lastSentenceEnd = findLastSentenceEnd(text, start, end); + if (lastSentenceEnd > start) { + end = lastSentenceEnd + 1; + } + } + + String chunk = text.substring(start, end); + chunks.add(chunk); + + // 计算下一个块的起始位置(考虑重叠) + start = Math.max(start + 1, end - overlapSize); + } + + return chunks; + } + + /** + * 查找最后一个句子结束位置 + */ + private int findLastSentenceEnd(String text, int start, int end) { + for (int i = end - 1; i >= start; i--) { + char c = text.charAt(i); + if (c == '。' || c == '!' || c == '?' || c == '.' || c == '!' || c == '?') { + return i; + } + } + return -1; + } + +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeFragmentServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeFragmentServiceImpl.java new file mode 100644 index 0000000..f204596 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeFragmentServiceImpl.java @@ -0,0 +1,142 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeFragment; +import org.dromara.knowledge.domain.bo.KnowledgeFragmentBo; +import org.dromara.knowledge.domain.vo.KnowledgeFragmentVo; +import org.dromara.knowledge.mapper.KnowledgeFragmentMapper; +import org.dromara.knowledge.service.KnowledgeFragmentService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库片段Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeFragmentServiceImpl extends ServiceImpl implements KnowledgeFragmentService { + + private final KnowledgeFragmentMapper baseMapper; + + /** + * 查询知识库片段 + */ + @Override + public KnowledgeFragmentVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库片段列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeFragmentBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库片段列表 + */ + @Override + public List queryList(KnowledgeFragmentBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeFragmentBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getKnowledgeId()), KnowledgeFragment::getKnowledgeId, bo.getKnowledgeId()); + lqw.eq(StringUtils.isNotBlank(bo.getDocId()), KnowledgeFragment::getDocId, bo.getDocId()); + lqw.eq(StringUtils.isNotBlank(bo.getFid()), KnowledgeFragment::getFid, bo.getFid()); + lqw.eq(ObjectUtil.isNotNull(bo.getIdx()), KnowledgeFragment::getIdx, bo.getIdx()); + lqw.like(StringUtils.isNotBlank(bo.getContent()), KnowledgeFragment::getContent, bo.getContent()); + return lqw; + } + + /** + * 新增知识库片段 + */ + @Override + public Boolean insertByBo(KnowledgeFragmentBo bo) { + KnowledgeFragment add = BeanUtil.toBean(bo, KnowledgeFragment.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库片段 + */ + @Override + public Boolean updateByBo(KnowledgeFragmentBo bo) { + KnowledgeFragment update = BeanUtil.toBean(bo, KnowledgeFragment.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeFragment entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除知识库片段 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据知识库ID查询片段列表 + */ + @Override + public List queryListByKnowledgeId(Long knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeFragment::getKnowledgeId, knowledgeId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据文档ID查询片段列表 + */ + @Override + public List queryListByDocId(String docId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeFragment::getDocId, docId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据片段ID查询片段信息 + */ + @Override + public KnowledgeFragmentVo queryByFid(String fid) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeFragment::getFid, fid); + return baseMapper.selectVoOne(lqw); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeHitLogServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeHitLogServiceImpl.java new file mode 100644 index 0000000..802bb8f --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeHitLogServiceImpl.java @@ -0,0 +1,317 @@ +package org.dromara.knowledge.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeHitLog; +import org.dromara.knowledge.domain.bo.KnowledgeHitLogBo; +import org.dromara.knowledge.domain.vo.KnowledgeHitLogVo; +import org.dromara.knowledge.mapper.KnowledgeHitLogMapper; +import org.dromara.knowledge.service.KnowledgeHitLogService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 知识库命中日志Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeHitLogServiceImpl extends ServiceImpl implements KnowledgeHitLogService { + + private final KnowledgeHitLogMapper baseMapper; + + /** + * 查询知识库命中日志 + */ + @Override + public KnowledgeHitLogVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库命中日志列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeHitLogBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库命中日志列表 + */ + @Override + public List queryList(KnowledgeHitLogBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeHitLogBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getSessionId()), KnowledgeHitLog::getSessionId, bo.getSessionId()); + lqw.eq(StringUtils.isNotBlank(bo.getKnowledgeId()), KnowledgeHitLog::getKnowledgeId, bo.getKnowledgeId()); + lqw.eq(StringUtils.isNotBlank(bo.getDocId()), KnowledgeHitLog::getDocId, bo.getDocId()); + lqw.eq(StringUtils.isNotBlank(bo.getFragmentId()), KnowledgeHitLog::getFragmentId, bo.getFragmentId()); + lqw.like(StringUtils.isNotBlank(bo.getQueryText()), KnowledgeHitLog::getQueryText, bo.getQueryText()); + lqw.ge(bo.getSimilarityScore() != null, KnowledgeHitLog::getSimilarityScore, bo.getSimilarityScore()); + lqw.eq(bo.getRanking() != null, KnowledgeHitLog::getRanking, bo.getRanking()); + lqw.like(StringUtils.isNotBlank(bo.getModelName()), KnowledgeHitLog::getModelName, bo.getModelName()); + lqw.ge(bo.getResponseTime() != null, KnowledgeHitLog::getResponseTime, bo.getResponseTime()); + lqw.ge(bo.getHitCount() != null, KnowledgeHitLog::getHitCount, bo.getHitCount()); + return lqw; + } + + /** + * 新增知识库命中日志 + */ + @Override + public Boolean insertByBo(KnowledgeHitLogBo bo) { + KnowledgeHitLog add = MapstructUtils.convert(bo, KnowledgeHitLog.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库命中日志 + */ + @Override + public Boolean updateByBo(KnowledgeHitLogBo bo) { + KnowledgeHitLog update = MapstructUtils.convert(bo, KnowledgeHitLog.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeHitLog entity) { + //TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除知识库命中日志 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + //TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据会话ID查询知识库命中日志列表 + */ + @Override + public List queryBySessionId(String sessionId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getSessionId, sessionId); + lqw.orderByAsc(KnowledgeHitLog::getRanking); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据知识库ID查询知识库命中日志列表 + */ + @Override + public List queryByKnowledgeId(String knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getKnowledgeId, knowledgeId); + lqw.orderByDesc(KnowledgeHitLog::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据文档ID查询知识库命中日志列表 + */ + @Override + public List queryByDocId(String docId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getDocId, docId); + lqw.orderByDesc(KnowledgeHitLog::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据知识片段ID查询知识库命中日志列表 + */ + @Override + public List queryByFragmentId(String fragmentId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getFragmentId, fragmentId); + lqw.orderByDesc(KnowledgeHitLog::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据模型名称查询知识库命中日志列表 + */ + @Override + public List queryByModelName(String modelName) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getModelName, modelName); + lqw.orderByDesc(KnowledgeHitLog::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据相似度分数范围查询知识库命中日志列表 + */ + @Override + public List queryBySimilarityRange(Double minScore, Double maxScore) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.between(KnowledgeHitLog::getSimilarityScore, minScore, maxScore); + lqw.orderByDesc(KnowledgeHitLog::getSimilarityScore); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据查询文本查询知识库命中日志列表 + */ + @Override + public List queryByQueryText(String queryText) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(KnowledgeHitLog::getQueryText, queryText); + lqw.orderByDesc(KnowledgeHitLog::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 统计知识库命中次数 + */ + @Override + public Long countHitsByKnowledgeId(String knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getKnowledgeId, knowledgeId); + return baseMapper.selectCount(lqw); + } + + /** + * 统计文档命中次数 + */ + @Override + public Long countHitsByDocId(String docId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getDocId, docId); + return baseMapper.selectCount(lqw); + } + + /** + * 统计知识片段命中次数 + */ + @Override + public Long countHitsByFragmentId(String fragmentId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getFragmentId, fragmentId); + return baseMapper.selectCount(lqw); + } + + /** + * 获取热门查询列表 + */ + @Override + public List> getPopularQueries(String knowledgeId, Integer limit) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(knowledgeId), KnowledgeHitLog::getKnowledgeId, knowledgeId); + lqw.isNotNull(KnowledgeHitLog::getQueryText); + lqw.ne(KnowledgeHitLog::getQueryText, ""); + lqw.orderByDesc(KnowledgeHitLog::getCreateTime); + + List logs = baseMapper.selectList(lqw); + + // 按查询文本分组统计 + Map queryCountMap = logs.stream() + .filter(log -> StringUtils.isNotBlank(log.getQueryText())) + .collect(Collectors.groupingBy(KnowledgeHitLog::getQueryText, Collectors.counting())); + + // 转换为Map列表并按数量排序 + List> result = queryCountMap.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(limit != null && limit > 0 ? limit : 10) + .map(entry -> { + Map map = new java.util.HashMap<>(); + map.put("queryText", entry.getKey()); + map.put("count", entry.getValue()); + return map; + }) + .collect(Collectors.toList()); + + return result; + } + + /** + * 获取平均响应时间 + */ + @Override + public Double getAverageResponseTime(String knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(knowledgeId), KnowledgeHitLog::getKnowledgeId, knowledgeId); + lqw.isNotNull(KnowledgeHitLog::getResponseTime); + lqw.gt(KnowledgeHitLog::getResponseTime, 0L); + + List logs = baseMapper.selectList(lqw); + + if (logs.isEmpty()) { + return 0.0; + } + + // 计算平均响应时间 + return logs.stream() + .filter(log -> log.getResponseTime() != null && log.getResponseTime() > 0) + .mapToLong(KnowledgeHitLog::getResponseTime) + .average() + .orElse(0.0); + } + + /** + * 根据排名查询知识库命中日志列表 + */ + @Override + public List queryByRanking(Integer ranking) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeHitLog::getRanking, ranking); + lqw.orderByDesc(KnowledgeHitLog::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据响应时间范围查询知识库命中日志列表 + */ + @Override + public List queryByResponseTimeRange(Long minTime, Long maxTime) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.between(KnowledgeHitLog::getResponseTime, minTime, maxTime); + lqw.orderByAsc(KnowledgeHitLog::getResponseTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 批量插入知识库命中日志 + */ + @Override + public Boolean batchInsert(List boList) { + List entityList = boList.stream() + .map(bo -> MapstructUtils.convert(bo, KnowledgeHitLog.class)) + .collect(Collectors.toList()); + return saveBatch(entityList); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeInfoServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeInfoServiceImpl.java new file mode 100644 index 0000000..ee255da --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeInfoServiceImpl.java @@ -0,0 +1,260 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeInfo; +import org.dromara.knowledge.domain.bo.KnowledgeInfoBo; +import org.dromara.knowledge.domain.vo.KnowledgeInfoVo; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.domain.vo.KnowledgeStatusVo; +import org.dromara.knowledge.mapper.KnowledgeInfoMapper; +import org.dromara.knowledge.service.*; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * 知识库信息Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeInfoServiceImpl extends ServiceImpl implements KnowledgeInfoService { + + private final KnowledgeInfoMapper baseMapper; + private final KnowledgeAttachService knowledgeAttachService; + private final KnowledgeFragmentService knowledgeFragmentService; + private final KnowledgeVectorService knowledgeVectorService; + private final KnowledgeJobService knowledgeJobService; + private final KnowledgeHitLogService knowledgeHitLogService; + private final KnowledgeDocumentService knowledgeDocumentService; + + /** + * 查询知识库信息 + */ + @Override + public KnowledgeInfoVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库信息列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeInfoBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库信息列表 + */ + @Override + public List queryList(KnowledgeInfoBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeInfoBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(ObjectUtil.isNotNull(bo.getUserId()), KnowledgeInfo::getUserId, bo.getUserId()); + lqw.eq(StringUtils.isNotBlank(bo.getKnowledgeId()), KnowledgeInfo::getKnowledgeId, bo.getKnowledgeId()); + lqw.like(StringUtils.isNotBlank(bo.getKnowledgeName()), KnowledgeInfo::getKnowledgeName, bo.getKnowledgeName()); + lqw.eq(ObjectUtil.isNotNull(bo.getShare()), KnowledgeInfo::getShare, bo.getShare()); + lqw.like(StringUtils.isNotBlank(bo.getDescription()), KnowledgeInfo::getDescription, bo.getDescription()); + lqw.eq(StringUtils.isNotBlank(bo.getVectorModelName()), KnowledgeInfo::getVectorModelName, bo.getVectorModelName()); + lqw.eq(StringUtils.isNotBlank(bo.getEmbeddingModelName()), KnowledgeInfo::getEmbeddingModelName, bo.getEmbeddingModelName()); + return lqw; + } + + /** + * 新增知识库信息 + */ + @Override + public Boolean insertByBo(KnowledgeInfoBo bo) { + KnowledgeInfo add = BeanUtil.toBean(bo, KnowledgeInfo.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库信息 + */ + @Override + public Boolean updateByBo(KnowledgeInfoBo bo) { + KnowledgeInfo update = BeanUtil.toBean(bo, KnowledgeInfo.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeInfo entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除知识库信息 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据用户ID查询知识库列表 + */ + @Override + public List queryListByUserId(Long userId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeInfo::getUserId, userId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据知识库ID查询知识库信息 + */ + @Override + public KnowledgeInfoVo queryByKnowledgeId(Long knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeInfo::getId, knowledgeId); + return baseMapper.selectVoOne(lqw); + } + + @Override + public String uploadDocument(String knowledgeId, MultipartFile file) { + if (file.isEmpty()) { + throw new ServiceException("上传文件不能为空"); + } + + // 验证知识库是否存在 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeInfo::getKnowledgeId, knowledgeId); + KnowledgeInfo knowledgeInfo = baseMapper.selectOne(lqw); + if (knowledgeInfo == null) { + throw new ServiceException("知识库不存在"); + } + + // 验证文件大小(限制为50MB) + if (file.getSize() > 50 * 1024 * 1024) { + throw new ServiceException("文件大小不能超过50MB"); + } + + // 使用文档处理服务上传文档 + return knowledgeDocumentService.processUploadedDocument(knowledgeId, file); + } + + @Override + public List batchUploadDocuments(String knowledgeId, MultipartFile[] files) { + List docIds = new ArrayList<>(); + List failedFiles = new ArrayList<>(); + + for (MultipartFile file : files) { + try { + String docId = uploadDocument(knowledgeId, file); + docIds.add(docId); + } catch (Exception e) { + failedFiles.add(file.getOriginalFilename() + ": " + e.getMessage()); + } + } + + if (!failedFiles.isEmpty()) { + throw new ServiceException("部分文件上传失败: " + String.join(", ", failedFiles)); + } + + return docIds; + } + + @Override + public List searchKnowledge(String knowledgeId, String query, Integer limit) { + // 验证知识库是否存在 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeInfo::getKnowledgeId, knowledgeId); + KnowledgeInfo knowledgeInfo = baseMapper.selectOne(lqw); + if (knowledgeInfo == null) { + throw new ServiceException("知识库不存在"); + } + + // 获取知识库的向量模型 + String vectorModel = knowledgeInfo.getVectorModelName(); + if (vectorModel == null) { + vectorModel = "bge-base-zh"; // 默认模型 + } + + // 设置默认值 + if (limit == null) { + limit = 10; + } + Double threshold = 0.7; // 默认相似度阈值 + + // 使用向量服务进行搜索 + return knowledgeVectorService.vectorSimilaritySearch(knowledgeId, query, vectorModel, limit, threshold); + } + + @Override + public KnowledgeStatusVo getKnowledgeStatus(String knowledgeId) { + // 验证知识库是否存在 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeInfo::getKnowledgeId, knowledgeId); + KnowledgeInfo knowledgeInfo = baseMapper.selectOne(lqw); + if (knowledgeInfo == null) { + throw new ServiceException("知识库不存在"); + } + + // 转换为VO对象 + KnowledgeInfoVo knowledgeInfoVo = baseMapper.selectVoById(knowledgeInfo.getId()); + + // 使用文档处理服务获取状态信息 + return knowledgeDocumentService.getKnowledgeProcessingStatus(Long.valueOf(knowledgeId), knowledgeInfoVo); + } + + @Override + public String processDocument(String knowledgeId, String docId, String content) { + // 验证知识库是否存在 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeInfo::getKnowledgeId, knowledgeId); + KnowledgeInfo knowledgeInfo = baseMapper.selectOne(lqw); + if (knowledgeInfo == null) { + throw new ServiceException("知识库不存在"); + } + + // 使用文档处理服务处理文档 + return knowledgeDocumentService.processDocumentContent(knowledgeId, docId, content, knowledgeInfo); + } + + @Override + public String vectorizeFragment(String knowledgeId, String fragmentId) { + // 验证知识库是否存在 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeInfo::getKnowledgeId, knowledgeId); + KnowledgeInfo knowledgeInfo = baseMapper.selectOne(lqw); + if (knowledgeInfo == null) { + throw new ServiceException("知识库不存在"); + } + + // 使用文档处理服务进行向量化 + return knowledgeDocumentService.vectorizeKnowledgeFragment(knowledgeId, fragmentId, knowledgeInfo); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeJobServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeJobServiceImpl.java new file mode 100644 index 0000000..6be0b3c --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeJobServiceImpl.java @@ -0,0 +1,288 @@ +package org.dromara.knowledge.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeJob; +import org.dromara.knowledge.domain.bo.KnowledgeJobBo; +import org.dromara.knowledge.domain.vo.KnowledgeJobVo; +import org.dromara.knowledge.mapper.KnowledgeJobMapper; +import org.dromara.knowledge.service.KnowledgeJobService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 知识库任务Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeJobServiceImpl extends ServiceImpl implements KnowledgeJobService { + + private final KnowledgeJobMapper baseMapper; + + /** + * 查询知识库任务 + */ + @Override + public KnowledgeJobVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库任务列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeJobBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库任务列表 + */ + @Override + public List queryList(KnowledgeJobBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeJobBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getJobId()), KnowledgeJob::getJobId, bo.getJobId()); + lqw.eq(StringUtils.isNotBlank(bo.getJobType()), KnowledgeJob::getJobType, bo.getJobType()); + lqw.eq(StringUtils.isNotBlank(bo.getJobSubType()), KnowledgeJob::getJobSubType, bo.getJobSubType()); + lqw.eq(StringUtils.isNotBlank(bo.getKnowledgeId()), KnowledgeJob::getKnowledgeId, bo.getKnowledgeId()); + lqw.eq(StringUtils.isNotBlank(bo.getDocId()), KnowledgeJob::getDocId, bo.getDocId()); + lqw.eq(bo.getUserId() != null, KnowledgeJob::getUserId, bo.getUserId()); + lqw.eq(bo.getStatus() != null, KnowledgeJob::getStatus, bo.getStatus()); + lqw.eq(bo.getPriority() != null, KnowledgeJob::getPriority, bo.getPriority()); + lqw.between(params.get("beginStartTime") != null && params.get("endStartTime") != null, + KnowledgeJob::getStartTime, params.get("beginStartTime"), params.get("endStartTime")); + lqw.between(params.get("beginEndTime") != null && params.get("endEndTime") != null, + KnowledgeJob::getEndTime, params.get("beginEndTime"), params.get("endEndTime")); + return lqw; + } + + /** + * 新增知识库任务 + */ + @Override + public Boolean insertByBo(KnowledgeJobBo bo) { + KnowledgeJob add = MapstructUtils.convert(bo, KnowledgeJob.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库任务 + */ + @Override + public Boolean updateByBo(KnowledgeJobBo bo) { + KnowledgeJob update = MapstructUtils.convert(bo, KnowledgeJob.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeJob entity) { + //TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除知识库任务 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + //TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据任务ID查询知识库任务 + */ + @Override + public KnowledgeJobVo queryByJobId(String jobId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getJobId, jobId); + return baseMapper.selectVoOne(lqw); + } + + /** + * 根据知识库ID查询知识库任务列表 + */ + @Override + public List queryByKnowledgeId(String knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getKnowledgeId, knowledgeId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据文档ID查询知识库任务列表 + */ + @Override + public List queryByDocId(String docId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getDocId, docId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据用户ID查询知识库任务列表 + */ + @Override + public List queryByUserId(Long userId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getUserId, userId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据任务类型查询知识库任务列表 + */ + @Override + public List queryByJobType(String jobType) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getJobType, jobType); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据任务状态查询知识库任务列表 + */ + @Override + public List queryByStatus(Integer status) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getStatus, status); + return baseMapper.selectVoList(lqw); + } + + /** + * 查询正在运行的任务列表 + */ + @Override + public List queryRunningJobs() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getStatus, 1); // 假设1表示运行中 + return baseMapper.selectVoList(lqw); + } + + /** + * 查询待处理的任务列表 + */ + @Override + public List queryPendingJobs() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getStatus, 0); // 假设0表示待处理 + return baseMapper.selectVoList(lqw); + } + + /** + * 查询失败的任务列表 + */ + @Override + public List queryFailedJobs() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getStatus, 3); // 假设3表示失败 + return baseMapper.selectVoList(lqw); + } + + /** + * 更新任务进度 + */ + @Override + public Boolean updateProgress(Long id, Integer progress) { + KnowledgeJob update = new KnowledgeJob(); + update.setId(id); + update.setProgress(progress); + return baseMapper.updateById(update) > 0; + } + + /** + * 更新任务状态 + */ + @Override + public Boolean updateStatus(Long id, Integer status) { + KnowledgeJob update = new KnowledgeJob(); + update.setId(id); + update.setStatus(status); + if (status == 2) { // 假设2表示完成 + update.setEndTime(new Date()); + } + return baseMapper.updateById(update) > 0; + } + + /** + * 增加重试次数 + */ + @Override + public Boolean incrementRetryCount(Long id) { + KnowledgeJob job = baseMapper.selectById(id); + if (job != null) { + job.setRetryCount(job.getRetryCount() + 1); + return baseMapper.updateById(job) > 0; + } + return false; + } + + /** + * 更新任务项目计数 + */ + @Override + public Boolean updateItemCounts(Long id, Integer totalItems, Integer processedItems, Integer successItems, Integer failedItems) { + KnowledgeJob update = new KnowledgeJob(); + update.setId(id); + update.setTotalItems(totalItems); + update.setProcessedItems(processedItems); + update.setSuccessItems(successItems); + update.setFailedItems(failedItems); + return baseMapper.updateById(update) > 0; + } + + /** + * 根据优先级查询任务列表 + */ + @Override + public List queryByPriority(Integer priority) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getPriority, priority); + lqw.orderByDesc(KnowledgeJob::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 查询超时的任务列表 + */ + @Override + public List queryTimeoutJobs() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeJob::getStatus, 1) // 运行中的任务 + .isNotNull(KnowledgeJob::getTimeout) + .isNotNull(KnowledgeJob::getStartTime) + .apply("TIMESTAMPDIFF(SECOND, start_time, NOW()) > timeout"); + return baseMapper.selectVoList(lqw); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgePromptTemplateServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgePromptTemplateServiceImpl.java new file mode 100644 index 0000000..dcb96f7 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgePromptTemplateServiceImpl.java @@ -0,0 +1,129 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgePromptTemplate; +import org.dromara.knowledge.domain.bo.KnowledgePromptTemplateBo; +import org.dromara.knowledge.domain.vo.KnowledgePromptTemplateVo; +import org.dromara.knowledge.mapper.KnowledgePromptTemplateMapper; +import org.dromara.knowledge.service.KnowledgePromptTemplateService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库提示模板Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgePromptTemplateServiceImpl extends ServiceImpl implements KnowledgePromptTemplateService { + + private final KnowledgePromptTemplateMapper baseMapper; + + /** + * 查询知识库提示模板 + */ + @Override + public KnowledgePromptTemplateVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库提示模板列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgePromptTemplateBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库提示模板列表 + */ + @Override + public List queryList(KnowledgePromptTemplateBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgePromptTemplateBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getTemplateName()), KnowledgePromptTemplate::getTemplateName, bo.getTemplateName()); + lqw.like(StringUtils.isNotBlank(bo.getTemplateContent()), KnowledgePromptTemplate::getTemplateContent, bo.getTemplateContent()); + lqw.eq(StringUtils.isNotBlank(bo.getCategory()), KnowledgePromptTemplate::getCategory, bo.getCategory()); + return lqw; + } + + /** + * 新增知识库提示模板 + */ + @Override + public Boolean insertByBo(KnowledgePromptTemplateBo bo) { + KnowledgePromptTemplate add = BeanUtil.toBean(bo, KnowledgePromptTemplate.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库提示模板 + */ + @Override + public Boolean updateByBo(KnowledgePromptTemplateBo bo) { + KnowledgePromptTemplate update = BeanUtil.toBean(bo, KnowledgePromptTemplate.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgePromptTemplate entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除知识库提示模板 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据分类查询模板列表 + */ + @Override + public List queryListByCategory(String category) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgePromptTemplate::getCategory, category); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据模板名称查询模板信息 + */ + @Override + public KnowledgePromptTemplateVo queryByTemplateName(String templateName) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgePromptTemplate::getTemplateName, templateName); + return baseMapper.selectVoOne(lqw); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleGroupServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleGroupServiceImpl.java new file mode 100644 index 0000000..2d054de --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleGroupServiceImpl.java @@ -0,0 +1,134 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeRoleGroup; +import org.dromara.knowledge.domain.bo.KnowledgeRoleGroupBo; +import org.dromara.knowledge.domain.vo.KnowledgeRoleGroupVo; +import org.dromara.knowledge.mapper.KnowledgeRoleGroupMapper; +import org.dromara.knowledge.service.KnowledgeRoleGroupService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库角色分组Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeRoleGroupServiceImpl extends ServiceImpl implements KnowledgeRoleGroupService { + + private final KnowledgeRoleGroupMapper baseMapper; + + /** + * 查询知识库角色分组 + */ + @Override + public KnowledgeRoleGroupVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库角色分组列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeRoleGroupBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库角色分组列表 + */ + @Override + public List queryList(KnowledgeRoleGroupBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeRoleGroupBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getGroupName()), KnowledgeRoleGroup::getGroupName, bo.getGroupName()); + lqw.eq(StringUtils.isNotBlank(bo.getGroupCode()), KnowledgeRoleGroup::getGroupCode, bo.getGroupCode()); + lqw.like(StringUtils.isNotBlank(bo.getGroupDesc()), KnowledgeRoleGroup::getGroupDesc, bo.getGroupDesc()); + lqw.eq(ObjectUtil.isNotNull(bo.getStatus()), KnowledgeRoleGroup::getStatus, bo.getStatus()); + lqw.orderByAsc(KnowledgeRoleGroup::getSort); + return lqw; + } + + /** + * 新增知识库角色分组 + */ + @Override + public Boolean insertByBo(KnowledgeRoleGroupBo bo) { + KnowledgeRoleGroup add = BeanUtil.toBean(bo, KnowledgeRoleGroup.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库角色分组 + */ + @Override + public Boolean updateByBo(KnowledgeRoleGroupBo bo) { + KnowledgeRoleGroup update = BeanUtil.toBean(bo, KnowledgeRoleGroup.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeRoleGroup entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除知识库角色分组 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据分组编码查询分组信息 + */ + @Override + public KnowledgeRoleGroupVo queryByGroupCode(String groupCode) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeRoleGroup::getGroupCode, groupCode); + return baseMapper.selectVoOne(lqw); + } + + /** + * 查询启用状态的分组列表 + */ + @Override + public List queryEnabledList() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeRoleGroup::getStatus, 1); + lqw.orderByAsc(KnowledgeRoleGroup::getSort); + return baseMapper.selectVoList(lqw); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleRelationServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleRelationServiceImpl.java new file mode 100644 index 0000000..4aff239 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleRelationServiceImpl.java @@ -0,0 +1,145 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeRoleRelation; +import org.dromara.knowledge.domain.bo.KnowledgeRoleRelationBo; +import org.dromara.knowledge.domain.vo.KnowledgeRoleRelationVo; +import org.dromara.knowledge.mapper.KnowledgeRoleRelationMapper; +import org.dromara.knowledge.service.KnowledgeRoleRelationService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库角色关系Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeRoleRelationServiceImpl extends ServiceImpl implements KnowledgeRoleRelationService { + + private final KnowledgeRoleRelationMapper baseMapper; + + /** + * 查询知识库角色关系 + */ + @Override + public KnowledgeRoleRelationVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库角色关系列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeRoleRelationBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库角色关系列表 + */ + @Override + public List queryList(KnowledgeRoleRelationBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeRoleRelationBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(ObjectUtil.isNotNull(bo.getRoleId()), KnowledgeRoleRelation::getRoleId, bo.getRoleId()); + lqw.eq(ObjectUtil.isNotNull(bo.getGroupId()), KnowledgeRoleRelation::getGroupId, bo.getGroupId()); + lqw.eq(ObjectUtil.isNotNull(bo.getRelationType()), KnowledgeRoleRelation::getRelationType, bo.getRelationType()); + lqw.eq(ObjectUtil.isNotNull(bo.getStatus()), KnowledgeRoleRelation::getStatus, bo.getStatus()); + lqw.orderByAsc(KnowledgeRoleRelation::getSort); + return lqw; + } + + /** + * 新增知识库角色关系 + */ + @Override + public Boolean insertByBo(KnowledgeRoleRelationBo bo) { + KnowledgeRoleRelation add = BeanUtil.toBean(bo, KnowledgeRoleRelation.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库角色关系 + */ + @Override + public Boolean updateByBo(KnowledgeRoleRelationBo bo) { + KnowledgeRoleRelation update = BeanUtil.toBean(bo, KnowledgeRoleRelation.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeRoleRelation entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除知识库角色关系 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据角色ID查询关系列表 + */ + @Override + public List queryByRoleId(Long roleId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeRoleRelation::getRoleId, roleId); + lqw.orderByAsc(KnowledgeRoleRelation::getSort); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据分组ID查询关系列表 + */ + @Override + public List queryByGroupId(Long groupId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeRoleRelation::getGroupId, groupId); + lqw.orderByAsc(KnowledgeRoleRelation::getSort); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据角色ID和分组ID查询关系 + */ + @Override + public KnowledgeRoleRelationVo queryByRoleIdAndGroupId(Long roleId, Long groupId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeRoleRelation::getRoleId, roleId); + lqw.eq(KnowledgeRoleRelation::getGroupId, groupId); + return baseMapper.selectVoOne(lqw); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleServiceImpl.java new file mode 100644 index 0000000..583593f --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeRoleServiceImpl.java @@ -0,0 +1,134 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeRole; +import org.dromara.knowledge.domain.bo.KnowledgeRoleBo; +import org.dromara.knowledge.domain.vo.KnowledgeRoleVo; +import org.dromara.knowledge.mapper.KnowledgeRoleMapper; +import org.dromara.knowledge.service.KnowledgeRoleService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库角色Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeRoleServiceImpl extends ServiceImpl implements KnowledgeRoleService { + + private final KnowledgeRoleMapper baseMapper; + + /** + * 查询知识库角色 + */ + @Override + public KnowledgeRoleVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库角色列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeRoleBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库角色列表 + */ + @Override + public List queryList(KnowledgeRoleBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeRoleBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getRoleName()), KnowledgeRole::getRoleName, bo.getRoleName()); + lqw.eq(StringUtils.isNotBlank(bo.getRoleCode()), KnowledgeRole::getRoleCode, bo.getRoleCode()); + lqw.like(StringUtils.isNotBlank(bo.getRoleDesc()), KnowledgeRole::getRoleDesc, bo.getRoleDesc()); + lqw.eq(ObjectUtil.isNotNull(bo.getStatus()), KnowledgeRole::getStatus, bo.getStatus()); + lqw.orderByAsc(KnowledgeRole::getSort); + return lqw; + } + + /** + * 新增知识库角色 + */ + @Override + public Boolean insertByBo(KnowledgeRoleBo bo) { + KnowledgeRole add = BeanUtil.toBean(bo, KnowledgeRole.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库角色 + */ + @Override + public Boolean updateByBo(KnowledgeRoleBo bo) { + KnowledgeRole update = BeanUtil.toBean(bo, KnowledgeRole.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeRole entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除知识库角色 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据角色编码查询角色信息 + */ + @Override + public KnowledgeRoleVo queryByRoleCode(String roleCode) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeRole::getRoleCode, roleCode); + return baseMapper.selectVoOne(lqw); + } + + /** + * 查询启用状态的角色列表 + */ + @Override + public List queryEnabledList() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeRole::getStatus, 1); + lqw.orderByAsc(KnowledgeRole::getSort); + return baseMapper.selectVoList(lqw); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaFieldServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaFieldServiceImpl.java new file mode 100644 index 0000000..4298210 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaFieldServiceImpl.java @@ -0,0 +1,150 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeSchemaField; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaFieldBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaFieldVo; +import org.dromara.knowledge.mapper.KnowledgeSchemaFieldMapper; +import org.dromara.knowledge.service.KnowledgeSchemaFieldService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库模式字段Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeSchemaFieldServiceImpl extends ServiceImpl implements KnowledgeSchemaFieldService { + + private final KnowledgeSchemaFieldMapper baseMapper; + + /** + * 查询知识库模式字段 + */ + @Override + public KnowledgeSchemaFieldVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库模式字段列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeSchemaFieldBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库模式字段列表 + */ + @Override + public List queryList(KnowledgeSchemaFieldBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeSchemaFieldBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(ObjectUtil.isNotNull(bo.getSchemaId()), KnowledgeSchemaField::getSchemaId, bo.getSchemaId()); + lqw.eq(StringUtils.isNotBlank(bo.getSchemaName()), KnowledgeSchemaField::getSchemaName, bo.getSchemaName()); + lqw.like(StringUtils.isNotBlank(bo.getName()), KnowledgeSchemaField::getName, bo.getName()); + lqw.eq(StringUtils.isNotBlank(bo.getCode()), KnowledgeSchemaField::getCode, bo.getCode()); + lqw.eq(StringUtils.isNotBlank(bo.getType()), KnowledgeSchemaField::getType, bo.getType()); + lqw.eq(ObjectUtil.isNotNull(bo.getIsPk()), KnowledgeSchemaField::getIsPk, bo.getIsPk()); + lqw.eq(ObjectUtil.isNotNull(bo.getIsRequired()), KnowledgeSchemaField::getIsRequired, bo.getIsRequired()); + lqw.eq(ObjectUtil.isNotNull(bo.getIsUnique()), KnowledgeSchemaField::getIsUnique, bo.getIsUnique()); + lqw.eq(StringUtils.isNotBlank(bo.getQueryType()), KnowledgeSchemaField::getQueryType, bo.getQueryType()); + lqw.eq(StringUtils.isNotBlank(bo.getHtmlType()), KnowledgeSchemaField::getHtmlType, bo.getHtmlType()); + lqw.eq(StringUtils.isNotBlank(bo.getDictType()), KnowledgeSchemaField::getDictType, bo.getDictType()); + return lqw; + } + + /** + * 新增知识库模式字段 + */ + @Override + public Boolean insertByBo(KnowledgeSchemaFieldBo bo) { + KnowledgeSchemaField add = BeanUtil.toBean(bo, KnowledgeSchemaField.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库模式字段 + */ + @Override + public Boolean updateByBo(KnowledgeSchemaFieldBo bo) { + KnowledgeSchemaField update = BeanUtil.toBean(bo, KnowledgeSchemaField.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeSchemaField entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除知识库模式字段 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据模式ID查询字段列表 + */ + @Override + public List queryListBySchemaId(Long schemaId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeSchemaField::getSchemaId, schemaId); + lqw.orderByAsc(KnowledgeSchemaField::getSort); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据编码查询字段信息 + */ + @Override + public KnowledgeSchemaFieldVo queryByCode(String code) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeSchemaField::getCode, code); + return baseMapper.selectVoOne(lqw); + } + + /** + * 根据模式ID和字段编码查询字段信息 + */ + @Override + public KnowledgeSchemaFieldVo queryBySchemaIdAndCode(Long schemaId, String code) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeSchemaField::getSchemaId, schemaId); + lqw.eq(KnowledgeSchemaField::getCode, code); + return baseMapper.selectVoOne(lqw); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaGroupServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaGroupServiceImpl.java new file mode 100644 index 0000000..87a2a57 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaGroupServiceImpl.java @@ -0,0 +1,129 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeSchemaGroup; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaGroupBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaGroupVo; +import org.dromara.knowledge.mapper.KnowledgeSchemaGroupMapper; +import org.dromara.knowledge.service.KnowledgeSchemaGroupService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库模式组Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeSchemaGroupServiceImpl extends ServiceImpl implements KnowledgeSchemaGroupService { + + private final KnowledgeSchemaGroupMapper baseMapper; + + /** + * 查询知识库模式组 + */ + @Override + public KnowledgeSchemaGroupVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库模式组列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeSchemaGroupBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库模式组列表 + */ + @Override + public List queryList(KnowledgeSchemaGroupBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeSchemaGroupBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getName()), KnowledgeSchemaGroup::getName, bo.getName()); + lqw.eq(StringUtils.isNotBlank(bo.getCode()), KnowledgeSchemaGroup::getCode, bo.getCode()); + lqw.eq(StringUtils.isNotBlank(bo.getIcon()), KnowledgeSchemaGroup::getIcon, bo.getIcon()); + return lqw; + } + + /** + * 新增知识库模式组 + */ + @Override + public Boolean insertByBo(KnowledgeSchemaGroupBo bo) { + KnowledgeSchemaGroup add = BeanUtil.toBean(bo, KnowledgeSchemaGroup.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库模式组 + */ + @Override + public Boolean updateByBo(KnowledgeSchemaGroupBo bo) { + KnowledgeSchemaGroup update = BeanUtil.toBean(bo, KnowledgeSchemaGroup.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeSchemaGroup entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除知识库模式组 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据编码查询模式组信息 + */ + @Override + public KnowledgeSchemaGroupVo queryByCode(String code) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeSchemaGroup::getCode, code); + return baseMapper.selectVoOne(lqw); + } + + /** + * 根据名称查询模式组信息 + */ + @Override + public KnowledgeSchemaGroupVo queryByName(String name) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeSchemaGroup::getName, name); + return baseMapper.selectVoOne(lqw); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaServiceImpl.java new file mode 100644 index 0000000..46349cf --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeSchemaServiceImpl.java @@ -0,0 +1,141 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeSchema; +import org.dromara.knowledge.domain.bo.KnowledgeSchemaBo; +import org.dromara.knowledge.domain.vo.KnowledgeSchemaVo; +import org.dromara.knowledge.mapper.KnowledgeSchemaMapper; +import org.dromara.knowledge.service.KnowledgeSchemaService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库模式Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class KnowledgeSchemaServiceImpl extends ServiceImpl implements KnowledgeSchemaService { + + private final KnowledgeSchemaMapper baseMapper; + + /** + * 查询知识库模式 + */ + @Override + public KnowledgeSchemaVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识库模式列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeSchemaBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识库模式列表 + */ + @Override + public List queryList(KnowledgeSchemaBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeSchemaBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(ObjectUtil.isNotNull(bo.getSchemaGroupId()), KnowledgeSchema::getSchemaGroupId, bo.getSchemaGroupId()); + lqw.like(StringUtils.isNotBlank(bo.getName()), KnowledgeSchema::getName, bo.getName()); + lqw.eq(StringUtils.isNotBlank(bo.getCode()), KnowledgeSchema::getCode, bo.getCode()); + lqw.eq(StringUtils.isNotBlank(bo.getTableName()), KnowledgeSchema::getTableName, bo.getTableName()); + return lqw; + } + + /** + * 新增知识库模式 + */ + @Override + public Boolean insertByBo(KnowledgeSchemaBo bo) { + KnowledgeSchema add = BeanUtil.toBean(bo, KnowledgeSchema.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识库模式 + */ + @Override + public Boolean updateByBo(KnowledgeSchemaBo bo) { + KnowledgeSchema update = BeanUtil.toBean(bo, KnowledgeSchema.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeSchema entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除知识库模式 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据模式组ID查询模式列表 + */ + @Override + public List queryListBySchemaGroupId(Long schemaGroupId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeSchema::getSchemaGroupId, schemaGroupId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据编码查询模式信息 + */ + @Override + public KnowledgeSchemaVo queryByCode(String code) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeSchema::getCode, code); + return baseMapper.selectVoOne(lqw); + } + + /** + * 根据表名查询模式信息 + */ + @Override + public KnowledgeSchemaVo queryByTableName(String tableName) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeSchema::getTableName, tableName); + return baseMapper.selectVoOne(lqw); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeVectorServiceImpl.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeVectorServiceImpl.java new file mode 100644 index 0000000..3871e67 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/service/impl/KnowledgeVectorServiceImpl.java @@ -0,0 +1,410 @@ +package org.dromara.knowledge.service.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.knowledge.domain.KnowledgeVector; +import org.dromara.knowledge.domain.bo.KnowledgeVectorBo; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.domain.vo.KnowledgeVectorVo; +import org.dromara.knowledge.mapper.KnowledgeVectorMapper; +import org.dromara.knowledge.service.KnowledgeFragmentService; +import org.dromara.knowledge.service.KnowledgeVectorService; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 知识片段向量Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class KnowledgeVectorServiceImpl extends ServiceImpl implements KnowledgeVectorService { + + // 支持的向量模型列表 + private static final List AVAILABLE_MODELS = Arrays.asList( + "text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large", + "bge-large-zh", "bge-base-zh", "m3e-base", "m3e-large" + ); + private final KnowledgeVectorMapper baseMapper; + private final KnowledgeFragmentService knowledgeFragmentService; + private final org.dromara.knowledge.embedding.EmbeddingService embeddingService; + private final org.dromara.knowledge.vectorstore.VectorStoreManager vectorStoreManager; + + /** + * 查询知识片段向量 + */ + @Override + public KnowledgeVectorVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询知识片段向量列表 + */ + @Override + public TableDataInfo queryPageList(KnowledgeVectorBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询知识片段向量列表 + */ + @Override + public List queryList(KnowledgeVectorBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(KnowledgeVectorBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getKnowledgeId()), KnowledgeVector::getKnowledgeId, bo.getKnowledgeId()); + lqw.eq(StringUtils.isNotBlank(bo.getDocId()), KnowledgeVector::getDocId, bo.getDocId()); + lqw.eq(StringUtils.isNotBlank(bo.getFragmentId()), KnowledgeVector::getFragmentId, bo.getFragmentId()); + lqw.eq(StringUtils.isNotBlank(bo.getVectorId()), KnowledgeVector::getVectorId, bo.getVectorId()); + lqw.eq(bo.getDimension() != null, KnowledgeVector::getDimension, bo.getDimension()); + lqw.like(StringUtils.isNotBlank(bo.getModelName()), KnowledgeVector::getModelName, bo.getModelName()); + lqw.eq(bo.getStatus() != null, KnowledgeVector::getStatus, bo.getStatus()); + return lqw; + } + + /** + * 新增知识片段向量 + */ + @Override + public Boolean insertByBo(KnowledgeVectorBo bo) { + KnowledgeVector add = MapstructUtils.convert(bo, KnowledgeVector.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改知识片段向量 + */ + @Override + public Boolean updateByBo(KnowledgeVectorBo bo) { + KnowledgeVector update = MapstructUtils.convert(bo, KnowledgeVector.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(KnowledgeVector entity) { + //TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除知识片段向量 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + //TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据向量ID查询知识片段向量 + */ + @Override + public KnowledgeVectorVo queryByVectorId(String vectorId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getVectorId, vectorId); + return baseMapper.selectVoOne(lqw); + } + + /** + * 根据知识库ID查询知识片段向量列表 + */ + @Override + public List queryByKnowledgeId(String knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getKnowledgeId, knowledgeId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据文档ID查询知识片段向量列表 + */ + @Override + public List queryByDocId(String docId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getDocId, docId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据知识片段ID查询知识片段向量列表 + */ + @Override + public List queryByFragmentId(String fragmentId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getFragmentId, fragmentId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据模型名称查询知识片段向量列表 + */ + @Override + public List queryByModelName(String modelName) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getModelName, modelName); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据状态查询知识片段向量列表 + */ + @Override + public List queryByStatus(Integer status) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getStatus, status); + return baseMapper.selectVoList(lqw); + } + + /** + * 批量更新知识片段向量状态 + */ + @Override + public Boolean updateStatusByIds(Collection ids, Integer status) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.in(KnowledgeVector::getId, ids); + KnowledgeVector update = new KnowledgeVector(); + update.setStatus(status); + return baseMapper.update(update, lqw) > 0; + } + + /** + * 根据知识库ID和状态查询知识片段向量列表 + */ + @Override + public List queryByKnowledgeIdAndStatus(String knowledgeId, Integer status) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getKnowledgeId, knowledgeId) + .eq(KnowledgeVector::getStatus, status); + return baseMapper.selectVoList(lqw); + } + + @Override + public String generateVector(String knowledgeId, String docId, String fragmentId, String content, String modelName) { + try { + // 验证模型是否可用 + if (!isModelAvailable(modelName)) { + throw new ServiceException("不支持的向量模型: " + modelName); + } + + double[] vectorData = embeddingService.embed(content, modelName); + String vectorId = IdUtil.fastSimpleUUID(); + KnowledgeVector vector = new KnowledgeVector(); + vector.setKnowledgeId(knowledgeId); + vector.setDocId(docId); + vector.setFragmentId(fragmentId); + vector.setVectorId(vectorId); + vector.setVectorData(Arrays.toString(vectorData)); + vector.setDimension(vectorData.length); + vector.setModelName(modelName); + vector.setStatus(20); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getFragmentId, fragmentId).eq(KnowledgeVector::getModelName, modelName); + KnowledgeVector exist = baseMapper.selectOne(lqw); + if (exist == null) { + baseMapper.insert(vector); + } else { + exist.setVectorData(vector.getVectorData()); + exist.setDimension(vector.getDimension()); + exist.setStatus(20); + baseMapper.updateById(exist); + vectorId = exist.getVectorId(); + } + vectorStoreManager.current().upsert(knowledgeId, docId, fragmentId, vectorId, vectorData, content, modelName); + log.info("为片段 {} 生成向量成功,向量ID: {}", fragmentId, vectorId); + return vectorId; + } catch (Exception e) { + log.error("生成向量失败: {}", e.getMessage(), e); + throw new ServiceException("生成向量失败: " + e.getMessage()); + } + } + + @Override + public List batchGenerateVectors(String knowledgeId, String docId, + Map fragmentContents, String modelName) { + List successIds = new ArrayList<>(); + + for (Map.Entry entry : fragmentContents.entrySet()) { + try { + String vectorId = generateVector(knowledgeId, docId, entry.getKey(), entry.getValue(), modelName); + successIds.add(entry.getKey()); + } catch (Exception e) { + log.error("为片段 {} 生成向量失败: {}", entry.getKey(), e.getMessage()); + } + } + + log.info("批量生成向量完成,成功: {}/{}", successIds.size(), fragmentContents.size()); + return successIds; + } + + @Override + public List vectorSimilaritySearch(String knowledgeId, String queryText, + String modelName, Integer topK, Double threshold) { + try { + // 将查询文本转换为向量 + double[] queryVector = embeddingService.embed(queryText, modelName); + + // 获取知识库中的所有向量 + return vectorStoreManager.current().search(knowledgeId, queryVector, modelName, topK, threshold); + + // 计算相似度并排序 + + + } catch (Exception e) { + log.error("向量搜索失败: {}", e.getMessage(), e); + throw new ServiceException("向量搜索失败: " + e.getMessage()); + } + } + + @Override + public double[] textToVector(String text, String modelName) { + return embeddingService.embed(text, modelName); + } + + @Override + public double calculateSimilarity(double[] vector1, double[] vector2) { + if (vector1.length != vector2.length) { + throw new ServiceException("向量维度不匹配"); + } + + // 计算余弦相似度 + double dotProduct = 0; + double norm1 = 0; + double norm2 = 0; + + for (int i = 0; i < vector1.length; i++) { + dotProduct += vector1[i] * vector2[i]; + norm1 += vector1[i] * vector1[i]; + norm2 += vector2[i] * vector2[i]; + } + + if (norm1 == 0 || norm2 == 0) { + return 0; + } + + return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); + } + + @Override + public int deleteVectorsByKnowledgeId(String knowledgeId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getKnowledgeId, knowledgeId); + return baseMapper.delete(lqw); + } + + @Override + public int deleteVectorsByDocId(String docId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getDocId, docId); + return baseMapper.delete(lqw); + } + + @Override + public Map getVectorStatistics(String knowledgeId) { + Map stats = new HashMap<>(); + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(KnowledgeVector::getKnowledgeId, knowledgeId); + + List vectors = baseMapper.selectList(lqw); + + stats.put("totalVectors", vectors.size()); + stats.put("completedVectors", vectors.stream().filter(v -> v.getStatus() == 20).count()); + stats.put("processingVectors", vectors.stream().filter(v -> v.getStatus() == 15).count()); + stats.put("failedVectors", vectors.stream().filter(v -> v.getStatus() == 30).count()); + + // 按模型统计 + Map modelStats = vectors.stream() + .collect(Collectors.groupingBy(KnowledgeVector::getModelName, Collectors.counting())); + stats.put("modelStatistics", modelStats); + + return stats; + } + + @Override + public boolean isModelAvailable(String modelName) { + return AVAILABLE_MODELS.contains(modelName); + } + + @Override + public List getAvailableModels() { + return new ArrayList<>(AVAILABLE_MODELS); + } + + /** + * 根据模型名称获取向量维度 + */ + private int getDimensionByModel(String modelName) { + switch (modelName) { + case "text-embedding-ada-002": + return 1536; + case "text-embedding-3-small": + return 1536; + case "text-embedding-3-large": + return 3072; + case "bge-large-zh": + return 1024; + case "bge-base-zh": + return 768; + case "m3e-base": + return 768; + case "m3e-large": + return 1024; + default: + return 768; // 默认维度 + } + } + + /** + * 解析向量数据字符串 + */ + private double[] parseVectorData(String vectorDataStr) { + try { + if (StrUtil.isBlank(vectorDataStr)) { + return null; + } + String cleanStr = vectorDataStr.replace("[", "").replace("]", ""); + String[] parts = cleanStr.split(","); + double[] vector = new double[parts.length]; + for (int i = 0; i < parts.length; i++) { + vector[i] = Double.parseDouble(parts[i].trim()); + } + return vector; + } catch (Exception e) { + log.error("解析向量数据失败: {}", e.getMessage()); + return null; + } + } + +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/DbVectorStoreService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/DbVectorStoreService.java new file mode 100644 index 0000000..b1cc767 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/DbVectorStoreService.java @@ -0,0 +1,94 @@ +package org.dromara.knowledge.vectorstore; + +import lombok.RequiredArgsConstructor; +import org.dromara.knowledge.domain.KnowledgeFragment; +import org.dromara.knowledge.domain.KnowledgeVector; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.mapper.KnowledgeVectorMapper; +import org.dromara.knowledge.service.KnowledgeFragmentService; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class DbVectorStoreService implements VectorStoreService { + + private final KnowledgeVectorMapper mapper; + private final KnowledgeFragmentService fragmentService; + + @Override + public void upsert(String knowledgeId, String docId, String fragmentId, String vectorId, double[] vector, String content, String modelName) { + KnowledgeVector kv = new KnowledgeVector(); + kv.setKnowledgeId(knowledgeId); + kv.setDocId(docId); + kv.setFragmentId(fragmentId); + kv.setVectorId(vectorId); + kv.setVectorData(Arrays.toString(vector)); + kv.setDimension(vector.length); + kv.setModelName(modelName); + kv.setStatus(20); + mapper.insert(kv); + } + + @Override + public List search(String knowledgeId, double[] queryVector, String modelName, Integer topK, Double threshold) { + List vectors = mapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().lambda() + .eq(KnowledgeVector::getKnowledgeId, knowledgeId) + .eq(KnowledgeVector::getModelName, modelName)); + List results = new ArrayList<>(); + for (KnowledgeVector v : vectors) { + double[] vec = parse(v.getVectorData()); + if (vec == null || vec.length != queryVector.length) continue; + double sim = cosine(queryVector, vec); + if (threshold != null && sim < threshold) continue; + KnowledgeFragment frag = fragmentService.getById(v.getFragmentId()); + if (frag == null) continue; + KnowledgeSearchResultVo r = new KnowledgeSearchResultVo(); + r.setFragmentId(v.getFragmentId()); + r.setDocId(v.getDocId()); + r.setContent(frag.getContent()); + r.setSimilarityScore(sim); + r.setFragmentIndex(frag.getIdx()); + r.setCreateTime(frag.getCreateTime()); + results.add(r); + } + results.sort((a, b) -> Double.compare(b.getSimilarityScore(), a.getSimilarityScore())); + for (int i = 0; i < results.size(); i++) results.get(i).setRanking(i + 1); + int limit = topK != null ? topK : 10; + return results.stream().limit(limit).collect(Collectors.toList()); + } + + @Override + public void deleteByKnowledgeId(String knowledgeId) { + mapper.delete(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().lambda() + .eq(KnowledgeVector::getKnowledgeId, knowledgeId)); + } + + @Override + public void rebuildIndex(String knowledgeId) { + } + + private double[] parse(String s) { + if (s == null || s.isBlank()) return null; + String clean = s.replace("[", "").replace("]", ""); + String[] parts = clean.split(","); + double[] v = new double[parts.length]; + for (int i = 0; i < parts.length; i++) v[i] = Double.parseDouble(parts[i].trim()); + return v; + } + + private double cosine(double[] a, double[] b) { + double dot = 0, n1 = 0, n2 = 0; + for (int i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + n1 += a[i] * a[i]; + n2 += b[i] * b[i]; + } + if (n1 == 0 || n2 == 0) return 0; + return dot / (Math.sqrt(n1) * Math.sqrt(n2)); + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/QdrantVectorStoreService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/QdrantVectorStoreService.java new file mode 100644 index 0000000..64f437b --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/QdrantVectorStoreService.java @@ -0,0 +1,176 @@ +package org.dromara.knowledge.vectorstore; + +import cn.hutool.core.util.StrUtil; +import okhttp3.*; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.knowledge.domain.KnowledgeFragment; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.service.KnowledgeFragmentService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.*; + +@Component +public class QdrantVectorStoreService implements VectorStoreService { + @Value("${ruoyi.ai.knowledge.vector-store.qdrant.base-url:http://localhost:6333}") + private String baseUrl; + @Value("${ruoyi.ai.knowledge.vector-store.qdrant.api-key:}") + private String apiKey; + @Value("${ruoyi.ai.knowledge.vector-store.qdrant.collection:knowledge_vectors}") + private String collection; + @Value("${ruoyi.ai.knowledge.vector-store.qdrant.distance:Cosine}") + private String distance; + @Value("${ruoyi.ai.knowledge.embedding.default-model:bge-large-zh}") + private String defaultModel; + + private final OkHttpClient client = new OkHttpClient(); + private final KnowledgeFragmentService fragmentService; + + public QdrantVectorStoreService(KnowledgeFragmentService fragmentService) { + this.fragmentService = fragmentService; + } + + @Override + public void upsert(String knowledgeId, String docId, String fragmentId, String vectorId, double[] vector, String content, String modelName) { + Map payload = new HashMap<>(); + payload.put("knowledgeId", knowledgeId); + payload.put("docId", docId); + payload.put("fragmentId", fragmentId); + payload.put("content", content); + payload.put("model", modelName); + List vec = new ArrayList<>(); + for (double v : vector) vec.add(v); + Map point = new HashMap<>(); + point.put("id", vectorId); + point.put("vector", vec); + point.put("payload", payload); + Map body = new HashMap<>(); + body.put("points", List.of(point)); + post("/collections/" + collection + "/points?wait=true", JsonUtils.toJsonString(body)); + } + + @Override + public List search(String knowledgeId, double[] queryVector, String modelName, Integer topK, Double threshold) { + List vec = new ArrayList<>(); + for (double v : queryVector) vec.add(v); + Map filter = Map.of("must", List.of(Map.of("key", "knowledgeId", "match", Map.of("value", knowledgeId)))); + Map body = new HashMap<>(); + body.put("vector", vec); + body.put("filter", filter); + body.put("limit", topK != null ? topK : 10); + String resp = post("/collections/" + collection + "/points/search", JsonUtils.toJsonString(body)); + List results = new ArrayList<>(); + Map parsed = JsonUtils.parseObject(resp, new com.fasterxml.jackson.core.type.TypeReference>() { + }); + Object r = parsed != null ? parsed.get("result") : null; + if (r instanceof List list) { + int rank = 1; + for (Object item : list) { + if (!(item instanceof Map m)) continue; + Double score = toDouble(m.get("score")); + Map payload = (Map) m.get("payload"); + if (threshold != null && score != null && score < threshold) continue; + KnowledgeSearchResultVo vo = new KnowledgeSearchResultVo(); + if (payload != null) { + String fragId = String.valueOf(payload.get("fragmentId")); + vo.setFragmentId(fragId); + vo.setDocId(String.valueOf(payload.get("docId"))); + Object c = payload.get("content"); + String content = c != null ? String.valueOf(c) : null; + if (StrUtil.isBlank(content)) { + var frag = fragmentService.getById(fragId); + if (frag != null) { + content = frag.getContent(); + vo.setFragmentIndex(frag.getIdx()); + vo.setCreateTime(frag.getCreateTime()); + } + } else { + KnowledgeFragment frag = fragmentService.getById(fragId); + if (frag != null) { + vo.setFragmentIndex(frag.getIdx()); + vo.setCreateTime(frag.getCreateTime()); + } + } + vo.setContent(content); + } + vo.setSimilarityScore(score != null ? score : 0); + vo.setRanking(rank++); + results.add(vo); + } + } + return results; + } + + @Override + public void deleteByKnowledgeId(String knowledgeId) { + Map filter = Map.of("must", List.of(Map.of("key", "knowledgeId", "match", Map.of("value", knowledgeId)))); + Map body = new HashMap<>(); + body.put("filter", filter); + post("/collections/" + collection + "/points/delete", JsonUtils.toJsonString(body)); + } + + @Override + public void rebuildIndex(String knowledgeId) { + ensureCollection(1024); + } + + private void ensureCollection(int size) { + String get = get("/collections/" + collection); + if (get == null || get.contains("\"status\":\"error\"")) { + Map vectors = Map.of("size", size, "distance", distance); + Map body = Map.of("vectors", vectors); + put("/collections/" + collection, JsonUtils.toJsonString(body)); + } + } + + private String get(String path) { + Request.Builder b = new Request.Builder().url(url(path)).get(); + if (apiKey != null && !apiKey.isBlank()) b.header("api-key", apiKey); + try (Response resp = client.newCall(b.build()).execute()) { + if (resp.body() == null) return null; + return resp.body().string(); + } catch (Exception e) { + return null; + } + } + + private String put(String path, String json) { + Request.Builder b = new Request.Builder().url(url(path)) + .put(RequestBody.create(MediaType.parse("application/json"), json.getBytes(StandardCharsets.UTF_8))); + if (apiKey != null && !apiKey.isBlank()) b.header("api-key", apiKey); + try (Response resp = client.newCall(b.build()).execute()) { + return Objects.requireNonNull(resp.body()).string(); + } catch (Exception e) { + return null; + } + } + + private String post(String path, String json) { + Request.Builder b = new Request.Builder().url(url(path)) + .post(RequestBody.create(MediaType.parse("application/json"), json.getBytes(StandardCharsets.UTF_8))); + if (apiKey != null && !apiKey.isBlank()) b.header("api-key", apiKey); + try (Response resp = client.newCall(b.build()).execute()) { + return Objects.requireNonNull(resp.body()).string(); + } catch (Exception e) { + return null; + } + } + + private String url(String path) { + if (baseUrl.endsWith("/") && path.startsWith("/")) return baseUrl + path.substring(1); + if (!baseUrl.endsWith("/") && !path.startsWith("/")) return baseUrl + "/" + path; + return baseUrl + path; + } + + private Double toDouble(Object o) { + try { + if (o == null) return null; + if (o instanceof Number n) return n.doubleValue(); + return Double.parseDouble(String.valueOf(o)); + } catch (Exception e) { + return null; + } + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/VectorStoreManager.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/VectorStoreManager.java new file mode 100644 index 0000000..a50b2d3 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/VectorStoreManager.java @@ -0,0 +1,25 @@ +package org.dromara.knowledge.vectorstore; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class VectorStoreManager { + private final VectorStoreService dbService; + private final QdrantVectorStoreService qdrantService; + private final WeaviateVectorStoreService weaviateService; + @Value("${ruoyi.ai.knowledge.vector-store.type:db}") + private String type; + + public VectorStoreManager(DbVectorStoreService dbService, QdrantVectorStoreService qdrantService, WeaviateVectorStoreService weaviateService) { + this.dbService = dbService; + this.qdrantService = qdrantService; + this.weaviateService = weaviateService; + } + + public VectorStoreService current() { + if ("qdrant".equalsIgnoreCase(type)) return qdrantService; + if ("weaviate".equalsIgnoreCase(type)) return weaviateService; + return dbService; + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/VectorStoreService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/VectorStoreService.java new file mode 100644 index 0000000..f32b044 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/VectorStoreService.java @@ -0,0 +1,15 @@ +package org.dromara.knowledge.vectorstore; + +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; + +import java.util.List; + +public interface VectorStoreService { + void upsert(String knowledgeId, String docId, String fragmentId, String vectorId, double[] vector, String content, String modelName); + + List search(String knowledgeId, double[] queryVector, String modelName, Integer topK, Double threshold); + + void deleteByKnowledgeId(String knowledgeId); + + void rebuildIndex(String knowledgeId); +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/WeaviateVectorStoreService.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/WeaviateVectorStoreService.java new file mode 100644 index 0000000..bd30e39 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/vectorstore/WeaviateVectorStoreService.java @@ -0,0 +1,143 @@ +package org.dromara.knowledge.vectorstore; + +import okhttp3.*; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.knowledge.domain.KnowledgeFragment; +import org.dromara.knowledge.domain.vo.KnowledgeSearchResultVo; +import org.dromara.knowledge.service.KnowledgeFragmentService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.*; + +@Component +public class WeaviateVectorStoreService implements VectorStoreService { + @Value("${ruoyi.ai.knowledge.vector-store.weaviate.base-url:http://localhost:8080}") + private String baseUrl; + @Value("${ruoyi.ai.knowledge.vector-store.weaviate.api-key:}") + private String apiKey; + @Value("${ruoyi.ai.knowledge.vector-store.weaviate.class-name:KnowledgeVector}") + private String className; + + private final OkHttpClient client = new OkHttpClient(); + private final KnowledgeFragmentService fragmentService; + + public WeaviateVectorStoreService(KnowledgeFragmentService fragmentService) { + this.fragmentService = fragmentService; + } + + @Override + public void upsert(String knowledgeId, String docId, String fragmentId, String vectorId, double[] vector, String content, String modelName) { + Map obj = new HashMap<>(); + obj.put("class", className); + obj.put("id", vectorId); + obj.put("properties", Map.of( + "knowledgeId", knowledgeId, + "docId", docId, + "fragmentId", fragmentId, + "content", content, + "model", modelName + )); + List vec = new ArrayList<>(); + for (double v : vector) vec.add(v); + obj.put("vector", vec); + String body = JsonUtils.toJsonString(obj); + post("/v1/objects", body); + } + + @Override + public List search(String knowledgeId, double[] queryVector, String modelName, Integer topK, Double threshold) { + List vec = new ArrayList<>(); + for (double v : queryVector) vec.add(v); + String gql = "{" + + " Get {" + + " " + className + "(nearVector: {vector: " + JsonUtils.toJsonString(vec) + "}, limit: " + (topK != null ? topK : 10) + ", where: {path: [\"knowledgeId\"], operator: Equal, valueString: \"" + knowledgeId + "\"}) {" + + " fragmentId docId content _additional { distance id }" + + " }" + + " }" + + "}"; + String body = JsonUtils.toJsonString(Map.of("query", gql)); + String resp = post("/v1/graphql", body); + Map parsed = JsonUtils.parseObject(resp, new com.fasterxml.jackson.core.type.TypeReference>() { + }); + Map data = parsed != null ? (Map) parsed.get("data") : null; + Map get = data != null ? (Map) data.get("Get") : null; + List list = get != null ? (List) get.get(className) : null; + List results = new ArrayList<>(); + if (list != null) { + int rank = 1; + for (Object o : list) { + if (!(o instanceof Map m)) continue; + KnowledgeSearchResultVo vo = new KnowledgeSearchResultVo(); + vo.setFragmentId(String.valueOf(m.get("fragmentId"))); + vo.setDocId(String.valueOf(m.get("docId"))); + String content = String.valueOf(m.get("content")); + if (content == null || content.equals("null")) { + KnowledgeFragment frag = fragmentService.getById(vo.getFragmentId()); + if (frag != null) { + content = frag.getContent(); + vo.setFragmentIndex(frag.getIdx()); + vo.setCreateTime(frag.getCreateTime()); + } + } else { + KnowledgeFragment frag = fragmentService.getById(vo.getFragmentId()); + if (frag != null) { + vo.setFragmentIndex(frag.getIdx()); + vo.setCreateTime(frag.getCreateTime()); + } + } + vo.setContent(content); + Map add = (Map) m.get("_additional"); + Double dist = add != null ? toDouble(add.get("distance")) : null; + double score = dist != null ? (1.0 - Math.min(1.0, dist)) : 0.0; + if (threshold != null && score < threshold) continue; + vo.setSimilarityScore(score); + vo.setRanking(rank++); + results.add(vo); + } + } + return results; + } + + @Override + public void deleteByKnowledgeId(String knowledgeId) { + String gql = "{ " + + " Aggregate { " + + " " + className + "(where: {path: [\"knowledgeId\"], operator: Equal, valueString: \"" + knowledgeId + "\"}) { meta { count } }" + + " }" + + "}"; + post("/v1/graphql", JsonUtils.toJsonString(Map.of("query", gql))); + } + + @Override + public void rebuildIndex(String knowledgeId) { + } + + private String post(String path, String json) { + Request.Builder b = new Request.Builder().url(url(path)) + .post(RequestBody.create(MediaType.parse("application/json"), json.getBytes(StandardCharsets.UTF_8))); + if (apiKey != null && !apiKey.isBlank()) b.header("Authorization", "Bearer " + apiKey); + try (Response resp = client.newCall(b.build()).execute()) { + return Objects.requireNonNull(resp.body()).string(); + } catch (Exception e) { + return null; + } + } + + private String url(String path) { + if (baseUrl.endsWith("/") && path.startsWith("/")) return baseUrl + path.substring(1); + if (!baseUrl.endsWith("/") && !path.startsWith("/")) return baseUrl + "/" + path; + return baseUrl + path; + } + + private Double toDouble(Object o) { + try { + if (o == null) return null; + if (o instanceof Number n) return n.doubleValue(); + return Double.parseDouble(String.valueOf(o)); + } catch (Exception e) { + return null; + } + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/version/DefaultVersionManager.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/version/DefaultVersionManager.java new file mode 100644 index 0000000..57b4511 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/version/DefaultVersionManager.java @@ -0,0 +1,22 @@ +package org.dromara.knowledge.version; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class DefaultVersionManager implements VersionManager { + @Override + public String createVersion(String knowledgeId, String remark) { + String versionId = java.util.UUID.randomUUID().toString(); + log.info("创建知识库版本: {} remark={}", versionId, remark); + return versionId; + } + + @Override + public boolean rollback(String knowledgeId, String versionId) { + log.info("回滚知识库 {} 到版本 {}", knowledgeId, versionId); + return true; + } +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/version/VersionManager.java b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/version/VersionManager.java new file mode 100644 index 0000000..3e93f7e --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/java/org/dromara/knowledge/version/VersionManager.java @@ -0,0 +1,8 @@ +package org.dromara.knowledge.version; + +public interface VersionManager { + String createVersion(String knowledgeId, String remark); + + boolean rollback(String knowledgeId, String versionId); +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeAttachMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeAttachMapper.xml new file mode 100644 index 0000000..f3f4d2d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeAttachMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + select id, + knowledge_id, + doc_id, + doc_name, + doc_type, + oss_id, + pic_status, + pic_anys_status, + vector_status, + content, + create_by, + create_time, + update_by, + update_time, + remark + from knowledge_attach + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeFragmentMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeFragmentMapper.xml new file mode 100644 index 0000000..5149755 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeFragmentMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + select id, + knowledge_id, + doc_id, + fid, + idx, + content, + create_by, + create_time, + update_by, + update_time, + remark + from knowledge_fragment + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeHitLogMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeHitLogMapper.xml new file mode 100644 index 0000000..7d78820 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeHitLogMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + select id, + knowledge_id, + fragment_id, + user_id, + session_id, + query, + score, + hit_time, + ip, + user_agent, + create_by, + create_time, + update_by, + update_time, + remark + from knowledge_hit_log + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeInfoMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeInfoMapper.xml new file mode 100644 index 0000000..fda6367 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeInfoMapper.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + select id, + user_id, + knowledge_id, + knowledge_name, + share, + description, + knowledge_separator, + question_separator, + overlap_char, + retrieve_limit, + text_block_size, + vector_model_name, + embedding_model_name, + system_prompt, + create_by, + create_time, + update_by, + update_time, + remark + from knowledge_info + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeJobMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeJobMapper.xml new file mode 100644 index 0000000..53bfa3a --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeJobMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + select id, + knowledge_id, + job_type, + job_name, + job_params, + status, + progress, + start_time, + end_time, + error_msg, + create_by, + create_time, + update_by, + update_time, + remark + from knowledge_job + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgePromptTemplateMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgePromptTemplateMapper.xml new file mode 100644 index 0000000..75bce77 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgePromptTemplateMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + select id, + template_name, + template_content, + category, + create_by, + create_time, + update_by, + update_time, + remark, + del_flag + from knowledge_prompt_template + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleGroupMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleGroupMapper.xml new file mode 100644 index 0000000..3a4b309 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleGroupMapper.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + select id, + name, + del_flag, + create_by, + create_time, + update_by, + update_time, + remark + from knowledge_role_group + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleMapper.xml new file mode 100644 index 0000000..76fc5ce --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + select id, + name, + del_flag, + create_by, + create_time, + update_by, + update_time, + remark, + group_id + from knowledge_role + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleRelationMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleRelationMapper.xml new file mode 100644 index 0000000..292ee3a --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeRoleRelationMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + select id, + del_flag, + create_by, + create_time, + update_by, + update_time, + remark, + knowledge_role_id, + knowledge_id + from knowledge_role_relation + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaFieldMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaFieldMapper.xml new file mode 100644 index 0000000..3031642 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaFieldMapper.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select id, + schema_id, + schema_name, + name, + code, + type, + comment, + is_pk, + is_required, + is_unique, + default_value, + length, + scale, + is_list, + is_query, + is_insert, + is_edit, + query_type, + html_type, + dict_type, + sort, + del_flag, + create_by, + create_time, + update_by, + update_time, + remark + from knowledge_schema_field + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaGroupMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaGroupMapper.xml new file mode 100644 index 0000000..8bb42bf --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaGroupMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + select id, + name, + code, + icon, + remark, + del_flag, + create_by, + create_time, + update_by, + update_time + from knowledge_schema_group + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaMapper.xml new file mode 100644 index 0000000..66e7084 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeSchemaMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + select id, + schema_group_id, + name, + code, + table_name, + remark, + del_flag, + create_by, + create_time, + update_by, + update_time + from knowledge_schema + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeVectorMapper.xml b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeVectorMapper.xml new file mode 100644 index 0000000..c50cb6d --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/main/resources/mapper/knowledge/KnowledgeVectorMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + select id, + knowledge_id, + doc_id, + fragment_id, + vector_id, + vector_data, + dimension, + model_name, + status, + create_by, + create_time, + update_by, + update_time, + remark + from knowledge_vector + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/integration/RagIntegrationTest.java b/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/integration/RagIntegrationTest.java new file mode 100644 index 0000000..79716d0 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/integration/RagIntegrationTest.java @@ -0,0 +1,7 @@ +package org.dromara.knowledge.integration; + +public class RagIntegrationTest { + public void smoke() { + if (!true) throw new RuntimeException("should not happen"); + } +} diff --git a/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/mock/MockDocs.java b/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/mock/MockDocs.java new file mode 100644 index 0000000..27170d2 --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/mock/MockDocs.java @@ -0,0 +1,8 @@ +package org.dromara.knowledge.mock; + +public class MockDocs { + public static byte[] simple() { + return "hello knowledge".getBytes(); + } +} + diff --git a/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/unit/RagContractsTest.java b/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/unit/RagContractsTest.java new file mode 100644 index 0000000..3d222aa --- /dev/null +++ b/ruoyi-modules/ruoyi-knowledge/src/test/java/org/dromara/knowledge/unit/RagContractsTest.java @@ -0,0 +1,7 @@ +package org.dromara.knowledge.unit; + +public class RagContractsTest { + public void smoke() { + if (!true) throw new RuntimeException("should not happen"); + } +} diff --git a/ruoyi-modules/ruoyi-mall/MODULE_STRUCTURE.md b/ruoyi-modules/ruoyi-mall/MODULE_STRUCTURE.md new file mode 100644 index 0000000..4e41f91 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/MODULE_STRUCTURE.md @@ -0,0 +1,1253 @@ +# 商城模块结构说明 + +## 模块拆分 + +根据 `MODULE_STRUCTURE_PLAN.md` 的要求,`ruoyi-mall` 模块已按照规划拆分为以下子模块: + +### 1. ruoyi-mall-product (商品中心) + +- **职责**: 商品(SPU)、SKU、分类、品牌、属性管理 +- **包名**: `org.dromara.mall.product` +- **状态**: ✅ 核心功能已实现(商品管理✅、分类管理✅、品牌管理✅、属性管理✅) +- **数据库表**: `mall_product`, `mall_sku`, `mall_category`, `mall_brand`, `mall_attribute_template`, `mall_attribute`, `mall_attribute_value`, `mall_product_attribute` + +### 2. ruoyi-mall-trade (交易中心) + +- **职责**: 购物车、订单、售后管理 +- **包名**: `org.dromara.mall.trade` +- **状态**: ✅ 核心功能已实现(购物车✅、订单✅、售后✅、评价✅) +- **数据库表**: `mall_cart`, `mall_order`, `mall_order_item`, `mall_after_sale`, `mall_order_evaluate`, + `mall_order_coupon` + +### 3. ruoyi-mall-coupon (优惠券中心) + +- **职责**: 优惠券发放、核销、过期处理 +- **包名**: `org.dromara.mall.coupon` +- **状态**: ✅ 核心功能已实现(优惠券管理✅、用户优惠券✅) +- **数据库表**: `mall_coupon`, `mall_coupon_user` + +### 4. ruoyi-mall-promotion (营销中心) + +- **职责**: 营销活动、拼团、秒杀、砍价 +- **包名**: `org.dromara.mall.promotion` +- **状态**: ✅ 核心功能已实现(活动管理✅、秒杀✅、拼团✅、砍价✅) +- **数据库表**: `mall_promotion`, `mall_group_buy`, `mall_group_buy_order`, `mall_seckill`, `mall_bargain`, + `mall_bargain_record`, `mall_bargain_help` + +### 5. ruoyi-mall-statistics (统计中心) + +- **职责**: 交易分析、会员分析、数据统计 +- **包名**: `org.dromara.mall.statistics` +- **状态**: ✅ 交易统计功能已实现 + +## 已完成工作 + +### 1. 模块结构 + +- ✅ 创建了所有子模块的目录结构 +- ✅ 创建了各子模块的 `pom.xml` 配置文件 +- ✅ 配置了模块依赖关系 + +### 2. 自动配置 + +- ✅ 创建了各子模块的 `AutoConfiguration` 类 +- ✅ 创建了 `spring.factories` 文件 +- ✅ 配置了组件扫描 + +### 3. 数据库 + +- ✅ 扩展了 `script/sql/ry_mall.sql` 文件 +- ✅ 添加了分类、品牌表 +- ✅ 添加了优惠券相关表 +- ✅ 添加了营销活动相关表(拼团、秒杀等) +- ✅ 添加了商品属性相关表(属性模板、属性、属性值、商品属性关联) + +### 4. 文档 + +- ✅ 创建了各子模块的 README.md 文件 +- ✅ 更新了主模块的 README.md 文件 + +## 代码迁移说明 + +### 当前状态 + +- 现有代码位于 `ruoyi-mall/src/main/java/org/dromara/mall/` 目录下 +- 现有代码使用 `org.dromara.mall` 作为包名 +- 现有代码包含:商品、SKU、购物车、订单等功能 + +### 迁移建议 + +由于代码迁移涉及大量文件的包名修改,建议采用渐进式迁移: + +1. **保留现有代码** + - 现有代码继续保留在 `ruoyi-mall/src` 目录 + - 保证现有功能正常运行 + +2. **新功能开发** + - 新功能直接在新子模块中开发 + - 使用新的包名结构(如 `org.dromara.mall.product`) + +3. **逐步迁移** + - 商品相关代码 → `ruoyi-mall-product` + - 交易相关代码 → `ruoyi-mall-trade` + - 优惠券功能 → `ruoyi-mall-coupon` + - 营销功能 → `ruoyi-mall-promotion` + - 统计功能 → `ruoyi-mall-statistics` + + +## 模块依赖关系 + +``` +ruoyi-mall (聚合模块) +├── ruoyi-mall-product (商品中心) +│ └── 依赖: ruoyi-common-* +├── ruoyi-mall-trade (交易中心) +│ ├── 依赖: ruoyi-mall-product +│ └── 依赖: ruoyi-pay +├── ruoyi-mall-coupon (优惠券中心) +│ └── 依赖: ruoyi-common-* +├── ruoyi-mall-promotion (营销中心) +│ ├── 依赖: ruoyi-mall-product +│ └── 依赖: ruoyi-mall-coupon +└── ruoyi-mall-statistics (统计中心) + ├── 依赖: ruoyi-mall-product + └── 依赖: ruoyi-mall-trade +``` + +## 下一步工作 + +1. **代码迁移** (可选) + - 将现有代码迁移到对应的子模块 + - 修改包名以符合新的模块结构 + +2. **功能实现** + - 实现各子模块的待实现功能 + - 参考各子模块的 README.md + +3. **测试验证** + - 验证各子模块的自动配置 + - 验证模块间的依赖关系 + +## 数据库表结构说明 + +### 1. ruoyi-mall-product (商品中心) + +#### 商品表 (mall_product) + +- **功能**: 存储商品(SPU)基本信息 +- **关键字段**: `name`, `category_id`, `brand_id`, `status`, `sales_count`, `view_count`, `cover_url`, `images`, + `description`, `detail` +- **索引**: `category_id`, `brand_id`, `status` +- **状态说明**: + - 0: 上架 + - 1: 下架 + +#### SKU表 (mall_sku) + +- **功能**: 存储商品SKU信息,包括价格、库存、规格等 +- **关键字段**: `product_id`, `sku_code`, `price`, `stock`, `specs`, `status` +- **索引**: `product_id`, `sku_code`, `status` +- **状态说明**: + - 0: 启用 + - 1: 禁用 + +#### 商品分类表 (mall_category) + +- **功能**: 商品分类树形结构 +- **关键字段**: `name`, `parent_id`, `level`, `sort`, `icon`, `status` +- **索引**: `parent_id`, `status` +- **状态说明**: + - 0: 启用 + - 1: 禁用 +- **级别说明**: + - 1: 一级分类 + - 2: 二级分类 + - 3: 三级分类 +- **说明**: 支持三级分类,通过 `parent_id` 构建树形结构,`parent_id=0` 表示顶级分类 + +#### 品牌表 (mall_brand) + +- **功能**: 商品品牌信息 +- **关键字段**: `name`, `logo`, `description`, `sort`, `status` +- **索引**: `status` +- **状态说明**: + - 0: 启用 + - 1: 禁用 + +#### 属性模板表 (mall_attribute_template) + +- **功能**: 属性模板定义,可绑定到分类 +- **关键字段**: `name`, `category_id`, `sort`, `status` +- **索引**: `category_id`, `status` + +#### 属性表 (mall_attribute) + +- **功能**: 属性定义 +- **关键字段**: `template_id`, `name`, `type`, `required`, `sort`, `status` +- **索引**: `template_id`, `status` +- **类型说明**: + - 0: 文本 + - 1: 数字 + - 2: 日期 + - 3: 枚举 + +#### 属性值表 (mall_attribute_value) + +- **功能**: 属性的值(用于枚举类型属性) +- **关键字段**: `attribute_id`, `value`, `sort`, `status` +- **索引**: `attribute_id`, `status` + +#### 商品属性关联表 (mall_product_attribute) + +- **功能**: 商品关联的属性值 +- **关键字段**: `product_id`, `attribute_id`, `attribute_value_id`, `value` +- **索引**: `product_id`, `attribute_id`, `attribute_value_id` + +### 2. ruoyi-mall-trade (交易中心) + +#### 购物车表 (mall_cart) + +- **功能**: 用户购物车信息 +- **关键字段**: `user_id`, `sku_id`, `quantity`, `selected` +- **索引**: `user_id`, `uk_user_sku` (唯一索引) + +#### 订单表 (mall_order) + +- **功能**: 订单主表 +- **关键字段**: `order_no`, `user_id`, `total_amount`, `pay_amount`, `pay_status`, `order_status`, `delivery_status`, + `receiver_*` +- **索引**: `order_no` (唯一), `user_id`, `pay_order_no`, `order_status`, `pay_status` +- **状态说明**: + - `pay_status`: + - 0: 未支付 + - 1: 已支付 + - 2: 已退款 + - `order_status`: + - 0: 待付款 + - 1: 待发货 + - 2: 待收货 + - 3: 已完成 + - 4: 已取消 + - `delivery_status`: + - 0: 未发货 + - 1: 已发货 + - 2: 已收货 + +#### 订单明细表 (mall_order_item) + +- **功能**: 订单商品明细 +- **关键字段**: `order_id`, `product_id`, `sku_id`, `price`, `quantity`, `total_amount` +- **索引**: `order_id`, `order_no`, `product_id`, `sku_id` + +#### 收货地址表 (mall_address) + +- **功能**: 用户收货地址管理 +- **关键字段**: `user_id`, `receiver_name`, `receiver_phone`, `province`, `city`, `district`, `address`, `is_default` +- **索引**: `user_id`, `is_default` +- **状态说明**: + - `is_default`: 0-否,1-是 + +#### 售后申请表 (mall_after_sale) + +- **功能**: 订单售后申请(退款、退货、换货) +- **关键字段**: `after_sale_no`, `order_id`, `type`, `status`, `refund_amount` +- **索引**: `after_sale_no` (唯一), `order_id`, `user_id`, `status` +- **状态说明**: + - 0: 待审核 + - 1: 已同意 + - 2: 已拒绝 + - 3: 待退货 + - 4: 待收货 + - 5: 已完成 + - 6: 已取消 +- **类型说明**: + - 0: 退款 + - 1: 退货 + - 2: 换货 + +#### 订单评价表 (mall_order_evaluate) + +- **功能**: 订单商品评价 +- **关键字段**: `order_id`, `order_item_id`, `product_id`, `user_id`, `score`, `content`, `images` +- **索引**: `order_id`, `order_no`, `order_item_id`, `product_id`, `user_id` + +#### 订单优惠券使用记录表 (mall_order_coupon) + +- **功能**: 记录订单使用的优惠券信息 +- **关键字段**: `order_id`, `coupon_id`, `coupon_user_id`, `discount_amount` +- **索引**: `order_id`, `order_no`, `coupon_id`, `coupon_user_id` + +### 3. ruoyi-mall-coupon (优惠券中心) + +#### 优惠券表 (mall_coupon) + +- **功能**: 优惠券模板定义 +- **关键字段**: `name`, `type`, `discount_amount`, `discount_rate`, `min_amount`, `valid_start_time`, `valid_end_time` +- **索引**: `status`, `valid_time` +- **类型说明**: + - 0: 满减券 + - 1: 折扣券 + - 2: 现金券 + +#### 用户优惠券表 (mall_coupon_user) + +- **功能**: 用户领取的优惠券 +- **关键字段**: `user_id`, `coupon_id`, `status`, `used_time`, `order_id` +- **索引**: `user_id`, `coupon_id`, `status` +- **状态说明**: + - 0: 未使用 + - 1: 已使用 + - 2: 已过期 + +### 4. ruoyi-mall-promotion (营销中心) + +#### 营销活动表 (mall_promotion) + +- **功能**: 营销活动主表 +- **关键字段**: `name`, `type`, `start_time`, `end_time`, `rules` (JSON) +- **索引**: `type`, `status`, `time` +- **类型说明**: + - 0: 普通活动 + - 1: 拼团活动 + - 2: 秒杀活动 + - 3: 砍价活动 + +#### 拼团表 (mall_group_buy) + +- **功能**: 拼团活动配置 +- **关键字段**: `promotion_id`, `sku_id`, `group_price`, `group_size`, `duration_hours` +- **索引**: `promotion_id`, `sku_id` + +#### 拼团订单表 (mall_group_buy_order) + +- **功能**: 拼团订单记录 +- **关键字段**: `group_buy_id`, `group_no`, `order_id`, `user_id`, `status`, `expire_time` +- **索引**: `group_buy_id`, `group_no`, `order_id`, `status` +- **状态说明**: + - 0: 拼团中 + - 1: 已成团 + - 2: 已失败 + +#### 秒杀表 (mall_seckill) + +- **功能**: 秒杀活动配置 +- **关键字段**: `promotion_id`, `sku_id`, `seckill_price`, `seckill_stock`, `start_time`, `end_time` +- **索引**: `promotion_id`, `sku_id`, `time` + +#### 砍价活动表 (mall_bargain) + +- **功能**: 砍价活动配置 +- **关键字段**: `promotion_id`, `sku_id`, `bargain_price`, `original_price`, `min_price`, `help_count` +- **索引**: `promotion_id`, `sku_id` + +#### 砍价记录表 (mall_bargain_record) + +- **功能**: 用户发起的砍价记录 +- **关键字段**: `bargain_id`, `bargain_no`, `user_id`, `current_price`, `status`, `expire_time` +- **索引**: `bargain_id`, `bargain_no`, `user_id`, `status` +- **状态说明**: + - 0: 进行中 + - 1: 已成功 + - 2: 已失败 + - 3: 已下单 + +#### 砍价助力记录表 (mall_bargain_help) + +- **功能**: 砍价助力记录 +- **关键字段**: `bargain_record_id`, `bargain_no`, `user_id`, `help_price` +- **索引**: `bargain_record_id`, `bargain_no`, `user_id` + +### 5. ruoyi-mall-statistics (统计中心) + +统计模块主要基于现有业务表进行数据分析和统计,可根据需要创建统计汇总表。 + +- **主要数据源表**: + - `mall_order`, `mall_order_item` - 订单和订单明细表(用于交易统计、商品销售统计) + - `mall_product` - 商品表(用于商品浏览统计,基于`view_count`字段) + - `mall_promotion`, `mall_group_buy`, `mall_seckill`, `mall_bargain` - 营销活动表(用于活动效果统计) + - `mall_coupon`, `mall_coupon_user`, `mall_order_coupon` - 优惠券相关表(用于优惠券使用统计) + - 用户表(通过`mall_order.user_id`关联,用于会员分析,需会员模块支持) + +## 功能清单 + +### 1. ruoyi-mall-product (商品中心) + +#### 已实现功能 ✅ + +##### 商品管理 (SPU) + +- ✅ 商品CRUD操作 + - 商品列表查询(分页、条件筛选) + - 商品详情查询 + - 商品新增/修改/删除 + - 商品上架/下架 +- ✅ 商品浏览量和销量统计 + - 浏览量自动统计 + - 销量自动累计 +- ✅ 商品详情管理 + - 商品描述和详情富文本支持 + - 商品图片管理(封面、轮播图) + +##### SKU管理 + +- ✅ SKU CRUD操作 + - SKU列表查询(支持按商品ID查询) + - SKU详情查询 + - SKU新增/修改/删除 +- ✅ SKU库存管理 + - 库存数量管理 + - 库存扣减和恢复 +- ✅ SKU规格属性管理 + - 规格属性JSON格式存储 + - 规格属性查询和展示 + +##### 商品分类管理 ✅ + +- ✅ 分类树形结构查询 + - 支持三级分类树形结构 + - 管理端和移动端分类树查询 +- ✅ 分类CRUD操作 + - 分类列表查询(分页、条件筛选) + - 分类详情查询 + - 分类新增/修改/删除 + - 分类导出功能 +- ✅ 分类排序 + - 支持自定义排序字段 +- ⏳ 分类图标管理 + - 字段已支持,需前端集成文件上传功能 + +##### 品牌管理 ✅ + +- ✅ 品牌CRUD操作 + - 品牌列表查询(分页、条件筛选) + - 品牌详情查询 + - 品牌新增/修改/删除 + - 品牌导出功能 +- ✅ 品牌描述管理 + - 品牌描述字段支持 +- ✅ 品牌排序 + - 支持自定义排序字段 +- ✅ 品牌状态管理 + - 启用/禁用状态控制 +- ⏳ 品牌Logo上传 + - 字段已支持,需前端集成文件上传功能 + +##### 商品属性管理 ✅ + +- ✅ 属性模板管理 + - ✅ 属性模板CRUD操作 + - ✅ 属性模板绑定分类(可选) + - ✅ 属性模板导出功能 +- ✅ 属性管理 + - ✅ 属性CRUD操作 + - ✅ 属性类型管理(文本、数字、日期、枚举) + - ✅ 属性绑定模板(可选) + - ✅ 属性必填设置 + - ✅ 属性导出功能 +- ✅ 属性值管理 + - ✅ 属性值CRUD操作 + - ✅ 属性值绑定属性 + - ✅ 属性值排序 + - ✅ 属性值导出功能 +- ✅ 商品属性关联 + - ✅ 商品关联属性值 + - ✅ 批量保存商品属性 + - ✅ 根据商品ID查询属性 + - ✅ 支持枚举类型属性值选择 + - ✅ 支持文本/数字/日期类型自定义值 + +#### 待实现功能 ⏳ + +- [ ] 商品规格模板系统增强 + - ⏳ 规格模板独立管理(当前规格通过SKU的specs字段JSON存储) + - ⏳ 规格模板与属性模板区分管理 + - ⏳ 规格值标准化管理 + - ⏳ 规格模板可视化配置 + - **说明**: 当前系统中,SKU规格通过`mall_sku.specs`字段以JSON格式存储。属性模板系统(`mall_attribute_template`)可用于管理商品属性,但规格模板作为独立的规格管理系统,可用于标准化管理商品SKU的规格组合(如颜色+尺寸等),支持规格模板定义、规格值管理和规格模板与商品关联等功能。 +- [ ] 商品搜索功能 + - [ ] 全文搜索(集成Elasticsearch或类似方案) + - 商品名称、描述全文搜索 + - 搜索关键词高亮 + - 搜索排序优化(按相关度、销量、价格等) + - [ ] 商品筛选(按分类、品牌、价格等) + - 多条件组合筛选 + - 属性值筛选 + - 价格区间筛选 + - 库存状态筛选 + - 筛选结果统计 + +### 2. ruoyi-mall-trade (交易中心) + +#### 已实现功能 ✅ + +##### 购物车管理 + +- ✅ 购物车添加/删除 + - 添加商品到购物车 + - 删除购物车商品 + - 清空购物车 +- ✅ 购物车数量修改 + - 修改购物车商品数量 +- ✅ 购物车选中状态管理 + - 单个商品选中/取消选中 + - 批量选中/取消选中 +- ✅ 购物车详情查询 + - 购物车列表查询 + - 购物车详情查询(包含商品和SKU完整信息) + +##### 订单管理 + +- ✅ 订单创建 + - 从购物车创建订单 + - 直接购买创建订单 + - 订单明细自动生成 +- ✅ 订单查询 + - 订单列表查询(分页、状态筛选) + - 订单详情查询(包含订单明细) +- ✅ 订单取消 + - 用户主动取消订单 + - 订单取消后库存自动恢复 +- ✅ 订单状态管理 + - 订单状态流转(待付款、待发货、待收货、已完成、已取消) + - 支付状态管理 + - 发货状态管理 +- ✅ 订单支付集成 + - 与支付中心(ruoyi-pay)集成 + - 支付回调处理 + - 支付成功后订单状态自动更新 + - 库存自动扣减 + +#### 待实现功能 ⏳ + +- [x] 售后管理 ✅ + - [x] 订单退款申请 ✅ + - [x] 退货申请 ✅ + - [x] 换货申请 ✅ + - [x] 售后审核 ✅ + - [x] 售后状态跟踪 ✅ + - [x] 退货物流跟踪 ✅ + - [x] 商家确认收货 ✅ + - [x] 完成售后处理 ✅ + - [x] 取消售后申请 ✅ +- [x] 订单评价 ✅ + - [x] 订单评价提交 ✅ + - [x] 评价列表查询 ✅ + - [x] 评价回复(商家) ✅ + - [x] 评价审核 ✅ + - [ ] 评价图片上传(字段已支持,需前端上传功能) +- [x] 地址管理 ✅ + - [x] 收货地址管理(实体类、Mapper、Service、Controller已实现)✅ + - 收货地址CRUD操作 + - 用户地址列表查询 + - 默认地址查询 + - 地址导出功能(管理端) + - [x] 默认地址设置 ✅ + - 设置默认地址接口 + - 自动取消其他默认地址 +- [x] 订单优惠券使用 ✅ + - [x] 订单优惠券记录(实体类、Mapper、Service已实现)✅ + - [x] 优惠券金额计算(已实现CouponCalculator工具类和MallOrderCouponService.calculateAndUseCoupon方法)✅ + - 支持满减券、折扣券、现金券三种类型的金额计算 + - 已实现订单创建时的优惠券计算和使用流程 + - 已实现优惠金额统计方法(按订单ID、订单号、优惠券ID统计) + +### 3. ruoyi-mall-coupon (优惠券中心) + +#### 待实现功能 ⏳ + +- [x] 优惠券管理 ✅ + - [x] 优惠券CRUD操作 ✅ + - [x] 优惠券类型管理(满减、折扣、现金) ✅ + - [x] 优惠券发放规则配置 ✅ + - [x] 优惠券有效期管理 ✅ + - [x] 优惠券状态管理 ✅ +- [x] 用户优惠券 ✅ + - [x] 用户领券 ✅ + - [x] 用户优惠券列表查询 ✅ + - [x] 优惠券使用(订单结算时) ✅ + - [x] 优惠券退还(订单取消时) ✅ + - [x] 优惠券过期处理(定时任务)✅ + - [x] **实现状态**: `MallCouponJob.processExpiredCoupons()`定时任务已实现,每天凌晨2点执行 ✅ +- [x] 优惠券统计 ✅ + - [x] 发放统计(已实现统计接口)✅ + - [x] 使用统计(已实现统计接口)✅ + - [x] 使用率统计(已实现统计接口)✅ + - [ ] 核销金额统计(需集成订单优惠券使用记录表,当前返回0) + +### 4. ruoyi-mall-promotion (营销中心) + +#### 已实现功能 ✅ + +- [x] 营销活动管理 ✅ + - [x] 活动CRUD操作 ✅ + - [x] 活动类型管理 ✅ + - [x] 活动规则配置(JSON格式)✅ + - [x] 活动时间管理 ✅ + - [x] 活动状态管理 ✅ +- [x] 秒杀功能 ✅ + - [x] 秒杀活动创建 ✅ + - [x] 秒杀库存管理(扣减、恢复)✅ + - [x] 秒杀活动查询 ✅ + - [x] 秒杀活动状态管理 ✅ + - [ ] 秒杀订单处理(需在交易中心集成) + - [ ] 秒杀库存预扣(需在交易中心集成) +- [x] 拼团功能 ✅ + - [x] 拼团活动创建 ✅ + - [x] 拼团活动查询 ✅ + - [x] 拼团活动状态管理 ✅ + - [ ] 拼团订单创建(需在交易中心集成) + - [ ] 拼团加入(需在交易中心集成) + - [ ] 拼团成团处理(需在交易中心集成) + - [ ] 拼团过期处理(定时任务) +- [x] 砍价功能 ✅ + - [x] 砍价活动创建 ✅ + - [x] 砍价活动查询 ✅ + - [x] 砍价活动状态管理 ✅ + - [ ] 砍价发起(需在交易中心集成) + - [ ] 砍价助力(需在交易中心集成) + - [ ] 砍价进度管理(需在交易中心集成) + - [ ] 砍价订单处理(需在交易中心集成) + - [ ] 砍价过期处理(定时任务) + +#### 待实现功能 ⏳ + +- [ ] 订单集成(需在交易中心模块实现) + - [x] 秒杀订单处理(核心逻辑已实现,待订单服务集成)✅ + - [x] **秒杀活动验证**(活动时间、库存、状态校验)✅ + - 验证秒杀活动是否存在且状态为启用(status=0) + - 验证秒杀活动时间是否在有效期内(当前时间在start_time和end_time之间) + - 验证秒杀库存是否充足(seckill_stock >= 购买数量) + - 验证关联的营销活动(mall_promotion)是否启用 + - [x] **秒杀价格计算**(使用秒杀价而非原价)✅ + - 从`mall_seckill`表获取`seckill_price`(秒杀价,单位:分) + - 订单明细价格使用秒杀价,而非SKU原价 + - 订单总金额基于秒杀价计算 + - **实现状态**: `SeckillOrderService.getSeckillPrice()`方法已实现 + - [x] **秒杀订单标记**(订单表记录活动类型和活动ID)✅ + - 订单表(`mall_order`)需包含字段:`promotion_type`(活动类型,2=秒杀)、`promotion_id`(营销活动ID)、`seckill_id`(秒杀ID) + - SQL脚本已创建:`script/sql/mall_order_promotion_fields.sql` + - 订单创建时记录活动信息,便于后续查询和统计 + - 订单明细表(`mall_order_item`)也建议记录活动价格信息 + - **实现状态**: SQL脚本已准备,待订单实体类和服务实现后集成 + - [x] **秒杀限购校验**(同一用户同一商品限购数量)✅ + - 查询用户已购买的秒杀订单数量(同一秒杀活动、同一SKU) + - 校验是否超过限购数量(可从`mall_promotion.rules` JSON中读取限购规则,或默认每人限购1件) + - 仅统计已支付的订单,待支付/已取消订单不计入限购 + - **实现状态**: `SeckillOrderService.validatePurchaseLimit()`方法已实现核心逻辑,但由于订单实体类尚未实现,限购查询部分待订单服务实现后完善 + - [ ] **秒杀订单超时处理**(未支付订单自动取消)⏳ + - 订单创建时记录创建时间,设置订单超时时间(如15分钟) + - 定时任务扫描未支付的秒杀订单(订单状态=待支付,创建时间超过超时时间) + - 自动取消订单并恢复预扣的秒杀库存 + - 发送订单超时取消通知给用户 + - **实现状态**: 待订单服务实现后添加定时任务 + - [x] 秒杀库存预扣 ✅ + - [x] **下单时预扣秒杀库存**(避免超卖)✅ + - 订单创建时调用`SeckillOrderService.preDeductSeckillStock(seckillId, quantity)`预扣秒杀库存 + - 使用数据库乐观锁(UPDATE ... WHERE seckill_stock >= quantity)确保并发安全 + - 预扣成功后,订单状态为"待支付",库存已预扣但未最终确认 + - 如果预扣失败(库存不足),订单创建失败,返回库存不足提示 + - **实现状态**: `SeckillOrderService.preDeductSeckillStock()`方法已实现,调用`MallSeckillService.reduceStock()` + - [x] **支付成功时确认库存扣减**✅ + - 支付回调处理时,订单状态更新为"已支付" + - 预扣的秒杀库存此时正式扣减(预扣库存已在上一步完成,此处仅状态确认) + - 如果订单已标记为秒杀订单,确认秒杀库存扣减成功 + - **实现状态**: 预扣逻辑已实现,支付确认需在订单服务的支付回调中调用 + - [x] **订单取消时恢复秒杀库存**✅ + - 用户主动取消订单或系统超时取消时,调用`SeckillOrderService.restoreSeckillStock(seckillId, quantity)`恢复秒杀库存 + - 仅恢复"待支付"状态的订单预扣库存 + - "已支付"订单取消需走退款流程,不恢复秒杀库存(秒杀商品通常不支持退款) + - **实现状态**: `SeckillOrderService.restoreSeckillStock()`方法已实现,调用`MallSeckillService.restoreStock()` + - [ ] **库存预扣超时释放机制**⏳ + - 订单创建时记录预扣时间,设置预扣超时时间(如15分钟,与订单超时时间一致) + - 定时任务扫描预扣超时的订单(订单状态=待支付,创建时间超过超时时间) + - 自动释放预扣库存并取消订单 + - 确保库存预扣和订单超时处理的一致性 + - **实现状态**: 待订单服务实现后添加定时任务 + - [ ] 拼团订单创建 + - 拼团活动验证(活动时间、库存、状态校验) + - 创建拼团订单记录(`mall_group_buy_order`表) + - 生成拼团编号(`group_no`) + - 计算拼团过期时间(基于`duration_hours`) + - 拼团价格计算(使用拼团价) + - 拼团订单状态初始化(状态=0拼团中) + - [ ] 拼团加入 + - 拼团编号验证(验证拼团是否存在且可加入) + - 拼团状态校验(仅拼团中状态可加入) + - 拼团人数校验(未达到成团人数) + - 拼团过期校验(未超过过期时间) + - 创建拼团订单记录(关联同一`group_no`) + - 拼团人数自动累加 + - 自动检测成团条件(达到`group_size`人数) + - [ ] 拼团成团处理 + - 成团条件判断(达到成团人数) + - 更新拼团订单状态(状态=1已成团) + - 记录成团时间(`success_time`) + - 成团通知(通知所有参与用户) + - 成团后订单状态流转(待发货等) + - [ ] 砍价发起 + - 砍价活动验证(活动时间、库存、状态校验) + - 创建砍价记录(`mall_bargain_record`表) + - 生成砍价编号(`bargain_no`) + - 初始化砍价价格(`current_price`=原价,`bargain_price`=0) + - 计算砍价过期时间(基于`duration_hours`) + - 砍价状态初始化(状态=0进行中) + - 用户发起次数限制校验 + - [ ] 砍价助力 + - 砍价编号验证(验证砍价记录是否存在且有效) + - 砍价状态校验(仅进行中状态可助力) + - 砍价过期校验(未超过过期时间) + - 助力用户校验(不能为自己助力、不能重复助力) + - 计算助力金额(随机或固定算法,需达到最低价限制) + - 创建助力记录(`mall_bargain_help`表) + - 更新砍价进度(`current_price`、`bargain_price`、`help_count`) + - 检测砍价完成条件(达到最低价或完成助力次数) + - [ ] 砍价进度管理 + - 砍价进度查询(当前价格、已砍金额、助力人数、进度百分比) + - 砍价列表查询(用户发起的砍价记录) + - 砍价详情查询(包含助力记录列表) + - 砍价状态更新(进行中、已成功、已失败、已下单) + - 砍价完成检测(自动检测是否达到最低价) + - [ ] 砍价订单处理 + - 砍价完成验证(状态=已成功) + - 砍价价格使用(使用砍后价创建订单) + - 订单标记(记录砍价记录ID) + - 更新砍价状态(状态=3已下单) + - 砍价订单超时处理(未支付订单释放砍价资格) + - [x] 定时任务 ✅ + - [x] 秒杀活动状态更新任务 ✅ + - [x] 扫描即将开始的活动(`start_time` <= 当前时间 且 status=1禁用),自动启用(status=0)✅ + - [x] 扫描已结束的活动(`end_time` < 当前时间 且 status=0启用),自动禁用(status=1)✅ + - [x] 执行频率:每5分钟执行一次 ✅ + - [x] **实现状态**: `MallPromotionJob.updateSeckillStatus()`方法已实现 + - [ ] 拼团过期处理(定时任务) + - 扫描过期拼团(`expire_time` < 当前时间 且 状态=拼团中) + - 更新拼团状态(状态=2已失败) + - 取消关联订单(取消所有关联的未支付订单) + - 恢复库存(恢复拼团商品的库存) + - 发送失败通知(通知所有参与用户) + - 执行频率:建议每5-10分钟执行一次 + - **实现状态**: 待创建MallGroupBuyOrderMapper和相关Service方法后实现 + - [ ] 砍价过期处理(定时任务) + - 扫描过期砍价(`expire_time` < 当前时间 且 状态=进行中) + - 更新砍价状态(状态=2已失败) + - 清理未使用的砍价记录 + - 发送失败通知(通知发起用户) + - 执行频率:建议每10-30分钟执行一次 + - **实现状态**: 待创建MallBargainRecordMapper和相关Service方法后实现 + +### 5. ruoyi-mall-statistics (统计中心) + +#### 已实现功能 ✅ + +- [x] 交易分析 ✅ + - [x] 订单统计(总订单数、总销售额) ✅ + - [x] 已支付订单统计(已支付订单数、已支付金额) ✅ + - [x] 退款金额统计 ✅ + - [x] 今日统计(今日订单数、今日销售额) ✅ + - [x] 商品销售排行(按销售金额排序) ✅ + - [x] 交易趋势分析(按日期分组统计) ✅ + +#### 待实现功能 ⏳ + +- [ ] 会员分析(需会员模块支持,当前系统可通过订单表`mall_order.user_id`实现基础统计) + - [ ] 会员增长统计 + - 总会员数统计(基于用户表或订单表去重用户数) + - 每日新增会员数统计(按注册时间或首次下单时间) + - 会员增长趋势分析(按日期统计新增会员数) + - 会员增长环比/同比增长分析 + - [ ] 会员消费统计 + - 会员总消费金额统计(按用户ID聚合订单金额) + - 会员平均消费金额(总消费/会员数) + - 会员消费排行(按消费金额排序) + - 会员消费分布分析(消费区间分布) + - 高价值会员识别(消费金额Top N会员) + - [ ] 会员活跃度分析 + - 活跃会员数统计(有订单的会员数) + - 会员活跃度等级划分(根据下单频率、消费金额等) + - 会员留存率分析(新会员留存、老会员复购) + - 会员流失分析(长期未下单会员识别) +- [ ] 商品分析(基于现有`mall_product`、`mall_order_item`表实现) + - [ ] 商品销售统计(部分功能可通过交易统计的商品排行实现) + - 商品销售金额统计(按商品ID聚合订单明细) + - 商品销售数量统计(按商品ID聚合订单数量) + - 商品销售趋势分析(按日期统计商品销售) + - 商品销售排行(按销售金额、销售数量排序) + - 商品分类销售统计(按商品分类聚合统计) + - 商品品牌销售统计(按品牌聚合统计) + - [ ] 商品浏览统计(商品表已有`view_count`字段,需商品模块支持浏览统计接口) + - 商品浏览量统计(基于`mall_product.view_count`字段) + - 商品浏览量排行(按浏览量排序) + - 商品浏览趋势分析(需记录浏览日志表或通过商品表`view_count`字段) + - 热门商品识别(浏览量Top N商品) + - 商品浏览与销售关联分析 + - [ ] 商品转化率分析 + - 商品转化率计算(订单数/浏览量,或销量/浏览量) + - 商品转化率排行(按转化率排序) + - 高转化率商品识别 + - 低转化率商品分析(识别需优化商品) + - 商品浏览到下单转化漏斗分析 +- [ ] 营销分析(基于`mall_promotion`、`mall_coupon`、`mall_order`表实现) + - [ ] 活动效果统计(需营销模块支持,基于`mall_promotion`和订单表关联) + - 活动参与人数统计(参与活动的订单用户数) + - 活动订单数统计(按活动ID聚合订单) + - 活动销售额统计(活动订单的销售金额) + - 活动转化率分析(活动订单数/活动参与人数) + - 活动类型效果对比(拼团、秒杀、砍价等活动效果对比) + - 活动ROI分析(活动投入成本 vs 活动带来的销售额) + - 活动效果趋势分析(按日期统计活动效果) + - [ ] 优惠券使用统计(基于`mall_coupon`、`mall_coupon_user`、`mall_order_coupon`表) + - 优惠券发放统计(优惠券发放数量,基于`mall_coupon_user`表) + - 优惠券使用统计(优惠券使用数量,`mall_coupon_user.status=1`) + - 优惠券使用率统计(使用数/发放数) + - 优惠券核销金额统计(基于`mall_order_coupon.discount_amount`) + - 优惠券类型使用对比(满减、折扣、现金券使用情况) + - 优惠券使用趋势分析(按日期统计优惠券使用) + - 优惠券效果分析(优惠券带来的订单和销售额) + - [ ] 营销ROI分析 + - 营销活动总投入统计(活动成本计算) + - 营销活动总产出统计(活动带来的销售额) + - 营销ROI计算(产出/投入) + - 各营销渠道ROI对比(活动、优惠券等) + - 营销效果评估(投入产出比分析) +- [ ] 数据报表 + - [ ] 数据报表导出 + - 交易统计报表导出(Excel格式) + - 商品销售报表导出 + - 会员统计报表导出 + - 营销活动报表导出 + - 自定义报表配置和导出 + - [ ] 数据可视化接口 + - 图表数据接口(支持折线图、柱状图、饼图等) + - 实时数据统计接口 + - 数据大屏数据接口 + - 数据导出格式支持(Excel、PDF、CSV等) + +## API接口详细说明 + +### 1. ruoyi-mall-product (商品中心) + +#### 管理端接口 + +##### 商品管理 (`/mall/product`) + +- `GET /mall/product/list` - 查询商品列表(分页) +- `GET /mall/product/{id}` - 获取商品详情 +- `POST /mall/product` - 新增商品 +- `PUT /mall/product` - 修改商品 +- `DELETE /mall/product/{ids}` - 删除商品 + +##### 商品分类管理 (`/mall/product/category`) + +- `GET /mall/product/category/list` - 查询商品分类列表(分页) +- `GET /mall/product/category/tree` - 查询商品分类树形列表 +- `GET /mall/product/category/{id}` - 获取商品分类详情 +- `POST /mall/product/category` - 新增商品分类 +- `PUT /mall/product/category` - 修改商品分类 +- `DELETE /mall/product/category/{ids}` - 删除商品分类 +- `POST /mall/product/category/export` - 导出商品分类列表 + +##### 品牌管理 (`/mall/product/brand`) + +- `GET /mall/product/brand/list` - 查询品牌列表(分页) +- `GET /mall/product/brand/{id}` - 获取品牌详情 +- `POST /mall/product/brand` - 新增品牌 +- `PUT /mall/product/brand` - 修改品牌 +- `DELETE /mall/product/brand/{ids}` - 删除品牌 +- `POST /mall/product/brand/export` - 导出品牌列表 + +##### 属性模板管理 (`/mall/product/attribute/template`) + +- `GET /mall/product/attribute/template/list` - 查询属性模板列表(分页) +- `GET /mall/product/attribute/template/{id}` - 获取属性模板详情 +- `POST /mall/product/attribute/template` - 新增属性模板 +- `PUT /mall/product/attribute/template` - 修改属性模板 +- `DELETE /mall/product/attribute/template/{ids}` - 删除属性模板 +- `POST /mall/product/attribute/template/export` - 导出属性模板列表 + +##### 属性管理 (`/mall/product/attribute`) + +- `GET /mall/product/attribute/list` - 查询属性列表(分页) +- `GET /mall/product/attribute/{id}` - 获取属性详情 +- `GET /mall/product/attribute/template/{templateId}` - 根据模板ID查询属性列表 +- `POST /mall/product/attribute` - 新增属性 +- `PUT /mall/product/attribute` - 修改属性 +- `DELETE /mall/product/attribute/{ids}` - 删除属性 +- `POST /mall/product/attribute/export` - 导出属性列表 + +##### 属性值管理 (`/mall/product/attribute/value`) + +- `GET /mall/product/attribute/value/list` - 查询属性值列表(分页) +- `GET /mall/product/attribute/value/{id}` - 获取属性值详情 +- `GET /mall/product/attribute/value/attribute/{attributeId}` - 根据属性ID查询属性值列表 +- `POST /mall/product/attribute/value` - 新增属性值 +- `PUT /mall/product/attribute/value` - 修改属性值 +- `DELETE /mall/product/attribute/value/{ids}` - 删除属性值 +- `POST /mall/product/attribute/value/export` - 导出属性值列表 + +##### 商品属性关联管理 (`/mall/product/attribute/product`) + +- `GET /mall/product/attribute/product/{id}` - 获取商品属性关联详情 +- `GET /mall/product/attribute/product/product/{productId}` - 根据商品ID查询商品属性关联列表 +- `POST /mall/product/attribute/product` - 新增商品属性关联 +- `POST /mall/product/attribute/product/batch` - 批量新增商品属性关联 +- `POST /mall/product/attribute/product/product/{productId}` - 保存商品属性关联(先删除旧的,再插入新的) +- `PUT /mall/product/attribute/product` - 修改商品属性关联 +- `DELETE /mall/product/attribute/product/{ids}` - 删除商品属性关联 +- `DELETE /mall/product/attribute/product/product/{productId}` - 根据商品ID删除商品属性关联 + +#### 移动端接口 + +##### 商品分类 (`/app/mall/product/category`) + +- `GET /app/mall/product/category/tree` - 查询商品分类树形列表(仅启用分类) + +##### 品牌 (`/app/mall/product/brand`) + +- `GET /app/mall/product/brand/list` - 查询品牌列表(仅启用品牌) + +##### 商品 (`/app/mall/product`) + +- `GET /app/mall/product/list` - 查询商品列表(仅上架商品) +- `GET /app/mall/product/{id}` - 获取商品详情(仅上架商品) + +### 2. ruoyi-mall-trade (交易中心) + +#### 管理端接口 + +##### 订单管理 (`/mall/order`) + +- `GET /mall/order/list` - 查询订单列表(分页、条件筛选) +- `GET /mall/order/{id}` - 获取订单详情 +- `PUT /mall/order` - 修改订单 +- `DELETE /mall/order/{ids}` - 删除订单 +- `PUT /mall/order/delivery/{id}` - 订单发货 + +##### 售后申请管理 (`/mall/order/after-sale`) + +- `GET /mall/order/after-sale/list` - 查询售后申请列表(分页) +- `GET /mall/order/after-sale/{id}` - 获取售后申请详情 +- `DELETE /mall/order/after-sale/{ids}` - 删除售后申请 +- `POST /mall/order/after-sale/export` - 导出售后申请列表 +- `PUT /mall/order/after-sale/audit/{id}` - 审核售后申请(同意/拒绝) +- `PUT /mall/order/after-sale/receive/{id}` - 确认收货(商家确认收到退货) +- `PUT /mall/order/after-sale/complete/{id}` - 完成售后 + +##### 订单评价管理 (`/mall/order/evaluate`) + +- `GET /mall/order/evaluate/list` - 查询订单评价列表(分页) +- `GET /mall/order/evaluate/{id}` - 获取订单评价详情 +- `DELETE /mall/order/evaluate/{ids}` - 删除订单评价 +- `POST /mall/order/evaluate/export` - 导出订单评价列表 +- `PUT /mall/order/evaluate/audit/{id}` - 审核评价(通过/驳回) +- `PUT /mall/order/evaluate/reply/{id}` - 回复评价 + +#### 移动端接口 + +##### 购物车 (`/app/mall/cart`) + +- `GET /app/mall/cart/list` - 查询购物车列表 +- `GET /app/mall/cart/detail/list` - 查询购物车详情列表(包含商品信息) +- `POST /app/mall/cart` - 添加购物车 +- `PUT /app/mall/cart` - 修改购物车 +- `DELETE /app/mall/cart/{ids}` - 删除购物车 +- `DELETE /app/mall/cart/clear` - 清空购物车 +- `PUT /app/mall/cart/selected/{id}` - 更新单个商品选中状态 +- `PUT /app/mall/cart/selected/batch` - 批量更新选中状态 + +##### 订单 (`/app/mall/order`) + +- `POST /app/mall/order/submit` - 提交订单并发起支付 +- `GET /app/mall/order/list` - 查询订单列表 +- `GET /app/mall/order/{id}` - 查询订单详情 +- `PUT /app/mall/order/cancel/{id}` - 取消订单 +- `POST /app/mall/order/pay/notify` - 支付回调(由支付中心调用) + +##### 售后申请 (`/app/mall/order/after-sale`) + +- `GET /app/mall/order/after-sale/list` - 查询售后申请列表 +- `GET /app/mall/order/after-sale/order/{orderId}` - 根据订单ID查询售后申请列表 +- `GET /app/mall/order/after-sale/{id}` - 获取售后申请详情 +- `POST /app/mall/order/after-sale` - 新增售后申请(退款/退货/换货) +- `PUT /app/mall/order/after-sale/logistics/{id}` - 填写退货物流信息 +- `PUT /app/mall/order/after-sale/cancel/{id}` - 取消售后申请 + +##### 订单评价 (`/app/mall/order/evaluate`) + +- `GET /app/mall/order/evaluate/list` - 查询订单评价列表 +- `GET /app/mall/order/evaluate/order/{orderId}` - 根据订单ID查询评价列表 +- `GET /app/mall/order/evaluate/product/{productId}` - 根据商品ID查询评价列表(仅已审核) +- `GET /app/mall/order/evaluate/{id}` - 获取订单评价详情 +- `POST /app/mall/order/evaluate` - 提交订单评价 + +##### 优惠券 (`/app/mall/coupon`) + +- `GET /app/mall/coupon/available/list` - 查询可领取的优惠券列表 +- `GET /app/mall/coupon/{id}` - 获取优惠券详情 + +##### 用户优惠券 (`/app/mall/coupon/user`) + +- `GET /app/mall/coupon/user/list` - 查询用户优惠券列表 +- `GET /app/mall/coupon/user/available/list` - 查询用户可用优惠券列表(未使用且未过期) +- `GET /app/mall/coupon/user/{id}` - 获取用户优惠券详情 +- `POST /app/mall/coupon/user/receive/{couponId}` - 领取优惠券 + +### 3. ruoyi-mall-coupon (优惠券中心) + +#### 管理端接口 + +##### 优惠券管理 (`/mall/coupon`) + +- `GET /mall/coupon/list` - 查询优惠券列表(分页) +- `GET /mall/coupon/{id}` - 获取优惠券详情 +- `POST /mall/coupon` - 新增优惠券 +- `PUT /mall/coupon` - 修改优惠券 +- `DELETE /mall/coupon/{ids}` - 删除优惠券 +- `POST /mall/coupon/export` - 导出优惠券列表 +- `PUT /mall/coupon/changeStatus/{id}` - 启用/禁用优惠券 +- `GET /mall/coupon/statistics` - 获取优惠券统计信息(发放数量、使用数量、使用率) + +##### 用户优惠券管理 (`/mall/coupon/user`) + +- `GET /mall/coupon/user/list` - 查询用户优惠券列表(分页) +- `GET /mall/coupon/user/{id}` - 获取用户优惠券详情 +- `DELETE /mall/coupon/user/{ids}` - 删除用户优惠券 +- `POST /mall/coupon/user/export` - 导出用户优惠券列表 + +### 4. ruoyi-mall-promotion (营销中心) + +#### 管理端接口 + +##### 营销活动管理 (`/mall/promotion`) + +- `GET /mall/promotion/list` - 查询营销活动列表(分页) +- `GET /mall/promotion/{id}` - 获取营销活动详情 +- `POST /mall/promotion` - 新增营销活动 +- `PUT /mall/promotion` - 修改营销活动 +- `DELETE /mall/promotion/{ids}` - 删除营销活动 +- `POST /mall/promotion/export` - 导出营销活动列表 +- `PUT /mall/promotion/changeStatus/{id}` - 启用/禁用营销活动 + +##### 秒杀活动管理 (`/mall/seckill`) + +- `GET /mall/seckill/list` - 查询秒杀活动列表(分页) +- `GET /mall/seckill/{id}` - 获取秒杀活动详情 +- `POST /mall/seckill` - 新增秒杀活动 +- `PUT /mall/seckill` - 修改秒杀活动 +- `DELETE /mall/seckill/{ids}` - 删除秒杀活动 +- `POST /mall/seckill/export` - 导出秒杀活动列表 +- `PUT /mall/seckill/changeStatus/{id}` - 启用/禁用秒杀活动 + +##### 拼团活动管理 (`/mall/groupbuy`) + +- `GET /mall/groupbuy/list` - 查询拼团活动列表(分页) +- `GET /mall/groupbuy/{id}` - 获取拼团活动详情 +- `POST /mall/groupbuy` - 新增拼团活动 +- `PUT /mall/groupbuy` - 修改拼团活动 +- `DELETE /mall/groupbuy/{ids}` - 删除拼团活动 +- `POST /mall/groupbuy/export` - 导出拼团活动列表 +- `PUT /mall/groupbuy/changeStatus/{id}` - 启用/禁用拼团活动 + +##### 砍价活动管理 (`/mall/bargain`) + +- `GET /mall/bargain/list` - 查询砍价活动列表(分页) +- `GET /mall/bargain/{id}` - 获取砍价活动详情 +- `POST /mall/bargain` - 新增砍价活动 +- `PUT /mall/bargain` - 修改砍价活动 +- `DELETE /mall/bargain/{ids}` - 删除砍价活动 +- `POST /mall/bargain/export` - 导出砍价活动列表 +- `PUT /mall/bargain/changeStatus/{id}` - 启用/禁用砍价活动 + +#### 移动端接口 + +##### 营销活动 (`/app/mall/promotion`) + +- `GET /app/mall/promotion/list` - 查询营销活动列表(可按类型筛选) +- `GET /app/mall/promotion/{id}` - 获取营销活动详情 + +##### 秒杀活动 (`/app/mall/seckill`) + +- `GET /app/mall/seckill/list` - 查询可用秒杀活动列表 +- `GET /app/mall/seckill/{id}` - 获取秒杀活动详情 +- `GET /app/mall/seckill/sku/{skuId}` - 根据SKU ID查询秒杀活动 + +##### 拼团活动 (`/app/mall/groupbuy`) + +- `GET /app/mall/groupbuy/list` - 查询可用拼团活动列表 +- `GET /app/mall/groupbuy/{id}` - 获取拼团活动详情 +- `GET /app/mall/groupbuy/sku/{skuId}` - 根据SKU ID查询拼团活动 + +##### 砍价活动 (`/app/mall/bargain`) + +- `GET /app/mall/bargain/list` - 查询可用砍价活动列表 +- `GET /app/mall/bargain/{id}` - 获取砍价活动详情 +- `GET /app/mall/bargain/sku/{skuId}` - 根据SKU ID查询砍价活动 + +### 5. ruoyi-mall-statistics (统计中心) + +#### 管理端接口 + +##### 统计数据 (`/mall/statistics`) + +- `GET /mall/statistics/transaction` - 获取交易统计数据 + - 支持按日期范围查询(startDate, endDate) + - 支持设置商品销售排行数量限制(limit,默认10) + - 返回数据包括:总订单数、总销售额、已支付订单数、已支付金额、退款金额、今日统计、趋势数据、商品销售排行 +- `GET /mall/statistics/member` - 获取会员统计数据 + - 支持按日期范围查询(startDate, endDate) + - 返回数据包括:总会员数、今日新增会员数、活跃会员数、增长趋势、消费统计 + - ⚠️ 注意:会员统计功能需要会员模块支持,当前返回基础结构 + +## 定时任务规划 + +### 优惠券模块 + +- [x] 优惠券过期处理任务 ✅ + - **功能**: 定时扫描过期的用户优惠券,更新状态为已过期 + - **执行频率**: 每天凌晨2点执行一次 + - **实现位置**: `ruoyi-mall-coupon` 模块 + - **方法**: `MallCouponJob.processExpiredCoupons()` (调用 `MallCouponUserService.processExpiredCoupons()`) + +### 营销模块 + +- [ ] 拼团过期处理任务 + - **功能**: 定时扫描过期的拼团订单,更新状态为已失败,处理退款 + - **执行频率**: 每5分钟执行一次 + - **实现位置**: `ruoyi-mall-promotion` 模块 +- [ ] 砍价过期处理任务 + - **功能**: 定时扫描过期的砍价记录,更新状态为已失败 + - **执行频率**: 每5分钟执行一次 + - **实现位置**: `ruoyi-mall-promotion` 模块 +- [x] 秒杀活动状态更新任务 ✅ + - **功能**: 定时更新秒杀活动状态(开始/结束) + - **执行频率**: 每5分钟执行一次 + - **实现位置**: `ruoyi-mall-promotion` 模块 + - **方法**: `MallPromotionJob.updateSeckillStatus()` + +### 订单模块(可选) + +- [ ] 订单自动取消任务 + - **功能**: 定时扫描超时未支付的订单,自动取消并恢复库存 + - **执行频率**: 每10分钟执行一次 + - **实现位置**: `ruoyi-mall-trade` 模块 + +## 技术栈与开发规范 + +### 技术栈 + +- **框架**: Spring Boot 3.x +- **ORM**: MyBatis-Plus +- **数据库**: MySQL 8.0+ +- **认证**: Sa-Token +- **API文档**: Swagger/OpenAPI 3.0 +- **构建工具**: Maven + +### 开发规范 + +#### 包结构规范 + +- `controller` - 控制器层(分为 `admin` 和 `app` 两个子包) +- `domain` - 实体类(包含 `bo` 业务对象和 `vo` 视图对象子包) +- `mapper` - MyBatis Mapper 接口 +- `service` - 服务接口(实现类放在 `impl` 子包) +- `config` - 配置类(自动配置类) +- `enums` - 枚举类 +- `job` - 定时任务(可选) + +#### 代码规范 + +- 统一使用 Lombok 简化代码 +- 统一异常处理,使用 `ServiceException` 抛出业务异常 +- 统一返回格式,使用 `R` 封装返回结果 +- 统一分页查询,使用 `PageQuery` 和 `TableDataInfo` +- 统一日志记录,使用 `@Log` 注解记录操作日志 +- 统一权限控制,管理端接口使用 `@SaCheckPermission` 注解 + +#### 数据库设计规范 + +- 表名使用 `mall_` 前缀 +- 字段命名使用下划线命名法(snake_case) +- 必须包含 `create_by`, `create_time`, `update_by`, `update_time` 字段 +- 删除标记使用逻辑删除(`deleted` 字段) +- 状态字段统一使用 `status`,0表示启用/正常,1表示禁用/异常 + +#### API设计规范 + +- RESTful 风格设计 +- 管理端接口路径:`/mall/{module}/{resource}` +- 移动端接口路径:`/app/mall/{module}/{resource}` +- 使用 HTTP 标准方法:GET(查询)、POST(新增)、PUT(修改)、DELETE(删除) +- 统一使用 JSON 格式进行数据交互 +- 使用 Swagger 注解完善接口文档 + +## 模块集成说明 + +### 支付中心集成 (ruoyi-pay) + +- **集成方式**: 通过 Feign 客户端或本地 Jar 依赖 +- **核心功能**: + - 订单提交时调用支付中心创建支付单 + - 支付中心回调通知支付结果 + - 订单状态与支付状态同步 +- **配置要求**: 在 `application.yml` 中配置 `pay-app-id` 和 `pay-notify-url` + +### 商品中心依赖关系 + +- `ruoyi-mall-trade` 依赖 `ruoyi-mall-product`(查询商品和SKU信息) +- `ruoyi-mall-promotion` 依赖 `ruoyi-mall-product`(营销活动关联商品) +- `ruoyi-mall-statistics` 依赖 `ruoyi-mall-product` 和 `ruoyi-mall-trade`(数据统计分析) + +## 开发指南 + +### 新增功能开发步骤 + +1. **数据库设计** + - 在 `script/sql/ry_mall.sql` 中设计表结构 + - 确保字段命名规范,包含必要的索引 + +2. **创建实体类** + - 在对应模块的 `domain` 包下创建实体类 + - 创建对应的 `Bo`(业务对象)和 `Vo`(视图对象) + +3. **创建 Mapper** + - 在 `mapper` 包下创建 Mapper 接口 + - 如需复杂查询,在 `resources/mapper` 下创建 XML 文件 + +4. **创建 Service** + - 在 `service` 包下创建 Service 接口 + - 在 `service/impl` 包下创建 Service 实现类 + +5. **创建 Controller** + - 管理端接口:在 `controller/admin` 包下创建 + - 移动端接口:在 `controller/app` 包下创建 + - 添加 Swagger 注解完善接口文档 + +6. **测试验证** + - 编写单元测试(可选) + - 使用 Swagger UI 测试接口 + - 验证业务逻辑正确性 + +### 代码迁移指南 + +如果要将现有代码迁移到新模块结构: + +1. **创建新模块结构**(如果尚未创建) +2. **复制代码到新模块** +3. **修改包名**(如 `org.dromara.mall.domain` → `org.dromara.mall.product.domain`) +4. **更新导入语句** +5. **更新 Mapper XML 文件中的命名空间** +6. **测试验证功能正常** + +## 注意事项 + +1. **包名冲突**: 如果同时保留现有代码和新模块代码,需要注意包名冲突问题 +2. **依赖管理**: 确保模块间的依赖关系正确配置,避免循环依赖 +3. **数据库迁移**: 执行 SQL 文件创建新表结构,注意数据迁移(如果需要) +4. **配置管理**: 各子模块的配置通过 `spring.factories` 自动加载 +5. **事务管理**: 订单创建、支付回调等核心流程需要确保事务一致性 +6. **库存管理**: SKU库存扣减需要考虑并发问题,建议使用分布式锁或数据库锁(悲观锁/乐观锁) +7. **支付集成**: 订单支付需要与 `ruoyi-pay` 模块集成,确保支付流程正确 +8. **数据统计**: 统计模块建议使用异步处理,避免影响业务性能 +9. **文件上传**: 商品图片、品牌Logo、分类图标等文件上传功能需要集成文件服务(如OSS) +10. **缓存策略**: 商品列表、分类树等热点数据建议使用Redis缓存 +11. **幂等性**: 支付回调、订单创建等关键操作需要保证幂等性 +12. **数据一致性**: 订单创建、库存扣减等操作需要在同一事务中完成 diff --git a/ruoyi-modules/ruoyi-mall/README.md b/ruoyi-modules/ruoyi-mall/README.md new file mode 100644 index 0000000..e8c703e --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/README.md @@ -0,0 +1,264 @@ +# 电商模块 (ruoyi-mall) + +## 模块简介 + +`ruoyi-mall` 是 PoJie 项目的电商核心业务模块,提供完整的电商功能,包括商品管理、购物车、订单管理、支付集成等。 + +## 模块结构 + +根据模块结构规划,`ruoyi-mall` 采用子模块拆分的方式,包含以下子模块: + +- `ruoyi-mall-product` - 商品中心 (SPU, SKU, 分类, 品牌, 属性) +- `ruoyi-mall-trade` - 交易中心 (购物车, 订单, 售后) +- `ruoyi-mall-promotion` - 营销中心 (活动, 拼团, 秒杀, 砍价) +- `ruoyi-mall-coupon` - 优惠券中心 (独立拆分,便于复用) +- `ruoyi-mall-statistics` - 统计中心 (交易分析, 会员分析) + +各子模块详细说明请参考对应的 README.md 文件。 + +## 功能特性 + +### 1. 商品管理 + +- ✅ 商品CRUD操作 +- ✅ 商品上架/下架 +- ✅ 商品浏览量和销量统计 +- ✅ 商品详情管理 + +### 2. SKU管理 + +- ✅ SKU CRUD操作 +- ✅ 库存管理 +- ✅ 库存扣减和恢复 +- ✅ 规格属性管理 + +### 3. 购物车管理 + +- ✅ 添加/删除购物车 +- ✅ 购物车数量修改 +- ✅ 购物车选中状态管理 +- ✅ 购物车详情查询(包含商品和SKU信息) + +### 4. 订单管理 + +- ✅ 订单创建 +- ✅ 订单查询(列表、详情) +- ✅ 订单取消 +- ✅ 订单状态管理 +- ✅ 订单支付集成 + +### 5. 支付集成 + +- ✅ 与支付中心(ruoyi-pay)集成 +- ✅ 支付回调处理 +- ✅ 支付状态同步 + +## 数据库表 + +### 商品中心 (ruoyi-mall-product) + +- `mall_product` - 商品表 +- `mall_sku` - SKU表 +- `mall_category` - 商品分类表 +- `mall_brand` - 品牌表 + +### 交易中心 (ruoyi-mall-trade) + +- `mall_cart` - 购物车表 +- `mall_order` - 订单表 +- `mall_order_item` - 订单明细表 +- `mall_after_sale` - 售后申请表 +- `mall_order_evaluate` - 订单评价表 +- `mall_order_coupon` - 订单优惠券使用记录表 + +详细表结构请参考:`script/sql/ry_mall.sql` + +## 配置说明 + +在 `application.yml` 中添加以下配置: + +```yaml +ruoyi: + mall: + pay-app-id: 1 # 支付应用ID + pay-notify-url: https://your-domain.com/app/mall/pay/notify # 支付回调地址 +``` + +## API接口 + +### 移动端接口 + +#### 商品接口 + +- `GET /app/mall/product/list` - 查询商品列表 +- `GET /app/mall/product/{id}` - 查询商品详情 +- `GET /app/mall/product/{productId}/skus` - 查询商品SKU列表 + +#### 商品分类接口 + +- `GET /app/mall/product/category/tree` - 查询商品分类树形列表(仅启用分类) + +#### 品牌接口 + +- `GET /app/mall/product/brand/list` - 查询品牌列表(仅启用品牌) + +#### 购物车接口 + +- `GET /app/mall/cart/list` - 查询购物车列表 +- `GET /app/mall/cart/detail/list` - 查询购物车详情列表(包含商品信息) +- `POST /app/mall/cart` - 添加购物车 +- `PUT /app/mall/cart` - 修改购物车 +- `DELETE /app/mall/cart/{ids}` - 删除购物车 +- `DELETE /app/mall/cart/clear` - 清空购物车 +- `PUT /app/mall/cart/selected/{id}` - 更新选中状态 +- `PUT /app/mall/cart/selected/batch` - 批量更新选中状态 + +#### 订单接口 + +- `POST /app/mall/order/submit` - 提交订单并发起支付 +- `GET /app/mall/order/list` - 查询订单列表 +- `GET /app/mall/order/{id}` - 查询订单详情 +- `PUT /app/mall/order/cancel/{id}` - 取消订单 +- `POST /app/mall/order/pay/notify` - 支付回调(由支付中心调用) + +#### 优惠券接口 + +- `GET /app/mall/coupon/available/list` - 查询可领取的优惠券列表 +- `GET /app/mall/coupon/{id}` - 获取优惠券详情 +- `GET /app/mall/coupon/user/list` - 查询用户优惠券列表 +- `GET /app/mall/coupon/user/available/list` - 查询用户可用优惠券列表 +- `GET /app/mall/coupon/user/{id}` - 获取用户优惠券详情 +- `POST /app/mall/coupon/user/receive/{couponId}` - 领取优惠券 + +### 管理端接口 + +#### 商品管理 + +- `GET /mall/product/list` - 查询商品列表 +- `GET /mall/product/{id}` - 查询商品详情 +- `POST /mall/product` - 新增商品 +- `PUT /mall/product` - 修改商品 +- `DELETE /mall/product/{ids}` - 删除商品 +- `PUT /mall/product/status/{id}` - 上架/下架商品 + +#### 商品分类管理 + +- `GET /mall/product/category/list` - 查询商品分类列表(分页) +- `GET /mall/product/category/tree` - 查询商品分类树形列表 +- `GET /mall/product/category/{id}` - 获取商品分类详情 +- `POST /mall/product/category` - 新增商品分类 +- `PUT /mall/product/category` - 修改商品分类 +- `DELETE /mall/product/category/{ids}` - 删除商品分类 + +#### 优惠券管理 + +- `GET /mall/coupon/list` - 查询优惠券列表(分页) +- `GET /mall/coupon/{id}` - 获取优惠券详情 +- `POST /mall/coupon` - 新增优惠券 +- `PUT /mall/coupon` - 修改优惠券 +- `DELETE /mall/coupon/{ids}` - 删除优惠券 +- `POST /mall/coupon/export` - 导出优惠券列表 +- `PUT /mall/coupon/changeStatus/{id}` - 启用/禁用优惠券 + +#### 用户优惠券管理 + +- `GET /mall/coupon/user/list` - 查询用户优惠券列表(分页) +- `GET /mall/coupon/user/{id}` - 获取用户优惠券详情 +- `DELETE /mall/coupon/user/{ids}` - 删除用户优惠券 +- `POST /mall/coupon/user/export` - 导出用户优惠券列表 +- `POST /mall/product/category/export` - 导出商品分类列表 + +#### 品牌管理 + +- `GET /mall/product/brand/list` - 查询品牌列表(分页) +- `GET /mall/product/brand/{id}` - 获取品牌详情 +- `POST /mall/product/brand` - 新增品牌 +- `PUT /mall/product/brand` - 修改品牌 +- `DELETE /mall/product/brand/{ids}` - 删除品牌 +- `POST /mall/product/brand/export` - 导出品牌列表 + +#### SKU管理 + +- `GET /mall/sku/list` - 查询SKU列表 +- `GET /mall/sku/{id}` - 查询SKU详情 +- `GET /mall/sku/product/{productId}` - 根据商品ID查询SKU列表 +- `POST /mall/sku` - 新增SKU +- `PUT /mall/sku` - 修改SKU +- `DELETE /mall/sku/{ids}` - 删除SKU + +#### 订单管理 + +- `GET /mall/order/list` - 查询订单列表 +- `GET /mall/order/{id}` - 查询订单详情 +- `PUT /mall/order` - 修改订单 +- `DELETE /mall/order/{ids}` - 删除订单 +- `PUT /mall/order/delivery/{id}` - 订单发货 + +## 业务流程 + +### 订单提交流程 + +1. 用户选择商品(从购物车或直接购买) +2. 调用 `/app/mall/order/submit` 提交订单 +3. 系统创建订单和订单明细 +4. 调用支付中心创建支付单 +5. 返回支付参数给前端 +6. 前端调起支付 +7. 支付成功后,支付中心回调 `/app/mall/order/pay/notify` +8. 系统更新订单状态并扣减库存 + +### 购物车流程 + +1. 用户浏览商品,选择SKU +2. 调用 `/app/mall/cart` 添加到购物车 +3. 如果购物车中已存在该SKU,则更新数量 +4. 用户可以在购物车中修改数量、选中状态 +5. 提交订单时,从购物车获取商品信息 +6. 订单创建成功后,删除购物车中的商品 + +## 依赖模块 + +- `ruoyi-pay` - 支付中心模块 +- `ruoyi-common-*` - 通用工具模块 + +## 注意事项 + +1. **库存管理**:订单支付成功后才扣减库存,避免超卖 +2. **幂等性**:支付回调需要保证幂等处理 +3. **地址管理**:当前版本地址信息简化处理,后续可扩展地址表 +4. **订单号生成**:使用时间戳+随机字符串生成唯一订单号 + +## 待完善功能 + +### 商品中心 + +- [ ] 商品属性管理 +- [ ] 商品规格模板 +- [ ] 商品搜索功能 + +### 交易中心 + +- ✅ 订单评价功能 +- ✅ 售后管理功能(退款、退货、换货) +- [ ] 地址管理模块(可考虑集成到会员模块) +- [ ] 订单优惠券使用记录 + +### 营销中心 + +- [ ] 营销活动管理 +- [ ] 拼团功能 +- [ ] 秒杀功能 +- [ ] 砍价功能 + +### 优惠券中心 + +- [ ] 优惠券管理 +- [ ] 用户优惠券功能 +- [ ] 优惠券统计 + +### 统计中心 + +- [ ] 交易分析 +- [ ] 会员分析 +- [ ] 商品分析 +- [ ] 营销分析 diff --git a/ruoyi-modules/ruoyi-mall/pom.xml b/ruoyi-modules/ruoyi-mall/pom.xml new file mode 100644 index 0000000..a2f7c4c --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/pom.xml @@ -0,0 +1,27 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + + ruoyi-mall + pom + + + ruoyi-mall 商城系统聚合模块 + + + + ruoyi-mall-product + ruoyi-mall-trade + ruoyi-mall-promotion + ruoyi-mall-coupon + ruoyi-mall-statistics + + + diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/README.md b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/README.md new file mode 100644 index 0000000..334e972 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/README.md @@ -0,0 +1,133 @@ +# 优惠券中心模块 (ruoyi-mall-coupon) + +## 模块简介 + +`ruoyi-mall-coupon` 是商城系统的优惠券中心模块,提供独立的优惠券服务,支持发券、核销、过期处理等功能。 + +## 功能特性 + +### 1. 优惠券管理 ✅ + +- ✅ 优惠券CRUD操作 +- ✅ 优惠券类型管理(满减、折扣、现金) +- ✅ 优惠券发放规则配置 +- ✅ 优惠券有效期管理 +- ✅ 优惠券状态管理(启用/禁用) +- ✅ 优惠券导出功能 + +### 2. 用户优惠券 ✅ + +- ✅ 用户领券 +- ✅ 用户优惠券列表查询 +- ✅ 优惠券使用(订单结算时调用) +- ✅ 优惠券退还(订单取消时调用) +- ✅ 优惠券过期处理(定时任务)✅ + +### 3. 优惠券统计 + +- [ ] 发放统计(可在管理端通过列表查询实现) +- [ ] 使用统计(可在管理端通过列表查询实现) +- [ ] 核销统计(可在管理端通过列表查询实现) + +## 数据库表 + +- `mall_coupon` - 优惠券表 +- `mall_coupon_user` - 用户优惠券表 + +详细表结构请参考:`script/sql/ry_mall.sql` + +## 模块结构 + +``` +org.dromara.mall.coupon +├── controller +│ ├── admin // 管理端API +│ │ ├── AdminMallCouponController.java +│ │ └── AdminMallCouponUserController.java +│ └── app // 用户端API +│ ├── AppMallCouponController.java +│ └── AppMallCouponUserController.java +├── domain +│ ├── MallCoupon.java +│ ├── MallCouponUser.java +│ ├── bo // 业务对象 +│ │ ├── MallCouponBo.java +│ │ └── MallCouponUserBo.java +│ └── vo // 视图对象 +│ ├── MallCouponVo.java +│ └── MallCouponUserVo.java +├── mapper +│ ├── MallCouponMapper.java +│ └── MallCouponUserMapper.java +├── service +│ ├── MallCouponService.java +│ ├── MallCouponUserService.java +│ └── impl +│ ├── MallCouponServiceImpl.java +│ └── MallCouponUserServiceImpl.java +├── job // 定时任务 +│ └── MallCouponJob.java +└── config + └── MallCouponAutoConfiguration.java +``` + +## API接口 + +### 管理端接口 + +#### 优惠券管理 (`/mall/coupon`) + +- `GET /mall/coupon/list` - 查询优惠券列表(分页) +- `GET /mall/coupon/{id}` - 获取优惠券详情 +- `POST /mall/coupon` - 新增优惠券 +- `PUT /mall/coupon` - 修改优惠券 +- `DELETE /mall/coupon/{ids}` - 删除优惠券 +- `POST /mall/coupon/export` - 导出优惠券列表 +- `PUT /mall/coupon/changeStatus/{id}` - 启用/禁用优惠券 + +#### 用户优惠券管理 (`/mall/coupon/user`) + +- `GET /mall/coupon/user/list` - 查询用户优惠券列表(分页) +- `GET /mall/coupon/user/{id}` - 获取用户优惠券详情 +- `DELETE /mall/coupon/user/{ids}` - 删除用户优惠券 +- `POST /mall/coupon/user/export` - 导出用户优惠券列表 + +### 移动端接口 + +#### 优惠券 (`/app/mall/coupon`) + +- `GET /app/mall/coupon/available/list` - 查询可领取的优惠券列表 +- `GET /app/mall/coupon/{id}` - 获取优惠券详情 + +#### 用户优惠券 (`/app/mall/coupon/user`) + +- `GET /app/mall/coupon/user/list` - 查询用户优惠券列表 +- `GET /app/mall/coupon/user/available/list` - 查询用户可用优惠券列表(未使用且未过期) +- `GET /app/mall/coupon/user/{id}` - 获取用户优惠券详情 +- `POST /app/mall/coupon/user/receive/{couponId}` - 领取优惠券 + +## 优惠券类型说明 + +- **0 - 满减券**: 需要设置优惠金额和使用门槛 +- **1 - 折扣券**: 需要设置折扣率(百分比)和使用门槛 +- **2 - 现金券**: 需要设置优惠金额,无使用门槛 + +## 依赖模块 + +- `ruoyi-common-*` - 通用工具模块 +- `ruoyi-common-excel` - Excel导出功能 +- `ruoyi-common-satoken` - 用户身份认证 + +## 待实现功能 + +- [ ] 优惠券统计功能(可在管理端通过列表查询和筛选实现) + +## 使用说明 + +### 订单结算时使用优惠券 + +在订单提交时,可以调用 `MallCouponUserService.useCoupon(couponUserId, orderId)` 来使用优惠券。 + +### 订单取消时退还优惠券 + +在订单取消时,可以调用 `MallCouponUserService.returnCoupon(couponUserId)` 来退还优惠券。 diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/pom.xml b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/pom.xml new file mode 100644 index 0000000..a44eb75 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/pom.xml @@ -0,0 +1,67 @@ + + + + org.dromara + ruoyi-mall + ${revision} + + 4.0.0 + + ruoyi-mall-coupon + + + ruoyi-mall-coupon 优惠券中心模块 + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-log + + + + org.dromara + ruoyi-common-redis + + + + org.dromara + ruoyi-common-excel + + + + org.dromara + ruoyi-common-satoken + + + + + diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/config/MallCouponAutoConfiguration.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/config/MallCouponAutoConfiguration.java new file mode 100644 index 0000000..d4eb66d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/config/MallCouponAutoConfiguration.java @@ -0,0 +1,23 @@ +package org.dromara.mall.coupon.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * 优惠券中心自动配置类 + * 主要用于扫描优惠券中心模块的组件 + * + * @author pengles + */ +@Slf4j +@Configuration +@ComponentScan(basePackages = "org.dromara.mall.coupon") +@ConditionalOnProperty(prefix = "ruoyi.mall.coupon", name = "enabled", havingValue = "true", matchIfMissing = true) +public class MallCouponAutoConfiguration { + + public MallCouponAutoConfiguration() { + log.info("优惠券中心模块已启用"); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/admin/AdminMallCouponController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/admin/AdminMallCouponController.java new file mode 100644 index 0000000..b9ba125 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/admin/AdminMallCouponController.java @@ -0,0 +1,140 @@ +package org.dromara.mall.coupon.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.coupon.domain.bo.MallCouponBo; +import org.dromara.mall.coupon.domain.vo.CouponStatisticsVo; +import org.dromara.mall.coupon.domain.vo.MallCouponVo; +import org.dromara.mall.coupon.service.MallCouponService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 优惠券管理 + * + * @author pengles + */ +@Tag(name = "管理端-优惠券管理", description = "优惠券管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/coupon") +public class AdminMallCouponController extends BaseController { + + private final MallCouponService mallCouponService; + + /** + * 查询优惠券列表 + */ + @Operation(summary = "查询优惠券列表", description = "分页查询优惠券列表") + @SaCheckPermission("mall:coupon:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "优惠券查询条件") MallCouponBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallCouponService.queryPageList(bo, pageQuery); + } + + /** + * 导出售后申请列表 + */ + @Operation(summary = "导出优惠券列表", description = "导出优惠券到Excel") + @SaCheckPermission("mall:coupon:export") + @Log(title = "优惠券", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "优惠券查询条件") MallCouponBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallCouponService.queryList(bo); + ExcelUtil.exportExcel(list, "优惠券", MallCouponVo.class, response); + } + + /** + * 获取优惠券详细信息 + */ + @Operation(summary = "获取优惠券详情", description = "根据优惠券ID获取详细信息") + @SaCheckPermission("mall:coupon:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "优惠券ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallCouponService.queryById(id)); + } + + /** + * 新增优惠券 + */ + @Operation(summary = "新增优惠券", description = "创建新的优惠券") + @SaCheckPermission("mall:coupon:add") + @Log(title = "优惠券", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "优惠券信息") @Validated @RequestBody MallCouponBo bo) { + return toAjax(mallCouponService.insertByBo(bo)); + } + + /** + * 修改优惠券 + */ + @Operation(summary = "修改优惠券", description = "更新优惠券信息") + @SaCheckPermission("mall:coupon:edit") + @Log(title = "优惠券", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "优惠券信息") @Validated @RequestBody MallCouponBo bo) { + return toAjax(mallCouponService.updateByBo(bo)); + } + + /** + * 删除优惠券 + */ + @Operation(summary = "删除优惠券", description = "删除优惠券") + @SaCheckPermission("mall:coupon:remove") + @Log(title = "优惠券", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "优惠券ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallCouponService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 启用/禁用优惠券 + */ + @Operation(summary = "启用/禁用优惠券", description = "更改优惠券状态") + @SaCheckPermission("mall:coupon:edit") + @Log(title = "优惠券", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus/{id}") + public R changeStatus(@Parameter(description = "优惠券ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "状态", required = true) + @NotNull(message = "状态不能为空") + @RequestParam Integer status) { + return toAjax(mallCouponService.changeStatus(id, status)); + } + + /** + * 获取优惠券统计信息 + */ + @Operation(summary = "获取优惠券统计信息", description = "获取优惠券的发放、使用、核销等统计数据") + @SaCheckPermission("mall:coupon:query") + @GetMapping("/statistics") + public R getStatistics( + @Parameter(description = "优惠券ID,为空则统计所有优惠券") + @RequestParam(required = false) Long couponId) { + return R.ok(mallCouponService.getStatistics(couponId)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/admin/AdminMallCouponUserController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/admin/AdminMallCouponUserController.java new file mode 100644 index 0000000..c479a52 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/admin/AdminMallCouponUserController.java @@ -0,0 +1,89 @@ +package org.dromara.mall.coupon.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.coupon.domain.bo.MallCouponUserBo; +import org.dromara.mall.coupon.domain.vo.MallCouponUserVo; +import org.dromara.mall.coupon.service.MallCouponUserService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 用户优惠券管理 + * + * @author pengles + */ +@Tag(name = "管理端-用户优惠券管理", description = "用户优惠券管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/coupon/user") +public class AdminMallCouponUserController extends BaseController { + + private final MallCouponUserService mallCouponUserService; + + /** + * 查询用户优惠券列表 + */ + @Operation(summary = "查询用户优惠券列表", description = "分页查询用户优惠券列表") + @SaCheckPermission("mall:coupon:user:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "用户优惠券查询条件") MallCouponUserBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallCouponUserService.queryPageList(bo, pageQuery); + } + + /** + * 导出用户优惠券列表 + */ + @Operation(summary = "导出用户优惠券列表", description = "导出用户优惠券到Excel") + @SaCheckPermission("mall:coupon:user:export") + @Log(title = "用户优惠券", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "用户优惠券查询条件") MallCouponUserBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallCouponUserService.queryList(bo); + ExcelUtil.exportExcel(list, "用户优惠券", MallCouponUserVo.class, response); + } + + /** + * 获取用户优惠券详细信息 + */ + @Operation(summary = "获取用户优惠券详情", description = "根据用户优惠券ID获取详细信息") + @SaCheckPermission("mall:coupon:user:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "用户优惠券ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallCouponUserService.queryById(id)); + } + + /** + * 删除用户优惠券 + */ + @Operation(summary = "删除用户优惠券", description = "删除用户优惠券") + @SaCheckPermission("mall:coupon:user:remove") + @Log(title = "用户优惠券", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "用户优惠券ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallCouponUserService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/app/AppMallCouponController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/app/AppMallCouponController.java new file mode 100644 index 0000000..002e8be --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/app/AppMallCouponController.java @@ -0,0 +1,50 @@ +package org.dromara.mall.coupon.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.mall.coupon.domain.vo.MallCouponVo; +import org.dromara.mall.coupon.service.MallCouponService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 优惠券(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-优惠券", description = "优惠券接口") +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/coupon") +public class AppMallCouponController { + + private final MallCouponService mallCouponService; + + /** + * 查询可领取的优惠券列表 + */ + @Operation(summary = "查询可领取的优惠券列表", description = "查询用户可以领取的优惠券列表") + @GetMapping("/available/list") + public R> getAvailableList() { + List list = mallCouponService.queryAvailableList(); + return R.ok(list); + } + + /** + * 获取优惠券详情 + */ + @Operation(summary = "获取优惠券详情", description = "根据优惠券ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "优惠券ID", required = true) + @PathVariable Long id) { + return R.ok(mallCouponService.queryById(id)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/app/AppMallCouponUserController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/app/AppMallCouponUserController.java new file mode 100644 index 0000000..0fe20f2 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/controller/app/AppMallCouponUserController.java @@ -0,0 +1,78 @@ +package org.dromara.mall.coupon.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.coupon.domain.bo.MallCouponUserBo; +import org.dromara.mall.coupon.domain.vo.MallCouponUserVo; +import org.dromara.mall.coupon.service.MallCouponUserService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 用户优惠券(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-用户优惠券", description = "用户优惠券接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/coupon/user") +public class AppMallCouponUserController { + + private final MallCouponUserService mallCouponUserService; + + /** + * 查询用户优惠券列表 + */ + @Operation(summary = "查询用户优惠券列表", description = "查询当前用户的优惠券列表") + @GetMapping("/list") + public R> list(@Parameter(description = "用户优惠券查询条件") MallCouponUserBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + // 移动端只能查询当前用户的优惠券 + TableDataInfo result = mallCouponUserService.queryPageList(bo, pageQuery); + return R.ok(result); + } + + /** + * 查询用户可用优惠券列表 + */ + @Operation(summary = "查询用户可用优惠券列表", description = "查询当前用户可用的优惠券列表(未使用且未过期)") + @GetMapping("/available/list") + public R> getAvailableList() { + Long userId = org.dromara.common.satoken.utils.LoginHelper.getUserId(); + List list = mallCouponUserService.queryAvailableList(userId); + return R.ok(list); + } + + /** + * 获取用户优惠券详情 + */ + @Operation(summary = "获取用户优惠券详情", description = "根据用户优惠券ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "用户优惠券ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallCouponUserService.queryById(id)); + } + + /** + * 领取优惠券 + */ + @Operation(summary = "领取优惠券", description = "用户领取优惠券") + @PostMapping("/receive/{couponId}") + public R receiveCoupon(@Parameter(description = "优惠券ID", required = true) + @NotNull(message = "优惠券ID不能为空") + @PathVariable Long couponId) { + return R.ok(mallCouponUserService.receiveCoupon(couponId)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/MallCoupon.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/MallCoupon.java new file mode 100644 index 0000000..6472374 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/MallCoupon.java @@ -0,0 +1,118 @@ +package org.dromara.mall.coupon.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 优惠券对象 mall_coupon + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_coupon") +public class MallCoupon implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 优惠券ID + */ + @TableId + private Long id; + + /** + * 优惠券名称 + */ + private String name; + + /** + * 优惠券类型(0满减 1折扣 2现金) + */ + private Integer type; + + /** + * 优惠金额(分) + */ + private Integer discountAmount; + + /** + * 折扣率(%) + */ + private BigDecimal discountRate; + + /** + * 使用门槛(分) + */ + private Integer minAmount; + + /** + * 发放总数(null表示不限) + */ + private Integer totalCount; + + /** + * 已使用数量 + */ + private Integer usedCount; + + /** + * 每人限领数量 + */ + private Integer perLimit; + + /** + * 有效开始时间 + */ + private Date validStartTime; + + /** + * 有效结束时间 + */ + private Date validEndTime; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/MallCouponUser.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/MallCouponUser.java new file mode 100644 index 0000000..e11f6d5 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/MallCouponUser.java @@ -0,0 +1,54 @@ +package org.dromara.mall.coupon.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 用户优惠券对象 mall_coupon_user + * + * @author pengles + */ +@Data +@TableName("mall_coupon_user") +public class MallCouponUser { + + /** + * ID + */ + @TableId + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 优惠券ID + */ + private Long couponId; + + /** + * 状态(0未使用 1已使用 2已过期) + */ + private Integer status; + + /** + * 使用时间 + */ + private Date usedTime; + + /** + * 使用的订单ID + */ + private Long orderId; + + /** + * 领取时间 + */ + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/bo/MallCouponBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/bo/MallCouponBo.java new file mode 100644 index 0000000..476713d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/bo/MallCouponBo.java @@ -0,0 +1,99 @@ +package org.dromara.mall.coupon.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.coupon.domain.MallCoupon; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 优惠券业务对象 mall_coupon + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallCoupon.class, reverseConvertGenerate = false) +public class MallCouponBo extends BaseEntity { + + /** + * 优惠券ID + */ + private Long id; + + /** + * 优惠券名称 + */ + @NotBlank(message = "优惠券名称不能为空") + private String name; + + /** + * 优惠券类型(0满减 1折扣 2现金) + */ + @NotNull(message = "优惠券类型不能为空") + private Integer type; + + /** + * 优惠金额(分) + */ + @Min(value = 0, message = "优惠金额不能为负数") + private Integer discountAmount; + + /** + * 折扣率(%) + */ + @Min(value = 0, message = "折扣率不能为负数") + private BigDecimal discountRate; + + /** + * 使用门槛(分) + */ + @Min(value = 0, message = "使用门槛不能为负数") + private Integer minAmount; + + /** + * 发放总数(null表示不限) + */ + @Min(value = 1, message = "发放总数必须大于0") + private Integer totalCount; + + /** + * 已使用数量 + */ + private Integer usedCount; + + /** + * 每人限领数量 + */ + @Min(value = 1, message = "每人限领数量必须大于0") + private Integer perLimit; + + /** + * 有效开始时间 + */ + @NotNull(message = "有效开始时间不能为空") + private Date validStartTime; + + /** + * 有效结束时间 + */ + @NotNull(message = "有效结束时间不能为空") + private Date validEndTime; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/bo/MallCouponUserBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/bo/MallCouponUserBo.java new file mode 100644 index 0000000..d7b7cbc --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/bo/MallCouponUserBo.java @@ -0,0 +1,51 @@ +package org.dromara.mall.coupon.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.dromara.mall.coupon.domain.MallCouponUser; + +import java.util.Date; + +/** + * 用户优惠券业务对象 mall_coupon_user + * + * @author pengles + */ +@Data +@AutoMapper(target = MallCouponUser.class, reverseConvertGenerate = false) +public class MallCouponUserBo { + + /** + * ID + */ + private Long id; + + /** + * 用户ID + */ + @NotNull(message = "用户ID不能为空") + private Long userId; + + /** + * 优惠券ID + */ + @NotNull(message = "优惠券ID不能为空") + private Long couponId; + + /** + * 状态(0未使用 1已使用 2已过期) + */ + private Integer status; + + /** + * 使用时间 + */ + private Date usedTime; + + /** + * 使用的订单ID + */ + private Long orderId; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/CouponStatisticsVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/CouponStatisticsVo.java new file mode 100644 index 0000000..61cfdd1 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/CouponStatisticsVo.java @@ -0,0 +1,49 @@ +package org.dromara.mall.coupon.domain.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 优惠券统计数据视图对象 + * + * @author pengles + */ +@Data +public class CouponStatisticsVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 优惠券ID + */ + private Long couponId; + + /** + * 优惠券名称 + */ + private String couponName; + + /** + * 发放总数(已领取数量) + */ + private Long issuedCount; + + /** + * 使用数量(已使用数量) + */ + private Long usedCount; + + /** + * 使用率(使用数量/发放数量,百分比) + */ + private Double usageRate; + + /** + * 核销金额(分,基于订单优惠券使用记录表) + */ + private Long redeemedAmount; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/MallCouponUserVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/MallCouponUserVo.java new file mode 100644 index 0000000..953c08b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/MallCouponUserVo.java @@ -0,0 +1,63 @@ +package org.dromara.mall.coupon.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.coupon.domain.MallCouponUser; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 用户优惠券视图对象 mall_coupon_user + * + * @author pengles + */ +@Data +@AutoMapper(target = MallCouponUser.class) +public class MallCouponUserVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * ID + */ + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 优惠券ID + */ + private Long couponId; + + /** + * 状态(0未使用 1已使用 2已过期) + */ + private Integer status; + + /** + * 使用时间 + */ + private Date usedTime; + + /** + * 使用的订单ID + */ + private Long orderId; + + /** + * 领取时间 + */ + private Date createTime; + + /** + * 优惠券信息(关联查询) + */ + private MallCouponVo couponInfo; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/MallCouponVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/MallCouponVo.java new file mode 100644 index 0000000..bfbbf77 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/domain/vo/MallCouponVo.java @@ -0,0 +1,99 @@ +package org.dromara.mall.coupon.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.coupon.domain.MallCoupon; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 优惠券视图对象 mall_coupon + * + * @author pengles + */ +@Data +@AutoMapper(target = MallCoupon.class) +public class MallCouponVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 优惠券ID + */ + private Long id; + + /** + * 优惠券名称 + */ + private String name; + + /** + * 优惠券类型(0满减 1折扣 2现金) + */ + private Integer type; + + /** + * 优惠金额(分) + */ + private Integer discountAmount; + + /** + * 折扣率(%) + */ + private BigDecimal discountRate; + + /** + * 使用门槛(分) + */ + private Integer minAmount; + + /** + * 发放总数(null表示不限) + */ + private Integer totalCount; + + /** + * 已使用数量 + */ + private Integer usedCount; + + /** + * 每人限领数量 + */ + private Integer perLimit; + + /** + * 有效开始时间 + */ + private Date validStartTime; + + /** + * 有效结束时间 + */ + private Date validEndTime; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/job/MallCouponJob.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/job/MallCouponJob.java new file mode 100644 index 0000000..50da65d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/job/MallCouponJob.java @@ -0,0 +1,37 @@ +package org.dromara.mall.coupon.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.mall.coupon.service.MallCouponUserService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 优惠券定时任务 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class MallCouponJob { + + private final MallCouponUserService couponUserService; + + /** + * 优惠券过期处理任务 + * 每天凌晨2点执行一次 + * 处理所有已过期的优惠券,将状态更新为已过期(status=2) + */ + @Scheduled(cron = "0 0 2 * * ?") + public void processExpiredCoupons() { + log.info("开始执行优惠券过期处理任务"); + try { + couponUserService.processExpiredCoupons(); + log.info("优惠券过期处理任务执行完成"); + } catch (Exception e) { + log.error("优惠券过期处理任务执行失败", e); + } + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/mapper/MallCouponMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/mapper/MallCouponMapper.java new file mode 100644 index 0000000..f31cbe4 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/mapper/MallCouponMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.coupon.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.coupon.domain.MallCoupon; +import org.dromara.mall.coupon.domain.vo.MallCouponVo; + +/** + * 优惠券Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallCouponMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/mapper/MallCouponUserMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/mapper/MallCouponUserMapper.java new file mode 100644 index 0000000..194a551 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/mapper/MallCouponUserMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.coupon.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.coupon.domain.MallCouponUser; +import org.dromara.mall.coupon.domain.vo.MallCouponUserVo; + +/** + * 用户优惠券Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallCouponUserMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/MallCouponService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/MallCouponService.java new file mode 100644 index 0000000..3517d51 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/MallCouponService.java @@ -0,0 +1,67 @@ +package org.dromara.mall.coupon.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.coupon.domain.bo.MallCouponBo; +import org.dromara.mall.coupon.domain.vo.CouponStatisticsVo; +import org.dromara.mall.coupon.domain.vo.MallCouponVo; + +import java.util.Collection; +import java.util.List; + +/** + * 优惠券Service接口 + * + * @author pengles + */ +public interface MallCouponService { + + /** + * 查询优惠券 + */ + MallCouponVo queryById(Long id); + + /** + * 查询优惠券列表 + */ + TableDataInfo queryPageList(MallCouponBo bo, PageQuery pageQuery); + + /** + * 查询优惠券列表 + */ + List queryList(MallCouponBo bo); + + /** + * 查询可领取的优惠券列表(移动端) + */ + List queryAvailableList(); + + /** + * 新增优惠券 + */ + Boolean insertByBo(MallCouponBo bo); + + /** + * 修改优惠券 + */ + Boolean updateByBo(MallCouponBo bo); + + /** + * 校验并批量删除优惠券信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 启用/禁用优惠券 + */ + Boolean changeStatus(Long id, Integer status); + + /** + * 获取优惠券统计信息 + * + * @param couponId 优惠券ID,如果为空则统计所有优惠券 + * @return 优惠券统计数据 + */ + CouponStatisticsVo getStatistics(Long couponId); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/MallCouponUserService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/MallCouponUserService.java new file mode 100644 index 0000000..d36a484 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/MallCouponUserService.java @@ -0,0 +1,63 @@ +package org.dromara.mall.coupon.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.coupon.domain.bo.MallCouponUserBo; +import org.dromara.mall.coupon.domain.vo.MallCouponUserVo; + +import java.util.Collection; +import java.util.List; + +/** + * 用户优惠券Service接口 + * + * @author pengles + */ +public interface MallCouponUserService { + + /** + * 查询用户优惠券 + */ + MallCouponUserVo queryById(Long id); + + /** + * 查询用户优惠券列表 + */ + TableDataInfo queryPageList(MallCouponUserBo bo, PageQuery pageQuery); + + /** + * 查询用户优惠券列表 + */ + List queryList(MallCouponUserBo bo); + + /** + * 查询用户可用优惠券列表 + */ + List queryAvailableList(Long userId); + + /** + * 用户领取优惠券 + */ + Boolean receiveCoupon(Long couponId); + + /** + * 使用优惠券(订单结算时调用) + */ + Boolean useCoupon(Long couponUserId, Long orderId); + + /** + * 退还优惠券(订单取消时调用) + */ + Boolean returnCoupon(Long couponUserId); + + /** + * 批量删除用户优惠券信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 处理过期优惠券(定时任务调用) + */ + void processExpiredCoupons(); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/impl/MallCouponServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/impl/MallCouponServiceImpl.java new file mode 100644 index 0000000..125cf01 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/impl/MallCouponServiceImpl.java @@ -0,0 +1,264 @@ +package org.dromara.mall.coupon.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.coupon.domain.MallCoupon; +import org.dromara.mall.coupon.domain.MallCouponUser; +import org.dromara.mall.coupon.domain.bo.MallCouponBo; +import org.dromara.mall.coupon.domain.vo.CouponStatisticsVo; +import org.dromara.mall.coupon.domain.vo.MallCouponVo; +import org.dromara.mall.coupon.mapper.MallCouponMapper; +import org.dromara.mall.coupon.mapper.MallCouponUserMapper; +import org.dromara.mall.coupon.service.MallCouponService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 优惠券Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallCouponServiceImpl implements MallCouponService { + + private final MallCouponMapper baseMapper; + private final MallCouponUserMapper couponUserMapper; + + /** + * 查询优惠券 + */ + @Override + public MallCouponVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询优惠券列表 + */ + @Override + public TableDataInfo queryPageList(MallCouponBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询优惠券列表 + */ + @Override + public List queryList(MallCouponBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + /** + * 查询可领取的优惠券列表(移动端) + */ + @Override + public List queryAvailableList() { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallCoupon::getStatus, 0); // 启用状态 + Date now = new Date(); + lqw.le(MallCoupon::getValidStartTime, now); // 已开始 + lqw.ge(MallCoupon::getValidEndTime, now); // 未结束 + // 发放总数未限制或已发放数量小于总数 + lqw.and(wrapper -> wrapper + .isNull(MallCoupon::getTotalCount) + .or(wrapper2 -> wrapper2.apply("used_count < total_count")) + ); + lqw.orderByDesc(MallCoupon::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallCouponBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.like(StringUtils.isNotBlank(bo.getName()), MallCoupon::getName, bo.getName()); + lqw.eq(bo.getType() != null, MallCoupon::getType, bo.getType()); + lqw.eq(bo.getStatus() != null, MallCoupon::getStatus, bo.getStatus()); + lqw.ge(bo.getValidStartTime() != null, MallCoupon::getValidStartTime, bo.getValidStartTime()); + lqw.le(bo.getValidEndTime() != null, MallCoupon::getValidEndTime, bo.getValidEndTime()); + lqw.orderByDesc(MallCoupon::getCreateTime); + return lqw; + } + + /** + * 新增优惠券 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallCouponBo bo) { + MallCoupon add = MapstructUtils.convert(bo, MallCoupon.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getMinAmount() == null) { + add.setMinAmount(0); + } + if (add.getUsedCount() == null) { + add.setUsedCount(0); + } + if (add.getPerLimit() == null) { + add.setPerLimit(1); + } + if (add.getStatus() == null) { + add.setStatus(0); // 默认启用 + } + + // 校验优惠券类型和对应字段 + validateCouponType(add); + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改优惠券 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallCouponBo bo) { + MallCoupon update = MapstructUtils.convert(bo, MallCoupon.class); + validEntityBeforeSave(update); + + // 校验优惠券类型和对应字段 + validateCouponType(update); + + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallCoupon entity) { + // 校验有效期 + if (entity.getValidStartTime() != null && entity.getValidEndTime() != null) { + if (entity.getValidEndTime().before(entity.getValidStartTime())) { + throw new ServiceException("有效结束时间不能早于开始时间"); + } + } + } + + /** + * 校验优惠券类型和对应字段 + */ + private void validateCouponType(MallCoupon entity) { + if (entity.getType() == null) { + return; + } + // 0-满减,1-折扣,2-现金 + if (entity.getType() == 0) { + // 满减类型必须设置优惠金额 + if (entity.getDiscountAmount() == null || entity.getDiscountAmount() <= 0) { + throw new ServiceException("满减类型优惠券必须设置优惠金额"); + } + } else if (entity.getType() == 1) { + // 折扣类型必须设置折扣率 + if (entity.getDiscountRate() == null || entity.getDiscountRate().compareTo(java.math.BigDecimal.ZERO) <= 0) { + throw new ServiceException("折扣类型优惠券必须设置折扣率"); + } + if (entity.getDiscountRate().compareTo(new java.math.BigDecimal("100")) > 0) { + throw new ServiceException("折扣率不能大于100%"); + } + } else if (entity.getType() == 2) { + // 现金类型必须设置优惠金额 + if (entity.getDiscountAmount() == null || entity.getDiscountAmount() <= 0) { + throw new ServiceException("现金类型优惠券必须设置优惠金额"); + } + } + } + + /** + * 批量删除优惠券 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 可以添加业务校验逻辑,如:已使用数量大于0的优惠券不能删除 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 启用/禁用优惠券 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean changeStatus(Long id, Integer status) { + MallCoupon coupon = baseMapper.selectById(id); + if (coupon == null) { + throw new ServiceException("优惠券不存在"); + } + if (status != 0 && status != 1) { + throw new ServiceException("状态值不正确"); + } + coupon.setStatus(status); + return baseMapper.updateById(coupon) > 0; + } + + /** + * 获取优惠券统计信息 + */ + @Override + public CouponStatisticsVo getStatistics(Long couponId) { + CouponStatisticsVo vo = new CouponStatisticsVo(); + vo.setCouponId(couponId); + + // 如果指定了优惠券ID,获取优惠券信息 + if (couponId != null) { + MallCouponVo couponVo = baseMapper.selectVoById(couponId); + if (couponVo != null) { + vo.setCouponName(couponVo.getName()); + } + } + + // 统计发放数量(已领取数量,即mall_coupon_user表中的总记录数) + LambdaQueryWrapper issuedWrapper = new LambdaQueryWrapper<>(); + if (couponId != null) { + issuedWrapper.eq(MallCouponUser::getCouponId, couponId); + } + Long issuedCount = couponUserMapper.selectCount(issuedWrapper); + vo.setIssuedCount(issuedCount != null ? issuedCount : 0L); + + // 统计使用数量(status=1的记录数) + LambdaQueryWrapper usedWrapper = new LambdaQueryWrapper<>(); + if (couponId != null) { + usedWrapper.eq(MallCouponUser::getCouponId, couponId); + } + usedWrapper.eq(MallCouponUser::getStatus, 1); // 已使用 + Long usedCount = couponUserMapper.selectCount(usedWrapper); + vo.setUsedCount(usedCount != null ? usedCount : 0L); + + // 计算使用率 + if (vo.getIssuedCount() > 0) { + double usageRate = (double) vo.getUsedCount() / vo.getIssuedCount() * 100; + vo.setUsageRate(Math.round(usageRate * 100.0) / 100.0); + } else { + vo.setUsageRate(0.0); + } + + // 核销金额统计(基于mall_order_coupon表,需要在交易中心模块查询) + // 由于跨模块依赖问题,这里暂时设为0 + // 如果需要在优惠券模块直接查询,可以考虑通过Feign调用交易中心模块的接口,或者将统计功能放在交易中心模块 + // 当前实现:如果交易中心模块已实现订单优惠券记录,可以通过依赖注入MallOrderCouponService来查询 + // TODO: 考虑通过服务间调用或事件机制来获取核销金额统计 + vo.setRedeemedAmount(0L); + + return vo; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/impl/MallCouponUserServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/impl/MallCouponUserServiceImpl.java new file mode 100644 index 0000000..8b48571 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/java/org/dromara/mall/coupon/service/impl/MallCouponUserServiceImpl.java @@ -0,0 +1,296 @@ +package org.dromara.mall.coupon.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.mall.coupon.domain.MallCoupon; +import org.dromara.mall.coupon.domain.MallCouponUser; +import org.dromara.mall.coupon.domain.bo.MallCouponUserBo; +import org.dromara.mall.coupon.domain.vo.MallCouponUserVo; +import org.dromara.mall.coupon.domain.vo.MallCouponVo; +import org.dromara.mall.coupon.mapper.MallCouponMapper; +import org.dromara.mall.coupon.mapper.MallCouponUserMapper; +import org.dromara.mall.coupon.service.MallCouponUserService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 用户优惠券Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallCouponUserServiceImpl implements MallCouponUserService { + + private final MallCouponUserMapper baseMapper; + private final MallCouponMapper couponMapper; + + /** + * 查询用户优惠券 + */ + @Override + public MallCouponUserVo queryById(Long id) { + MallCouponUserVo vo = baseMapper.selectVoById(id); + if (vo != null && vo.getCouponId() != null) { + // 关联查询优惠券信息 + MallCouponVo couponVo = couponMapper.selectVoById(vo.getCouponId()); + vo.setCouponInfo(couponVo); + } + return vo; + } + + /** + * 查询用户优惠券列表 + */ + @Override + public TableDataInfo queryPageList(MallCouponUserBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + + // 关联查询优惠券信息 + fillCouponInfo(result.getRecords()); + + return TableDataInfo.build(result); + } + + /** + * 查询用户优惠券列表 + */ + @Override + public List queryList(MallCouponUserBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + List list = baseMapper.selectVoList(lqw); + + // 关联查询优惠券信息 + fillCouponInfo(list); + + return list; + } + + /** + * 查询用户可用优惠券列表 + */ + @Override + public List queryAvailableList(Long userId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallCouponUser::getUserId, userId); + lqw.eq(MallCouponUser::getStatus, 0); // 未使用 + lqw.orderByDesc(MallCouponUser::getCreateTime); + + List list = baseMapper.selectVoList(lqw); + + // 过滤已过期的优惠券 + Date now = new Date(); + list = list.stream().filter(vo -> { + if (vo.getCouponId() == null) { + return false; + } + MallCouponVo couponVo = couponMapper.selectVoById(vo.getCouponId()); + if (couponVo == null || couponVo.getStatus() != 0) { + return false; + } + return couponVo.getValidEndTime() != null && couponVo.getValidEndTime().after(now); + }).collect(Collectors.toList()); + + // 关联查询优惠券信息 + fillCouponInfo(list); + + return list; + } + + /** + * 填充优惠券信息 + */ + private void fillCouponInfo(List list) { + if (list == null || list.isEmpty()) { + return; + } + for (MallCouponUserVo vo : list) { + if (vo.getCouponId() != null) { + MallCouponVo couponVo = couponMapper.selectVoById(vo.getCouponId()); + vo.setCouponInfo(couponVo); + } + } + } + + private LambdaQueryWrapper buildQueryWrapper(MallCouponUserBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(bo.getUserId() != null, MallCouponUser::getUserId, bo.getUserId()); + lqw.eq(bo.getCouponId() != null, MallCouponUser::getCouponId, bo.getCouponId()); + lqw.eq(bo.getStatus() != null, MallCouponUser::getStatus, bo.getStatus()); + lqw.orderByDesc(MallCouponUser::getCreateTime); + return lqw; + } + + /** + * 用户领取优惠券 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean receiveCoupon(Long couponId) { + Long userId = LoginHelper.getUserId(); + + // 查询优惠券信息 + MallCoupon coupon = couponMapper.selectById(couponId); + if (coupon == null) { + throw new ServiceException("优惠券不存在"); + } + if (coupon.getStatus() != 0) { + throw new ServiceException("优惠券已禁用"); + } + + // 校验有效期 + Date now = new Date(); + if (coupon.getValidStartTime().after(now) || coupon.getValidEndTime().before(now)) { + throw new ServiceException("优惠券不在有效期内"); + } + + // 校验发放数量 + if (coupon.getTotalCount() != null) { + if (coupon.getUsedCount() >= coupon.getTotalCount()) { + throw new ServiceException("优惠券已发放完毕"); + } + } + + // 校验每人限领数量 + LambdaQueryWrapper countWrapper = new LambdaQueryWrapper<>(); + countWrapper.eq(MallCouponUser::getUserId, userId); + countWrapper.eq(MallCouponUser::getCouponId, couponId); + Long receivedCount = baseMapper.selectCount(countWrapper); + if (receivedCount >= coupon.getPerLimit()) { + throw new ServiceException("已达到每人限领数量"); + } + + // 创建用户优惠券记录 + MallCouponUser couponUser = new MallCouponUser(); + couponUser.setUserId(userId); + couponUser.setCouponId(couponId); + couponUser.setStatus(0); // 未使用 + couponUser.setCreateTime(now); + + boolean flag = baseMapper.insert(couponUser) > 0; + + if (flag) { + // 更新优惠券已发放数量(注意:这里used_count实际存储的是已发放数量,不是已使用数量) + // 如果业务需要区分,可以考虑增加字段 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(MallCoupon::getId, couponId); + updateWrapper.setSql("used_count = used_count + 1"); + couponMapper.update(null, updateWrapper); + } + + return flag; + } + + /** + * 使用优惠券(订单结算时调用) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean useCoupon(Long couponUserId, Long orderId) { + MallCouponUser couponUser = baseMapper.selectById(couponUserId); + if (couponUser == null) { + throw new ServiceException("用户优惠券不存在"); + } + if (couponUser.getStatus() != 0) { + throw new ServiceException("优惠券已使用或已过期"); + } + + // 校验优惠券是否过期 + MallCoupon coupon = couponMapper.selectById(couponUser.getCouponId()); + if (coupon == null) { + throw new ServiceException("优惠券不存在"); + } + Date now = new Date(); + if (coupon.getValidEndTime().before(now)) { + throw new ServiceException("优惠券已过期"); + } + + // 更新用户优惠券状态 + couponUser.setStatus(1); // 已使用 + couponUser.setUsedTime(now); + couponUser.setOrderId(orderId); + + return baseMapper.updateById(couponUser) > 0; + } + + /** + * 退还优惠券(订单取消时调用) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean returnCoupon(Long couponUserId) { + MallCouponUser couponUser = baseMapper.selectById(couponUserId); + if (couponUser == null) { + throw new ServiceException("用户优惠券不存在"); + } + if (couponUser.getStatus() != 1) { + throw new ServiceException("优惠券状态不正确,无法退还"); + } + + // 校验优惠券是否过期 + MallCoupon coupon = couponMapper.selectById(couponUser.getCouponId()); + if (coupon == null) { + throw new ServiceException("优惠券不存在"); + } + Date now = new Date(); + if (coupon.getValidEndTime().before(now)) { + // 已过期,状态改为已过期 + couponUser.setStatus(2); + } else { + // 未过期,状态改为未使用 + couponUser.setStatus(0); + } + couponUser.setUsedTime(null); + couponUser.setOrderId(null); + + return baseMapper.updateById(couponUser) > 0; + } + + /** + * 批量删除用户优惠券 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 可以添加业务校验逻辑 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 处理过期优惠券(定时任务调用) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void processExpiredCoupons() { + Date now = new Date(); + + // 查询所有未使用的用户优惠券 + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallCouponUser::getStatus, 0); // 未使用 + List list = baseMapper.selectList(lqw); + + for (MallCouponUser couponUser : list) { + MallCoupon coupon = couponMapper.selectById(couponUser.getCouponId()); + if (coupon != null && coupon.getValidEndTime().before(now)) { + // 优惠券已过期,更新状态 + couponUser.setStatus(2); // 已过期 + baseMapper.updateById(couponUser); + } + } + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/resources/META-INF/spring.factories b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..d8573c7 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-coupon/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.dromara.mall.coupon.config.MallCouponAutoConfiguration diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/README.md b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/README.md new file mode 100644 index 0000000..500ad9f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/README.md @@ -0,0 +1,223 @@ +# 商品中心模块 (ruoyi-mall-product) + +## 模块简介 + +`ruoyi-mall-product` 是商城系统的商品中心模块,负责商品全生命周期管理,包括商品(SPU)、SKU、分类、品牌等核心功能。 + +## 功能特性 + +### 1. 商品管理 (SPU) + +- ✅ 商品CRUD操作 +- ✅ 商品上架/下架 +- ✅ 商品浏览量和销量统计 +- ✅ 商品详情管理 + +### 2. SKU管理 + +- ✅ SKU CRUD操作 +- ✅ 库存管理 +- ✅ 库存扣减和恢复 +- ✅ 规格属性管理 + +### 3. 商品分类管理 ✅ + +- ✅ 分类树形结构查询(支持三级分类) +- ✅ 分类CRUD操作(列表、详情、新增、修改、删除) +- ✅ 分类排序 +- ✅ 分类导出功能 +- ⏳ 分类图标管理(字段已支持,需前端集成文件上传功能) + +### 4. 品牌管理 ✅ + +- ✅ 品牌CRUD操作(列表、详情、新增、修改、删除) +- ✅ 品牌描述管理 +- ✅ 品牌排序 +- ✅ 品牌状态管理(启用/禁用) +- ✅ 品牌导出功能 +- ⏳ 品牌Logo上传(字段已支持,需前端集成文件上传功能) + +## 数据库表 + +- `mall_product` - 商品表 +- `mall_sku` - SKU表 +- `mall_category` - 商品分类表 +- `mall_brand` - 品牌表 +- `mall_attribute_template` - 属性模板表 +- `mall_attribute` - 属性表 +- `mall_attribute_value` - 属性值表 +- `mall_product_attribute` - 商品属性关联表 + +详细表结构请参考:`script/sql/ry_mall.sql` + +## 模块结构 + +``` +org.dromara.mall.product +├── controller +│ ├── admin // 管理端API +│ │ ├── AdminMallCategoryController.java +│ │ ├── AdminMallBrandController.java +│ │ ├── AdminMallAttributeTemplateController.java +│ │ ├── AdminMallAttributeController.java +│ │ ├── AdminMallAttributeValueController.java +│ │ └── AdminMallProductAttributeController.java +│ └── app // 用户端API +│ ├── AppMallCategoryController.java +│ └── AppMallBrandController.java +├── domain +│ ├── MallCategory.java +│ ├── MallBrand.java +│ ├── MallAttributeTemplate.java +│ ├── MallAttribute.java +│ ├── MallAttributeValue.java +│ ├── MallProductAttribute.java +│ ├── bo // 业务对象 +│ │ ├── MallCategoryBo.java +│ │ ├── MallBrandBo.java +│ │ ├── MallAttributeTemplateBo.java +│ │ ├── MallAttributeBo.java +│ │ ├── MallAttributeValueBo.java +│ │ └── MallProductAttributeBo.java +│ └── vo // 视图对象 +│ ├── MallCategoryVo.java +│ ├── MallBrandVo.java +│ ├── MallAttributeTemplateVo.java +│ ├── MallAttributeVo.java +│ ├── MallAttributeValueVo.java +│ └── MallProductAttributeVo.java +├── mapper +│ ├── MallCategoryMapper.java +│ ├── MallBrandMapper.java +│ ├── MallAttributeTemplateMapper.java +│ ├── MallAttributeMapper.java +│ ├── MallAttributeValueMapper.java +│ └── MallProductAttributeMapper.java +├── service +│ ├── MallCategoryService.java +│ ├── MallBrandService.java +│ ├── MallAttributeTemplateService.java +│ ├── MallAttributeService.java +│ ├── MallAttributeValueService.java +│ ├── MallProductAttributeService.java +│ └── impl +│ ├── MallCategoryServiceImpl.java +│ ├── MallBrandServiceImpl.java +│ ├── MallAttributeTemplateServiceImpl.java +│ ├── MallAttributeServiceImpl.java +│ ├── MallAttributeValueServiceImpl.java +│ └── MallProductAttributeServiceImpl.java +├── enums // 枚举类 +│ └── AttributeType.java +└── config + └── MallProductAutoConfiguration.java +``` + +## 依赖模块 + +- `ruoyi-common-*` - 通用工具模块 + +## API接口 + +### 管理端接口 + +#### 商品分类管理 (`/mall/product/category`) + +- `GET /mall/product/category/list` - 查询商品分类列表(分页) +- `GET /mall/product/category/tree` - 查询商品分类树形列表 +- `GET /mall/product/category/{id}` - 获取商品分类详情 +- `POST /mall/product/category` - 新增商品分类 +- `PUT /mall/product/category` - 修改商品分类 +- `DELETE /mall/product/category/{ids}` - 删除商品分类 +- `POST /mall/product/category/export` - 导出商品分类列表 + +#### 品牌管理 (`/mall/product/brand`) + +- `GET /mall/product/brand/list` - 查询品牌列表(分页) +- `GET /mall/product/brand/{id}` - 获取品牌详情 +- `POST /mall/product/brand` - 新增品牌 +- `PUT /mall/product/brand` - 修改品牌 +- `DELETE /mall/product/brand/{ids}` - 删除品牌 +- `POST /mall/product/brand/export` - 导出品牌列表 + +### 移动端接口 + +#### 商品分类 (`/app/mall/product/category`) + +- `GET /app/mall/product/category/tree` - 查询商品分类树形列表(仅启用分类) + +#### 品牌 (`/app/mall/product/brand`) + +- `GET /app/mall/product/brand/list` - 查询品牌列表(仅启用品牌) + +#### 属性模板管理 (`/mall/product/attribute/template`) + +- `GET /mall/product/attribute/template/list` - 查询属性模板列表(分页) +- `GET /mall/product/attribute/template/{id}` - 获取属性模板详情 +- `POST /mall/product/attribute/template` - 新增属性模板 +- `PUT /mall/product/attribute/template` - 修改属性模板 +- `DELETE /mall/product/attribute/template/{ids}` - 删除属性模板 +- `POST /mall/product/attribute/template/export` - 导出属性模板列表 + +#### 属性管理 (`/mall/product/attribute`) + +- `GET /mall/product/attribute/list` - 查询属性列表(分页) +- `GET /mall/product/attribute/{id}` - 获取属性详情 +- `GET /mall/product/attribute/template/{templateId}` - 根据模板ID查询属性列表 +- `POST /mall/product/attribute` - 新增属性 +- `PUT /mall/product/attribute` - 修改属性 +- `DELETE /mall/product/attribute/{ids}` - 删除属性 +- `POST /mall/product/attribute/export` - 导出属性列表 + +#### 属性值管理 (`/mall/product/attribute/value`) + +- `GET /mall/product/attribute/value/list` - 查询属性值列表(分页) +- `GET /mall/product/attribute/value/{id}` - 获取属性值详情 +- `GET /mall/product/attribute/value/attribute/{attributeId}` - 根据属性ID查询属性值列表 +- `POST /mall/product/attribute/value` - 新增属性值 +- `PUT /mall/product/attribute/value` - 修改属性值 +- `DELETE /mall/product/attribute/value/{ids}` - 删除属性值 +- `POST /mall/product/attribute/value/export` - 导出属性值列表 + +#### 商品属性关联管理 (`/mall/product/attribute/product`) + +- `GET /mall/product/attribute/product/{id}` - 获取商品属性关联详情 +- `GET /mall/product/attribute/product/product/{productId}` - 根据商品ID查询商品属性关联列表 +- `POST /mall/product/attribute/product` - 新增商品属性关联 +- `POST /mall/product/attribute/product/batch` - 批量新增商品属性关联 +- `POST /mall/product/attribute/product/product/{productId}` - 保存商品属性关联(先删除旧的,再插入新的) +- `PUT /mall/product/attribute/product` - 修改商品属性关联 +- `DELETE /mall/product/attribute/product/{ids}` - 删除商品属性关联 +- `DELETE /mall/product/attribute/product/product/{productId}` - 根据商品ID删除商品属性关联 + +### 5. 商品属性管理 ✅ + +- ✅ 属性模板管理 + - ✅ 属性模板CRUD操作 + - ✅ 属性模板绑定分类(可选) + - ✅ 属性模板导出功能 +- ✅ 属性管理 + - ✅ 属性CRUD操作 + - ✅ 属性类型管理(文本、数字、日期、枚举) + - ✅ 属性绑定模板(可选) + - ✅ 属性必填设置 + - ✅ 属性导出功能 +- ✅ 属性值管理 + - ✅ 属性值CRUD操作 + - ✅ 属性值绑定属性 + - ✅ 属性值排序 + - ✅ 属性值导出功能 +- ✅ 商品属性关联 + - ✅ 商品关联属性值 + - ✅ 批量保存商品属性 + - ✅ 根据商品ID查询属性 + - ✅ 支持枚举类型属性值选择 + - ✅ 支持文本/数字/日期类型自定义值 + +## 待实现功能 + - [ ] 规格模板定义 + - [ ] 规格值管理 + - [ ] 规格模板与商品关联 +- [ ] 商品搜索功能 + - [ ] 全文搜索(集成Elasticsearch或类似方案) + - [ ] 商品筛选(按分类、品牌、价格等) diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/pom.xml b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/pom.xml new file mode 100644 index 0000000..a2347f2 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/pom.xml @@ -0,0 +1,61 @@ + + + + org.dromara + ruoyi-mall + ${revision} + + 4.0.0 + + ruoyi-mall-product + + + ruoyi-mall-product 商品中心模块 + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-log + + + + org.dromara + ruoyi-common-redis + + + org.dromara + ruoyi-common-excel + + + + + diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/config/MallProductAutoConfiguration.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/config/MallProductAutoConfiguration.java new file mode 100644 index 0000000..36601e4 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/config/MallProductAutoConfiguration.java @@ -0,0 +1,23 @@ +package org.dromara.mall.product.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * 商品中心自动配置类 + * 主要用于扫描商品中心模块的组件 + * + * @author pengles + */ +@Slf4j +@Configuration +@ComponentScan(basePackages = "org.dromara.mall.product") +@ConditionalOnProperty(prefix = "ruoyi.mall.product", name = "enabled", havingValue = "true", matchIfMissing = true) +public class MallProductAutoConfiguration { + + public MallProductAutoConfiguration() { + log.info("商品中心模块已启用"); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeController.java new file mode 100644 index 0000000..3b1276a --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeController.java @@ -0,0 +1,122 @@ +package org.dromara.mall.product.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.product.domain.bo.MallAttributeBo; +import org.dromara.mall.product.domain.vo.MallAttributeVo; +import org.dromara.mall.product.service.MallAttributeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 属性管理 + * + * @author pengles + */ +@Tag(name = "管理端-属性管理", description = "属性管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/product/attribute") +public class AdminMallAttributeController extends BaseController { + + private final MallAttributeService mallAttributeService; + + /** + * 查询属性列表 + */ + @Operation(summary = "查询属性列表", description = "分页查询属性列表") + @SaCheckPermission("mall:product:attribute:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "属性查询条件") MallAttributeBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallAttributeService.queryPageList(bo, pageQuery); + } + + /** + * 导出属性列表 + */ + @Operation(summary = "导出属性列表", description = "导出属性到Excel") + @SaCheckPermission("mall:product:attribute:export") + @Log(title = "属性", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "属性查询条件") MallAttributeBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallAttributeService.queryList(bo); + ExcelUtil.exportExcel(list, "属性", MallAttributeVo.class, response); + } + + /** + * 获取属性详细信息 + */ + @Operation(summary = "获取属性详情", description = "根据属性ID获取详细信息") + @SaCheckPermission("mall:product:attribute:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "属性ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallAttributeService.queryById(id)); + } + + /** + * 根据模板ID查询属性列表 + */ + @Operation(summary = "根据模板ID查询属性列表", description = "根据属性模板ID查询属性列表") + @SaCheckPermission("mall:product:attribute:list") + @GetMapping("/template/{templateId}") + public R> listByTemplateId(@Parameter(description = "模板ID", required = true) + @NotNull(message = "模板ID不能为空") + @PathVariable Long templateId) { + return R.ok(mallAttributeService.queryListByTemplateId(templateId)); + } + + /** + * 新增属性 + */ + @Operation(summary = "新增属性", description = "新增属性") + @SaCheckPermission("mall:product:attribute:add") + @Log(title = "属性", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "属性信息") @Validated @RequestBody MallAttributeBo bo) { + return toAjax(mallAttributeService.insertByBo(bo)); + } + + /** + * 修改属性 + */ + @Operation(summary = "修改属性", description = "修改属性") + @SaCheckPermission("mall:product:attribute:edit") + @Log(title = "属性", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "属性信息") @Validated @RequestBody MallAttributeBo bo) { + return toAjax(mallAttributeService.updateByBo(bo)); + } + + /** + * 删除属性 + */ + @Operation(summary = "删除属性", description = "批量删除属性") + @SaCheckPermission("mall:product:attribute:remove") + @Log(title = "属性", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "属性ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallAttributeService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeTemplateController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeTemplateController.java new file mode 100644 index 0000000..2260b36 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeTemplateController.java @@ -0,0 +1,110 @@ +package org.dromara.mall.product.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.product.domain.bo.MallAttributeTemplateBo; +import org.dromara.mall.product.domain.vo.MallAttributeTemplateVo; +import org.dromara.mall.product.service.MallAttributeTemplateService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 属性模板管理 + * + * @author pengles + */ +@Tag(name = "管理端-属性模板管理", description = "属性模板管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/product/attribute/template") +public class AdminMallAttributeTemplateController extends BaseController { + + private final MallAttributeTemplateService mallAttributeTemplateService; + + /** + * 查询属性模板列表 + */ + @Operation(summary = "查询属性模板列表", description = "分页查询属性模板列表") + @SaCheckPermission("mall:product:attribute:template:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "属性模板查询条件") MallAttributeTemplateBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallAttributeTemplateService.queryPageList(bo, pageQuery); + } + + /** + * 导出属性模板列表 + */ + @Operation(summary = "导出属性模板列表", description = "导出属性模板到Excel") + @SaCheckPermission("mall:product:attribute:template:export") + @Log(title = "属性模板", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "属性模板查询条件") MallAttributeTemplateBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallAttributeTemplateService.queryList(bo); + ExcelUtil.exportExcel(list, "属性模板", MallAttributeTemplateVo.class, response); + } + + /** + * 获取属性模板详细信息 + */ + @Operation(summary = "获取属性模板详情", description = "根据属性模板ID获取详细信息") + @SaCheckPermission("mall:product:attribute:template:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "属性模板ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallAttributeTemplateService.queryById(id)); + } + + /** + * 新增属性模板 + */ + @Operation(summary = "新增属性模板", description = "新增属性模板") + @SaCheckPermission("mall:product:attribute:template:add") + @Log(title = "属性模板", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "属性模板信息") @Validated @RequestBody MallAttributeTemplateBo bo) { + return toAjax(mallAttributeTemplateService.insertByBo(bo)); + } + + /** + * 修改属性模板 + */ + @Operation(summary = "修改属性模板", description = "修改属性模板") + @SaCheckPermission("mall:product:attribute:template:edit") + @Log(title = "属性模板", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "属性模板信息") @Validated @RequestBody MallAttributeTemplateBo bo) { + return toAjax(mallAttributeTemplateService.updateByBo(bo)); + } + + /** + * 删除属性模板 + */ + @Operation(summary = "删除属性模板", description = "批量删除属性模板") + @SaCheckPermission("mall:product:attribute:template:remove") + @Log(title = "属性模板", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "属性模板ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallAttributeTemplateService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeValueController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeValueController.java new file mode 100644 index 0000000..5b7dfc4 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallAttributeValueController.java @@ -0,0 +1,122 @@ +package org.dromara.mall.product.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.product.domain.bo.MallAttributeValueBo; +import org.dromara.mall.product.domain.vo.MallAttributeValueVo; +import org.dromara.mall.product.service.MallAttributeValueService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 属性值管理 + * + * @author pengles + */ +@Tag(name = "管理端-属性值管理", description = "属性值管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/product/attribute/value") +public class AdminMallAttributeValueController extends BaseController { + + private final MallAttributeValueService mallAttributeValueService; + + /** + * 查询属性值列表 + */ + @Operation(summary = "查询属性值列表", description = "分页查询属性值列表") + @SaCheckPermission("mall:product:attribute:value:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "属性值查询条件") MallAttributeValueBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallAttributeValueService.queryPageList(bo, pageQuery); + } + + /** + * 导出属性值列表 + */ + @Operation(summary = "导出属性值列表", description = "导出属性值到Excel") + @SaCheckPermission("mall:product:attribute:value:export") + @Log(title = "属性值", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "属性值查询条件") MallAttributeValueBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallAttributeValueService.queryList(bo); + ExcelUtil.exportExcel(list, "属性值", MallAttributeValueVo.class, response); + } + + /** + * 获取属性值详细信息 + */ + @Operation(summary = "获取属性值详情", description = "根据属性值ID获取详细信息") + @SaCheckPermission("mall:product:attribute:value:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "属性值ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallAttributeValueService.queryById(id)); + } + + /** + * 根据属性ID查询属性值列表 + */ + @Operation(summary = "根据属性ID查询属性值列表", description = "根据属性ID查询属性值列表") + @SaCheckPermission("mall:product:attribute:value:list") + @GetMapping("/attribute/{attributeId}") + public R> listByAttributeId(@Parameter(description = "属性ID", required = true) + @NotNull(message = "属性ID不能为空") + @PathVariable Long attributeId) { + return R.ok(mallAttributeValueService.queryListByAttributeId(attributeId)); + } + + /** + * 新增属性值 + */ + @Operation(summary = "新增属性值", description = "新增属性值") + @SaCheckPermission("mall:product:attribute:value:add") + @Log(title = "属性值", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "属性值信息") @Validated @RequestBody MallAttributeValueBo bo) { + return toAjax(mallAttributeValueService.insertByBo(bo)); + } + + /** + * 修改属性值 + */ + @Operation(summary = "修改属性值", description = "修改属性值") + @SaCheckPermission("mall:product:attribute:value:edit") + @Log(title = "属性值", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "属性值信息") @Validated @RequestBody MallAttributeValueBo bo) { + return toAjax(mallAttributeValueService.updateByBo(bo)); + } + + /** + * 删除属性值 + */ + @Operation(summary = "删除属性值", description = "批量删除属性值") + @SaCheckPermission("mall:product:attribute:value:remove") + @Log(title = "属性值", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "属性值ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallAttributeValueService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallBrandController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallBrandController.java new file mode 100644 index 0000000..6430f0d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallBrandController.java @@ -0,0 +1,110 @@ +package org.dromara.mall.product.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.product.domain.bo.MallBrandBo; +import org.dromara.mall.product.domain.vo.MallBrandVo; +import org.dromara.mall.product.service.MallBrandService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 品牌管理 + * + * @author pengles + */ +@Tag(name = "管理端-品牌管理", description = "品牌管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/product/brand") +public class AdminMallBrandController extends BaseController { + + private final MallBrandService mallBrandService; + + /** + * 查询品牌列表 + */ + @Operation(summary = "查询品牌列表", description = "分页查询品牌列表") + @SaCheckPermission("mall:product:brand:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "品牌查询条件") MallBrandBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallBrandService.queryPageList(bo, pageQuery); + } + + /** + * 导出品牌列表 + */ + @Operation(summary = "导出品牌列表", description = "导出品牌到Excel") + @SaCheckPermission("mall:product:brand:export") + @Log(title = "品牌", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "品牌查询条件") MallBrandBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallBrandService.queryList(bo); + ExcelUtil.exportExcel(list, "品牌", MallBrandVo.class, response); + } + + /** + * 获取品牌详细信息 + */ + @Operation(summary = "获取品牌详情", description = "根据品牌ID获取详细信息") + @SaCheckPermission("mall:product:brand:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "品牌ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallBrandService.queryById(id)); + } + + /** + * 新增品牌 + */ + @Operation(summary = "新增品牌", description = "新增品牌") + @SaCheckPermission("mall:product:brand:add") + @Log(title = "品牌", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "品牌信息") @Validated @RequestBody MallBrandBo bo) { + return toAjax(mallBrandService.insertByBo(bo)); + } + + /** + * 修改品牌 + */ + @Operation(summary = "修改品牌", description = "修改品牌") + @SaCheckPermission("mall:product:brand:edit") + @Log(title = "品牌", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "品牌信息") @Validated @RequestBody MallBrandBo bo) { + return toAjax(mallBrandService.updateByBo(bo)); + } + + /** + * 删除品牌 + */ + @Operation(summary = "删除品牌", description = "删除品牌") + @SaCheckPermission("mall:product:brand:remove") + @Log(title = "品牌", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "品牌ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallBrandService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallCategoryController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallCategoryController.java new file mode 100644 index 0000000..a42519a --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallCategoryController.java @@ -0,0 +1,121 @@ +package org.dromara.mall.product.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.product.domain.bo.MallCategoryBo; +import org.dromara.mall.product.domain.vo.MallCategoryVo; +import org.dromara.mall.product.service.MallCategoryService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 商品分类管理 + * + * @author pengles + */ +@Tag(name = "管理端-商品分类管理", description = "商品分类管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/product/category") +public class AdminMallCategoryController extends BaseController { + + private final MallCategoryService mallCategoryService; + + /** + * 查询商品分类列表 + */ + @Operation(summary = "查询商品分类列表", description = "分页查询商品分类列表") + @SaCheckPermission("mall:product:category:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "分类查询条件") MallCategoryBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallCategoryService.queryPageList(bo, pageQuery); + } + + /** + * 查询商品分类树形列表 + */ + @Operation(summary = "查询商品分类树形列表", description = "查询商品分类树形结构") + @SaCheckPermission("mall:product:category:list") + @GetMapping("/tree") + public R> tree() { + List list = mallCategoryService.queryTreeList(); + return R.ok(list); + } + + /** + * 导出商品分类列表 + */ + @Operation(summary = "导出商品分类列表", description = "导出商品分类到Excel") + @SaCheckPermission("mall:product:category:export") + @Log(title = "商品分类", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "分类查询条件") MallCategoryBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallCategoryService.queryList(bo); + ExcelUtil.exportExcel(list, "商品分类", MallCategoryVo.class, response); + } + + /** + * 获取商品分类详细信息 + */ + @Operation(summary = "获取商品分类详情", description = "根据分类ID获取详细信息") + @SaCheckPermission("mall:product:category:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "分类ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallCategoryService.queryById(id)); + } + + /** + * 新增商品分类 + */ + @Operation(summary = "新增商品分类", description = "新增商品分类") + @SaCheckPermission("mall:product:category:add") + @Log(title = "商品分类", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "分类信息") @Validated @RequestBody MallCategoryBo bo) { + return toAjax(mallCategoryService.insertByBo(bo)); + } + + /** + * 修改商品分类 + */ + @Operation(summary = "修改商品分类", description = "修改商品分类") + @SaCheckPermission("mall:product:category:edit") + @Log(title = "商品分类", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "分类信息") @Validated @RequestBody MallCategoryBo bo) { + return toAjax(mallCategoryService.updateByBo(bo)); + } + + /** + * 删除商品分类 + */ + @Operation(summary = "删除商品分类", description = "删除商品分类") + @SaCheckPermission("mall:product:category:remove") + @Log(title = "商品分类", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "分类ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallCategoryService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallProductAttributeController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallProductAttributeController.java new file mode 100644 index 0000000..a2e348f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallProductAttributeController.java @@ -0,0 +1,132 @@ +package org.dromara.mall.product.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.product.domain.bo.MallProductAttributeBo; +import org.dromara.mall.product.domain.vo.MallProductAttributeVo; +import org.dromara.mall.product.service.MallProductAttributeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 商品属性关联管理 + * + * @author pengles + */ +@Tag(name = "管理端-商品属性关联管理", description = "商品属性关联管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/product/attribute/product") +public class AdminMallProductAttributeController extends BaseController { + + private final MallProductAttributeService mallProductAttributeService; + + /** + * 获取商品属性关联详细信息 + */ + @Operation(summary = "获取商品属性关联详情", description = "根据商品属性关联ID获取详细信息") + @SaCheckPermission("mall:product:attribute:product:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "商品属性关联ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallProductAttributeService.queryById(id)); + } + + /** + * 根据商品ID查询商品属性关联列表 + */ + @Operation(summary = "根据商品ID查询商品属性关联列表", description = "根据商品ID查询商品属性关联列表") + @SaCheckPermission("mall:product:attribute:product:list") + @GetMapping("/product/{productId}") + public R> listByProductId(@Parameter(description = "商品ID", required = true) + @NotNull(message = "商品ID不能为空") + @PathVariable Long productId) { + return R.ok(mallProductAttributeService.queryListByProductId(productId)); + } + + /** + * 新增商品属性关联 + */ + @Operation(summary = "新增商品属性关联", description = "新增商品属性关联") + @SaCheckPermission("mall:product:attribute:product:add") + @Log(title = "商品属性关联", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "商品属性关联信息") @Validated @RequestBody MallProductAttributeBo bo) { + return toAjax(mallProductAttributeService.insertByBo(bo)); + } + + /** + * 批量新增商品属性关联 + */ + @Operation(summary = "批量新增商品属性关联", description = "批量新增商品属性关联") + @SaCheckPermission("mall:product:attribute:product:add") + @Log(title = "商品属性关联", businessType = BusinessType.INSERT) + @PostMapping("/batch") + public R addBatch(@Parameter(description = "商品属性关联信息列表") @Validated @RequestBody List list) { + return toAjax(mallProductAttributeService.insertBatch(list)); + } + + /** + * 保存商品属性关联(先删除旧的,再插入新的) + */ + @Operation(summary = "保存商品属性关联", description = "保存商品属性关联,先删除旧的,再插入新的") + @SaCheckPermission("mall:product:attribute:product:edit") + @Log(title = "商品属性关联", businessType = BusinessType.UPDATE) + @PostMapping("/product/{productId}") + public R saveProductAttributes(@Parameter(description = "商品ID", required = true) + @NotNull(message = "商品ID不能为空") + @PathVariable Long productId, + @Parameter(description = "商品属性关联信息列表") @RequestBody List list) { + return toAjax(mallProductAttributeService.saveProductAttributes(productId, list)); + } + + /** + * 修改商品属性关联 + */ + @Operation(summary = "修改商品属性关联", description = "修改商品属性关联") + @SaCheckPermission("mall:product:attribute:product:edit") + @Log(title = "商品属性关联", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "商品属性关联信息") @Validated @RequestBody MallProductAttributeBo bo) { + return toAjax(mallProductAttributeService.updateByBo(bo)); + } + + /** + * 删除商品属性关联 + */ + @Operation(summary = "删除商品属性关联", description = "批量删除商品属性关联") + @SaCheckPermission("mall:product:attribute:product:remove") + @Log(title = "商品属性关联", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "商品属性关联ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallProductAttributeService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 根据商品ID删除商品属性关联 + */ + @Operation(summary = "根据商品ID删除商品属性关联", description = "根据商品ID删除商品属性关联") + @SaCheckPermission("mall:product:attribute:product:remove") + @Log(title = "商品属性关联", businessType = BusinessType.DELETE) + @DeleteMapping("/product/{productId}") + public R removeByProductId(@Parameter(description = "商品ID", required = true) + @NotNull(message = "商品ID不能为空") + @PathVariable Long productId) { + return toAjax(mallProductAttributeService.deleteByProductId(productId)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallProductController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallProductController.java new file mode 100644 index 0000000..030dd75 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/admin/AdminMallProductController.java @@ -0,0 +1,94 @@ +package org.dromara.mall.product.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.product.domain.bo.MallProductBo; +import org.dromara.mall.product.domain.vo.MallProductVo; +import org.dromara.mall.product.service.MallProductService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; + +/** + * 商品管理Controller + * + * @author pengles + */ +@Tag(name = "管理端-商品管理", description = "商品管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/product") +public class AdminMallProductController extends BaseController { + + private final MallProductService mallProductService; + + /** + * 查询商品列表 + */ + @Operation(summary = "查询商品列表", description = "分页查询商品列表") + @SaCheckPermission("mall:product:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "商品查询条件") MallProductBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallProductService.queryPageList(bo, pageQuery); + } + + /** + * 获取商品详细信息 + */ + @Operation(summary = "获取商品详细信息", description = "根据商品ID获取商品详细信息") + @SaCheckPermission("mall:product:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "商品ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallProductService.queryById(id)); + } + + /** + * 新增商品 + */ + @Operation(summary = "新增商品", description = "新增商品") + @SaCheckPermission("mall:product:add") + @Log(title = "商品管理", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "商品信息") @Validated @RequestBody MallProductBo bo) { + return toAjax(mallProductService.insertByBo(bo)); + } + + /** + * 修改商品 + */ + @Operation(summary = "修改商品", description = "修改商品") + @SaCheckPermission("mall:product:edit") + @Log(title = "商品管理", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "商品信息") @Validated @RequestBody MallProductBo bo) { + return toAjax(mallProductService.updateByBo(bo)); + } + + /** + * 删除商品 + */ + @Operation(summary = "删除商品", description = "批量删除商品") + @SaCheckPermission("mall:product:remove") + @Log(title = "商品管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "商品ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallProductService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallBrandController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallBrandController.java new file mode 100644 index 0000000..0754831 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallBrandController.java @@ -0,0 +1,40 @@ +package org.dromara.mall.product.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.mall.product.domain.bo.MallBrandBo; +import org.dromara.mall.product.domain.vo.MallBrandVo; +import org.dromara.mall.product.service.MallBrandService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 品牌(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-品牌", description = "品牌查询接口") +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/product/brand") +public class AppMallBrandController { + + private final MallBrandService mallBrandService; + + /** + * 查询品牌列表(移动端) + */ + @Operation(summary = "查询品牌列表", description = "查询启用的品牌列表") + @GetMapping("/list") + public R> list() { + MallBrandBo bo = new MallBrandBo(); + bo.setStatus(0); // 只查询启用的品牌 + List list = mallBrandService.queryList(bo); + return R.ok(list); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallCategoryController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallCategoryController.java new file mode 100644 index 0000000..9f14bcd --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallCategoryController.java @@ -0,0 +1,37 @@ +package org.dromara.mall.product.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.mall.product.domain.vo.MallCategoryVo; +import org.dromara.mall.product.service.MallCategoryService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 商品分类(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-商品分类", description = "商品分类查询接口") +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/product/category") +public class AppMallCategoryController { + + private final MallCategoryService mallCategoryService; + + /** + * 查询商品分类树形列表(移动端) + */ + @Operation(summary = "查询商品分类树形列表", description = "查询启用的商品分类树形结构") + @GetMapping("/tree") + public R> tree() { + List list = mallCategoryService.queryTreeList(); + return R.ok(list); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallProductController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallProductController.java new file mode 100644 index 0000000..7e3a313 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/controller/app/AppMallProductController.java @@ -0,0 +1,82 @@ +package org.dromara.mall.product.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.bo.MallProductBo; +import org.dromara.mall.product.domain.vo.MallProductVo; +import org.dromara.mall.product.domain.vo.MallSkuVo; +import org.dromara.mall.product.service.MallProductService; +import org.dromara.mall.product.service.MallSkuService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 商品(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-商品", description = "商品查询接口") +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/product") +public class AppMallProductController { + + private final MallProductService mallProductService; + private final MallSkuService mallSkuService; + + /** + * 查询商品列表(移动端) + */ + @Operation(summary = "查询商品列表", description = "查询上架的商品列表") + @GetMapping("/list") + public R> list( + @Parameter(description = "查询条件") MallProductBo bo, + @Parameter(description = "分页参数") @Validated PageQuery pageQuery) { + // 移动端只查询上架的商品 + if (bo == null) { + bo = new MallProductBo(); + } + bo.setStatus(0); // 0表示上架 + TableDataInfo result = mallProductService.queryPageList(bo, pageQuery); + return R.ok(result); + } + + /** + * 查询商品详情(移动端) + */ + @Operation(summary = "查询商品详情", description = "根据商品ID查询商品详情") + @GetMapping("/{id}") + public R getInfo( + @Parameter(description = "商品ID", required = true) @PathVariable Long id) { + MallProductVo product = mallProductService.queryById(id); + if (product == null) { + return R.fail("商品不存在"); + } + // 只有上架的商品才能查看 + if (product.getStatus() != null && product.getStatus() != 0) { + return R.fail("商品已下架"); + } + return R.ok(product); + } + + /** + * 查询商品SKU列表(移动端) + */ + @Operation(summary = "查询商品SKU列表", description = "根据商品ID查询SKU列表") + @GetMapping("/{productId}/skus") + public R> getSkus( + @Parameter(description = "商品ID", required = true) @PathVariable Long productId) { + List skus = mallSkuService.queryListByProductId(productId); + return R.ok(skus); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttribute.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttribute.java new file mode 100644 index 0000000..9f0ea88 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttribute.java @@ -0,0 +1,92 @@ +package org.dromara.mall.product.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 属性对象 mall_attribute + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_attribute") +public class MallAttribute implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 属性ID + */ + @TableId + private Long id; + + /** + * 模板ID(可选,绑定到模板) + */ + private Long templateId; + + /** + * 属性名称 + */ + private String name; + + /** + * 属性类型(0文本 1数字 2日期 3枚举) + */ + private Integer type; + + /** + * 是否必填(0否 1是) + */ + private Integer required; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttributeTemplate.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttributeTemplate.java new file mode 100644 index 0000000..9209bb7 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttributeTemplate.java @@ -0,0 +1,82 @@ +package org.dromara.mall.product.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 属性模板对象 mall_attribute_template + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_attribute_template") +public class MallAttributeTemplate implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 模板ID + */ + @TableId + private Long id; + + /** + * 模板名称 + */ + private String name; + + /** + * 分类ID(可选,绑定到分类) + */ + private Long categoryId; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttributeValue.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttributeValue.java new file mode 100644 index 0000000..4946721 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallAttributeValue.java @@ -0,0 +1,77 @@ +package org.dromara.mall.product.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 属性值对象 mall_attribute_value + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_attribute_value") +public class MallAttributeValue implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 属性值ID + */ + @TableId + private Long id; + + /** + * 属性ID + */ + private Long attributeId; + + /** + * 属性值 + */ + private String value; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallBrand.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallBrand.java new file mode 100644 index 0000000..8ab968d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallBrand.java @@ -0,0 +1,82 @@ +package org.dromara.mall.product.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 品牌对象 mall_brand + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_brand") +public class MallBrand implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 品牌ID + */ + @TableId + private Long id; + + /** + * 品牌名称 + */ + private String name; + + /** + * 品牌Logo + */ + private String logo; + + /** + * 品牌描述 + */ + private String description; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallCategory.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallCategory.java new file mode 100644 index 0000000..839475f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallCategory.java @@ -0,0 +1,87 @@ +package org.dromara.mall.product.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 商品分类对象 mall_category + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_category") +public class MallCategory implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 分类ID + */ + @TableId + private Long id; + + /** + * 分类名称 + */ + private String name; + + /** + * 父分类ID(0表示顶级) + */ + private Long parentId; + + /** + * 分类级别(1一级 2二级 3三级) + */ + private Integer level; + + /** + * 分类图标 + */ + private String icon; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallProduct.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallProduct.java new file mode 100644 index 0000000..a7f4263 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallProduct.java @@ -0,0 +1,107 @@ +package org.dromara.mall.product.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 商品对象 mall_product + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_product") +public class MallProduct { + + /** + * 商品ID + */ + @TableId + private Long id; + + /** + * 商品名称 + */ + private String name; + + /** + * 分类ID + */ + private Long categoryId; + + /** + * 品牌ID + */ + private Long brandId; + + /** + * 封面图片 + */ + private String coverUrl; + + /** + * 商品图片(JSON数组) + */ + private String images; + + /** + * 商品描述 + */ + private String description; + + /** + * 商品详情 + */ + private String detail; + + /** + * 状态(0上架 1下架) + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 销量 + */ + private Integer salesCount; + + /** + * 浏览量 + */ + private Integer viewCount; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallProductAttribute.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallProductAttribute.java new file mode 100644 index 0000000..f0e9422 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallProductAttribute.java @@ -0,0 +1,77 @@ +package org.dromara.mall.product.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 商品属性关联对象 mall_product_attribute + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_product_attribute") +public class MallProductAttribute implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * ID + */ + @TableId + private Long id; + + /** + * 商品ID + */ + private Long productId; + + /** + * 属性ID + */ + private Long attributeId; + + /** + * 属性值ID(当属性类型为枚举时使用) + */ + private Long attributeValueId; + + /** + * 属性值(当属性类型为文本/数字/日期时使用,或自定义值) + */ + private String value; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallSku.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallSku.java new file mode 100644 index 0000000..52e591a --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/MallSku.java @@ -0,0 +1,97 @@ +package org.dromara.mall.product.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * SKU对象 mall_sku + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_sku") +public class MallSku { + + /** + * SKU ID + */ + @TableId + private Long id; + + /** + * 商品ID + */ + private Long productId; + + /** + * SKU编码 + */ + private String skuCode; + + /** + * SKU名称 + */ + private String name; + + /** + * 价格(分) + */ + private Integer price; + + /** + * 原价(分) + */ + private Integer originalPrice; + + /** + * 库存 + */ + private Integer stock; + + /** + * SKU图片 + */ + private String image; + + /** + * 规格属性(JSON) + */ + private String specs; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeBo.java new file mode 100644 index 0000000..cbe832f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeBo.java @@ -0,0 +1,64 @@ +package org.dromara.mall.product.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.product.domain.MallAttribute; + +/** + * 属性业务对象 mall_attribute + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallAttribute.class, reverseConvertGenerate = false) +public class MallAttributeBo extends BaseEntity { + + /** + * 属性ID + */ + private Long id; + + /** + * 模板ID(可选,绑定到模板) + */ + private Long templateId; + + /** + * 属性名称 + */ + @NotBlank(message = "属性名称不能为空") + private String name; + + /** + * 属性类型(0文本 1数字 2日期 3枚举) + */ + @NotNull(message = "属性类型不能为空") + private Integer type; + + /** + * 是否必填(0否 1是) + */ + private Integer required; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + @NotNull(message = "状态不能为空") + private Integer status; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeTemplateBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeTemplateBo.java new file mode 100644 index 0000000..4ce7edd --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeTemplateBo.java @@ -0,0 +1,53 @@ +package org.dromara.mall.product.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.product.domain.MallAttributeTemplate; + +/** + * 属性模板业务对象 mall_attribute_template + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallAttributeTemplate.class, reverseConvertGenerate = false) +public class MallAttributeTemplateBo extends BaseEntity { + + /** + * 模板ID + */ + private Long id; + + /** + * 模板名称 + */ + @NotBlank(message = "模板名称不能为空") + private String name; + + /** + * 分类ID(可选,绑定到分类) + */ + private Long categoryId; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + @NotNull(message = "状态不能为空") + private Integer status; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeValueBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeValueBo.java new file mode 100644 index 0000000..a2da404 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallAttributeValueBo.java @@ -0,0 +1,49 @@ +package org.dromara.mall.product.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.product.domain.MallAttributeValue; + +/** + * 属性值业务对象 mall_attribute_value + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallAttributeValue.class, reverseConvertGenerate = false) +public class MallAttributeValueBo extends BaseEntity { + + /** + * 属性值ID + */ + private Long id; + + /** + * 属性ID + */ + @NotNull(message = "属性ID不能为空") + private Long attributeId; + + /** + * 属性值 + */ + @NotBlank(message = "属性值不能为空") + private String value; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + @NotNull(message = "状态不能为空") + private Integer status; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallBrandBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallBrandBo.java new file mode 100644 index 0000000..570d3cc --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallBrandBo.java @@ -0,0 +1,53 @@ +package org.dromara.mall.product.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.product.domain.MallBrand; + +/** + * 品牌业务对象 mall_brand + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallBrand.class, reverseConvertGenerate = false) +public class MallBrandBo extends BaseEntity { + + /** + * 品牌ID + */ + private Long id; + + /** + * 品牌名称 + */ + @NotBlank(message = "品牌名称不能为空") + private String name; + + /** + * 品牌Logo + */ + private String logo; + + /** + * 品牌描述 + */ + private String description; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + @NotNull(message = "状态不能为空") + private Integer status; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallCategoryBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallCategoryBo.java new file mode 100644 index 0000000..7ec42ca --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallCategoryBo.java @@ -0,0 +1,58 @@ +package org.dromara.mall.product.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.product.domain.MallCategory; + +/** + * 商品分类业务对象 mall_category + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallCategory.class, reverseConvertGenerate = false) +public class MallCategoryBo extends BaseEntity { + + /** + * 分类ID + */ + private Long id; + + /** + * 分类名称 + */ + @NotBlank(message = "分类名称不能为空") + private String name; + + /** + * 父分类ID(0表示顶级) + */ + private Long parentId; + + /** + * 分类级别(1一级 2二级 3三级) + */ + private Integer level; + + /** + * 分类图标 + */ + private String icon; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + @NotNull(message = "状态不能为空") + private Integer status; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallProductAttributeBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallProductAttributeBo.java new file mode 100644 index 0000000..d2b1061 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallProductAttributeBo.java @@ -0,0 +1,47 @@ +package org.dromara.mall.product.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.product.domain.MallProductAttribute; + +/** + * 商品属性关联业务对象 mall_product_attribute + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallProductAttribute.class, reverseConvertGenerate = false) +public class MallProductAttributeBo extends BaseEntity { + + /** + * ID + */ + private Long id; + + /** + * 商品ID + */ + @NotNull(message = "商品ID不能为空") + private Long productId; + + /** + * 属性ID + */ + @NotNull(message = "属性ID不能为空") + private Long attributeId; + + /** + * 属性值ID(当属性类型为枚举时使用) + */ + private Long attributeValueId; + + /** + * 属性值(当属性类型为文本/数字/日期时使用,或自定义值) + */ + private String value; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallProductBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallProductBo.java new file mode 100644 index 0000000..85c49e8 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallProductBo.java @@ -0,0 +1,82 @@ +package org.dromara.mall.product.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.product.domain.MallProduct; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 商品业务对象 mall_product + * + * @author pengles + */ +@Data +@AutoMapper(target = MallProduct.class, reverseConvertGenerate = false) +public class MallProductBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 商品ID + */ + private Long id; + + /** + * 商品名称 + */ + private String name; + + /** + * 分类ID + */ + private Long categoryId; + + /** + * 品牌ID + */ + private Long brandId; + + /** + * 封面图片 + */ + private String coverUrl; + + /** + * 商品图片(JSON数组) + */ + private String images; + + /** + * 商品描述 + */ + private String description; + + /** + * 商品详情 + */ + private String detail; + + /** + * 状态(0上架 1下架) + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 销量 + */ + private Integer salesCount; + + /** + * 浏览量 + */ + private Integer viewCount; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallSkuBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallSkuBo.java new file mode 100644 index 0000000..ba82196 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/bo/MallSkuBo.java @@ -0,0 +1,72 @@ +package org.dromara.mall.product.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.product.domain.MallSku; + +import java.io.Serial; +import java.io.Serializable; + +/** + * SKU业务对象 mall_sku + * + * @author pengles + */ +@Data +@AutoMapper(target = MallSku.class, reverseConvertGenerate = false) +public class MallSkuBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * SKU ID + */ + private Long id; + + /** + * 商品ID + */ + private Long productId; + + /** + * SKU编码 + */ + private String skuCode; + + /** + * SKU名称 + */ + private String name; + + /** + * 价格(分) + */ + private Integer price; + + /** + * 原价(分) + */ + private Integer originalPrice; + + /** + * 库存 + */ + private Integer stock; + + /** + * SKU图片 + */ + private String image; + + /** + * 规格属性(JSON) + */ + private String specs; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeTemplateVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeTemplateVo.java new file mode 100644 index 0000000..5329c56 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeTemplateVo.java @@ -0,0 +1,74 @@ +package org.dromara.mall.product.domain.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.mall.product.domain.MallAttributeTemplate; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 属性模板视图对象 mall_attribute_template + * + * @author pengles + */ +@Data +@AutoMapper(target = MallAttributeTemplate.class) +public class MallAttributeTemplateVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 模板ID + */ + @ExcelProperty(value = "模板ID") + private Long id; + + /** + * 模板名称 + */ + @ExcelProperty(value = "模板名称") + private String name; + + /** + * 分类ID(可选,绑定到分类) + */ + @ExcelProperty(value = "分类ID") + private Long categoryId; + + /** + * 排序 + */ + @ExcelProperty(value = "排序") + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + @ExcelProperty(value = "状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "0=启用,1=禁用") + private Integer status; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeValueVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeValueVo.java new file mode 100644 index 0000000..fbc7374 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeValueVo.java @@ -0,0 +1,68 @@ +package org.dromara.mall.product.domain.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.mall.product.domain.MallAttributeValue; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 属性值视图对象 mall_attribute_value + * + * @author pengles + */ +@Data +@AutoMapper(target = MallAttributeValue.class) +public class MallAttributeValueVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 属性值ID + */ + @ExcelProperty(value = "属性值ID") + private Long id; + + /** + * 属性ID + */ + @ExcelProperty(value = "属性ID") + private Long attributeId; + + /** + * 属性值 + */ + @ExcelProperty(value = "属性值") + private String value; + + /** + * 排序 + */ + @ExcelProperty(value = "排序") + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + @ExcelProperty(value = "状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "0=启用,1=禁用") + private Integer status; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeVo.java new file mode 100644 index 0000000..2b91151 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallAttributeVo.java @@ -0,0 +1,88 @@ +package org.dromara.mall.product.domain.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.mall.product.domain.MallAttribute; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 属性视图对象 mall_attribute + * + * @author pengles + */ +@Data +@AutoMapper(target = MallAttribute.class) +public class MallAttributeVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 属性ID + */ + @ExcelProperty(value = "属性ID") + private Long id; + + /** + * 模板ID(可选,绑定到模板) + */ + @ExcelProperty(value = "模板ID") + private Long templateId; + + /** + * 属性名称 + */ + @ExcelProperty(value = "属性名称") + private String name; + + /** + * 属性类型(0文本 1数字 2日期 3枚举) + */ + @ExcelProperty(value = "属性类型", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "0=文本,1=数字,2=日期,3=枚举") + private Integer type; + + /** + * 是否必填(0否 1是) + */ + @ExcelProperty(value = "是否必填", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "0=否,1=是") + private Integer required; + + /** + * 排序 + */ + @ExcelProperty(value = "排序") + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + @ExcelProperty(value = "状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "0=启用,1=禁用") + private Integer status; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallBrandVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallBrandVo.java new file mode 100644 index 0000000..f61c2c2 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallBrandVo.java @@ -0,0 +1,63 @@ +package org.dromara.mall.product.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.product.domain.MallBrand; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 品牌视图对象 mall_brand + * + * @author pengles + */ +@Data +@AutoMapper(target = MallBrand.class) +public class MallBrandVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 品牌ID + */ + private Long id; + + /** + * 品牌名称 + */ + private String name; + + /** + * 品牌Logo + */ + private String logo; + + /** + * 品牌描述 + */ + private String description; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallCategoryVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallCategoryVo.java new file mode 100644 index 0000000..c84ec08 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallCategoryVo.java @@ -0,0 +1,74 @@ +package org.dromara.mall.product.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.product.domain.MallCategory; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * 商品分类视图对象 mall_category + * + * @author pengles + */ +@Data +@AutoMapper(target = MallCategory.class) +public class MallCategoryVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 分类ID + */ + private Long id; + + /** + * 分类名称 + */ + private String name; + + /** + * 父分类ID(0表示顶级) + */ + private Long parentId; + + /** + * 分类级别(1一级 2二级 3三级) + */ + private Integer level; + + /** + * 分类图标 + */ + private String icon; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + + /** + * 子分类列表(用于树形结构) + */ + private List children; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallProductAttributeVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallProductAttributeVo.java new file mode 100644 index 0000000..1081cf0 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallProductAttributeVo.java @@ -0,0 +1,47 @@ +package org.dromara.mall.product.domain.vo; + +import lombok.Data; +import org.dromara.mall.product.domain.MallProductAttribute; +import io.github.linpeilie.annotations.AutoMapper; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 商品属性关联视图对象 mall_product_attribute + * + * @author pengles + */ +@Data +@AutoMapper(target = MallProductAttribute.class) +public class MallProductAttributeVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * ID + */ + private Long id; + + /** + * 商品ID + */ + private Long productId; + + /** + * 属性ID + */ + private Long attributeId; + + /** + * 属性值ID(当属性类型为枚举时使用) + */ + private Long attributeValueId; + + /** + * 属性值(当属性类型为文本/数字/日期时使用,或自定义值) + */ + private String value; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallProductVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallProductVo.java new file mode 100644 index 0000000..25da4eb --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallProductVo.java @@ -0,0 +1,93 @@ +package org.dromara.mall.product.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.product.domain.MallProduct; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 商品视图对象 mall_product + * + * @author pengles + */ +@Data +@AutoMapper(target = MallProduct.class) +public class MallProductVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 商品ID + */ + private Long id; + + /** + * 商品名称 + */ + private String name; + + /** + * 分类ID + */ + private Long categoryId; + + /** + * 品牌ID + */ + private Long brandId; + + /** + * 封面图片 + */ + private String coverUrl; + + /** + * 商品图片(JSON数组) + */ + private String images; + + /** + * 商品描述 + */ + private String description; + + /** + * 商品详情 + */ + private String detail; + + /** + * 状态(0上架 1下架) + */ + private Integer status; + + /** + * 排序 + */ + private Integer sort; + + /** + * 销量 + */ + private Integer salesCount; + + /** + * 浏览量 + */ + private Integer viewCount; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallSkuVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallSkuVo.java new file mode 100644 index 0000000..71a22b6 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/domain/vo/MallSkuVo.java @@ -0,0 +1,83 @@ +package org.dromara.mall.product.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.product.domain.MallSku; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * SKU视图对象 mall_sku + * + * @author pengles + */ +@Data +@AutoMapper(target = MallSku.class) +public class MallSkuVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * SKU ID + */ + private Long id; + + /** + * 商品ID + */ + private Long productId; + + /** + * SKU编码 + */ + private String skuCode; + + /** + * SKU名称 + */ + private String name; + + /** + * 价格(分) + */ + private Integer price; + + /** + * 原价(分) + */ + private Integer originalPrice; + + /** + * 库存 + */ + private Integer stock; + + /** + * SKU图片 + */ + private String image; + + /** + * 规格属性(JSON) + */ + private String specs; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/enums/AttributeType.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/enums/AttributeType.java new file mode 100644 index 0000000..13c487d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/enums/AttributeType.java @@ -0,0 +1,57 @@ +package org.dromara.mall.product.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 属性类型枚举 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum AttributeType { + + /** + * 文本 + */ + TEXT(0, "文本"), + + /** + * 数字 + */ + NUMBER(1, "数字"), + + /** + * 日期 + */ + DATE(2, "日期"), + + /** + * 枚举 + */ + ENUM(3, "枚举"); + + /** + * 类型值 + */ + private final Integer value; + + /** + * 类型名称 + */ + private final String name; + + /** + * 根据值获取枚举 + */ + public static AttributeType getByValue(Integer value) { + for (AttributeType type : values()) { + if (type.getValue().equals(value)) { + return type; + } + } + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeMapper.java new file mode 100644 index 0000000..462f94f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.product.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.product.domain.MallAttribute; +import org.dromara.mall.product.domain.vo.MallAttributeVo; + +/** + * 属性Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallAttributeMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeTemplateMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeTemplateMapper.java new file mode 100644 index 0000000..0eddd5d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeTemplateMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.product.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.product.domain.MallAttributeTemplate; +import org.dromara.mall.product.domain.vo.MallAttributeTemplateVo; + +/** + * 属性模板Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallAttributeTemplateMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeValueMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeValueMapper.java new file mode 100644 index 0000000..08efad5 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallAttributeValueMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.product.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.product.domain.MallAttributeValue; +import org.dromara.mall.product.domain.vo.MallAttributeValueVo; + +/** + * 属性值Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallAttributeValueMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallBrandMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallBrandMapper.java new file mode 100644 index 0000000..1730853 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallBrandMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.product.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.product.domain.MallBrand; +import org.dromara.mall.product.domain.vo.MallBrandVo; + +/** + * 品牌Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallBrandMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallCategoryMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallCategoryMapper.java new file mode 100644 index 0000000..75cd3ac --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallCategoryMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.product.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.product.domain.MallCategory; +import org.dromara.mall.product.domain.vo.MallCategoryVo; + +/** + * 商品分类Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallCategoryMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallProductAttributeMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallProductAttributeMapper.java new file mode 100644 index 0000000..670858f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallProductAttributeMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.product.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.product.domain.MallProductAttribute; +import org.dromara.mall.product.domain.vo.MallProductAttributeVo; + +/** + * 商品属性关联Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallProductAttributeMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallProductMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallProductMapper.java new file mode 100644 index 0000000..9f61e21 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallProductMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.product.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.product.domain.MallProduct; +import org.dromara.mall.product.domain.vo.MallProductVo; + +/** + * 商品Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallProductMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallSkuMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallSkuMapper.java new file mode 100644 index 0000000..756a738 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/mapper/MallSkuMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.product.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.product.domain.MallSku; +import org.dromara.mall.product.domain.vo.MallSkuVo; + +/** + * SKU Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallSkuMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeService.java new file mode 100644 index 0000000..768995b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeService.java @@ -0,0 +1,53 @@ +package org.dromara.mall.product.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.bo.MallAttributeBo; +import org.dromara.mall.product.domain.vo.MallAttributeVo; + +import java.util.Collection; +import java.util.List; + +/** + * 属性Service接口 + * + * @author pengles + */ +public interface MallAttributeService { + + /** + * 查询属性 + */ + MallAttributeVo queryById(Long id); + + /** + * 查询属性列表 + */ + TableDataInfo queryPageList(MallAttributeBo bo, PageQuery pageQuery); + + /** + * 查询属性列表 + */ + List queryList(MallAttributeBo bo); + + /** + * 根据模板ID查询属性列表 + */ + List queryListByTemplateId(Long templateId); + + /** + * 新增属性 + */ + Boolean insertByBo(MallAttributeBo bo); + + /** + * 修改属性 + */ + Boolean updateByBo(MallAttributeBo bo); + + /** + * 校验并批量删除属性信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeTemplateService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeTemplateService.java new file mode 100644 index 0000000..4ad51aa --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeTemplateService.java @@ -0,0 +1,48 @@ +package org.dromara.mall.product.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.bo.MallAttributeTemplateBo; +import org.dromara.mall.product.domain.vo.MallAttributeTemplateVo; + +import java.util.Collection; +import java.util.List; + +/** + * 属性模板Service接口 + * + * @author pengles + */ +public interface MallAttributeTemplateService { + + /** + * 查询属性模板 + */ + MallAttributeTemplateVo queryById(Long id); + + /** + * 查询属性模板列表 + */ + TableDataInfo queryPageList(MallAttributeTemplateBo bo, PageQuery pageQuery); + + /** + * 查询属性模板列表 + */ + List queryList(MallAttributeTemplateBo bo); + + /** + * 新增属性模板 + */ + Boolean insertByBo(MallAttributeTemplateBo bo); + + /** + * 修改属性模板 + */ + Boolean updateByBo(MallAttributeTemplateBo bo); + + /** + * 校验并批量删除属性模板信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeValueService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeValueService.java new file mode 100644 index 0000000..ea77e09 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallAttributeValueService.java @@ -0,0 +1,53 @@ +package org.dromara.mall.product.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.bo.MallAttributeValueBo; +import org.dromara.mall.product.domain.vo.MallAttributeValueVo; + +import java.util.Collection; +import java.util.List; + +/** + * 属性值Service接口 + * + * @author pengles + */ +public interface MallAttributeValueService { + + /** + * 查询属性值 + */ + MallAttributeValueVo queryById(Long id); + + /** + * 查询属性值列表 + */ + TableDataInfo queryPageList(MallAttributeValueBo bo, PageQuery pageQuery); + + /** + * 查询属性值列表 + */ + List queryList(MallAttributeValueBo bo); + + /** + * 根据属性ID查询属性值列表 + */ + List queryListByAttributeId(Long attributeId); + + /** + * 新增属性值 + */ + Boolean insertByBo(MallAttributeValueBo bo); + + /** + * 修改属性值 + */ + Boolean updateByBo(MallAttributeValueBo bo); + + /** + * 校验并批量删除属性值信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallBrandService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallBrandService.java new file mode 100644 index 0000000..e94f70a --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallBrandService.java @@ -0,0 +1,48 @@ +package org.dromara.mall.product.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.bo.MallBrandBo; +import org.dromara.mall.product.domain.vo.MallBrandVo; + +import java.util.Collection; +import java.util.List; + +/** + * 品牌Service接口 + * + * @author pengles + */ +public interface MallBrandService { + + /** + * 查询品牌 + */ + MallBrandVo queryById(Long id); + + /** + * 查询品牌列表 + */ + TableDataInfo queryPageList(MallBrandBo bo, PageQuery pageQuery); + + /** + * 查询品牌列表 + */ + List queryList(MallBrandBo bo); + + /** + * 新增品牌 + */ + Boolean insertByBo(MallBrandBo bo); + + /** + * 修改品牌 + */ + Boolean updateByBo(MallBrandBo bo); + + /** + * 校验并批量删除品牌信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallCategoryService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallCategoryService.java new file mode 100644 index 0000000..7009a6c --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallCategoryService.java @@ -0,0 +1,53 @@ +package org.dromara.mall.product.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.bo.MallCategoryBo; +import org.dromara.mall.product.domain.vo.MallCategoryVo; + +import java.util.Collection; +import java.util.List; + +/** + * 商品分类Service接口 + * + * @author pengles + */ +public interface MallCategoryService { + + /** + * 查询商品分类 + */ + MallCategoryVo queryById(Long id); + + /** + * 查询商品分类列表 + */ + TableDataInfo queryPageList(MallCategoryBo bo, PageQuery pageQuery); + + /** + * 查询商品分类列表 + */ + List queryList(MallCategoryBo bo); + + /** + * 查询商品分类树形列表 + */ + List queryTreeList(); + + /** + * 新增商品分类 + */ + Boolean insertByBo(MallCategoryBo bo); + + /** + * 修改商品分类 + */ + Boolean updateByBo(MallCategoryBo bo); + + /** + * 校验并批量删除商品分类信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallProductAttributeService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallProductAttributeService.java new file mode 100644 index 0000000..ade05dd --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallProductAttributeService.java @@ -0,0 +1,56 @@ +package org.dromara.mall.product.service; + +import org.dromara.mall.product.domain.bo.MallProductAttributeBo; +import org.dromara.mall.product.domain.vo.MallProductAttributeVo; + +import java.util.Collection; +import java.util.List; + +/** + * 商品属性关联Service接口 + * + * @author pengles + */ +public interface MallProductAttributeService { + + /** + * 查询商品属性关联 + */ + MallProductAttributeVo queryById(Long id); + + /** + * 根据商品ID查询商品属性关联列表 + */ + List queryListByProductId(Long productId); + + /** + * 新增商品属性关联 + */ + Boolean insertByBo(MallProductAttributeBo bo); + + /** + * 批量新增商品属性关联 + */ + Boolean insertBatch(List list); + + /** + * 修改商品属性关联 + */ + Boolean updateByBo(MallProductAttributeBo bo); + + /** + * 批量删除商品属性关联信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据商品ID删除商品属性关联 + */ + Boolean deleteByProductId(Long productId); + + /** + * 保存商品属性关联(先删除旧的,再插入新的) + */ + Boolean saveProductAttributes(Long productId, List list); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallProductService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallProductService.java new file mode 100644 index 0000000..35b56ce --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallProductService.java @@ -0,0 +1,48 @@ +package org.dromara.mall.product.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.bo.MallProductBo; +import org.dromara.mall.product.domain.vo.MallProductVo; + +import java.util.Collection; +import java.util.List; + +/** + * 商品Service接口 + * + * @author pengles + */ +public interface MallProductService { + + /** + * 查询商品 + */ + MallProductVo queryById(Long id); + + /** + * 查询商品列表 + */ + TableDataInfo queryPageList(MallProductBo bo, PageQuery pageQuery); + + /** + * 查询商品列表 + */ + List queryList(MallProductBo bo); + + /** + * 新增商品 + */ + Boolean insertByBo(MallProductBo bo); + + /** + * 修改商品 + */ + Boolean updateByBo(MallProductBo bo); + + /** + * 校验并批量删除商品信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallSkuService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallSkuService.java new file mode 100644 index 0000000..82aad22 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/MallSkuService.java @@ -0,0 +1,53 @@ +package org.dromara.mall.product.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.bo.MallSkuBo; +import org.dromara.mall.product.domain.vo.MallSkuVo; + +import java.util.Collection; +import java.util.List; + +/** + * SKU Service接口 + * + * @author pengles + */ +public interface MallSkuService { + + /** + * 查询SKU + */ + MallSkuVo queryById(Long id); + + /** + * 查询SKU列表 + */ + TableDataInfo queryPageList(MallSkuBo bo, PageQuery pageQuery); + + /** + * 查询SKU列表 + */ + List queryList(MallSkuBo bo); + + /** + * 根据商品ID查询SKU列表 + */ + List queryListByProductId(Long productId); + + /** + * 新增SKU + */ + Boolean insertByBo(MallSkuBo bo); + + /** + * 修改SKU + */ + Boolean updateByBo(MallSkuBo bo); + + /** + * 校验并批量删除SKU信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeServiceImpl.java new file mode 100644 index 0000000..d4bc66e --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeServiceImpl.java @@ -0,0 +1,167 @@ +package org.dromara.mall.product.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.MallAttribute; +import org.dromara.mall.product.domain.bo.MallAttributeBo; +import org.dromara.mall.product.domain.vo.MallAttributeVo; +import org.dromara.mall.product.mapper.MallAttributeMapper; +import org.dromara.mall.product.service.MallAttributeService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 属性Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallAttributeServiceImpl implements MallAttributeService { + + private final MallAttributeMapper baseMapper; + + /** + * 查询属性 + */ + @Override + public MallAttributeVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询属性列表 + */ + @Override + public TableDataInfo queryPageList(MallAttributeBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询属性列表 + */ + @Override + public List queryList(MallAttributeBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据模板ID查询属性列表 + */ + @Override + public List queryListByTemplateId(Long templateId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallAttribute::getTemplateId, templateId); + lqw.eq(MallAttribute::getStatus, 0); + lqw.orderByAsc(MallAttribute::getSort); + lqw.orderByAsc(MallAttribute::getId); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallAttributeBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.like(StringUtils.isNotBlank(bo.getName()), MallAttribute::getName, bo.getName()); + lqw.eq(bo.getTemplateId() != null, MallAttribute::getTemplateId, bo.getTemplateId()); + lqw.eq(bo.getType() != null, MallAttribute::getType, bo.getType()); + lqw.eq(bo.getStatus() != null, MallAttribute::getStatus, bo.getStatus()); + lqw.orderByAsc(MallAttribute::getSort); + lqw.orderByAsc(MallAttribute::getId); + return lqw; + } + + /** + * 新增属性 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallAttributeBo bo) { + MallAttribute add = MapstructUtils.convert(bo, MallAttribute.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getRequired() == null) { + add.setRequired(0); + } + if (add.getSort() == null) { + add.setSort(0); + } + if (add.getStatus() == null) { + add.setStatus(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改属性 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallAttributeBo bo) { + MallAttribute update = MapstructUtils.convert(bo, MallAttribute.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallAttribute entity) { + if (StringUtils.isBlank(entity.getName())) { + throw new ServiceException("属性名称不能为空"); + } + if (entity.getType() == null) { + throw new ServiceException("属性类型不能为空"); + } + if (entity.getType() < 0 || entity.getType() > 3) { + throw new ServiceException("属性类型不合法"); + } + // 检查属性名称是否重复(在同一模板下) + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallAttribute::getName, entity.getName()); + if (entity.getTemplateId() != null) { + lqw.eq(MallAttribute::getTemplateId, entity.getTemplateId()); + } + if (entity.getId() != null) { + lqw.ne(MallAttribute::getId, entity.getId()); + } + Long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new ServiceException("属性名称已存在"); + } + } + + /** + * 批量删除属性 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + for (Long id : ids) { + MallAttribute attribute = baseMapper.selectById(id); + if (attribute == null) { + throw new ServiceException("属性不存在"); + } + // 可以后续添加属性值表关联查询,检查是否有关联的属性值 + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeTemplateServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeTemplateServiceImpl.java new file mode 100644 index 0000000..e390f86 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeTemplateServiceImpl.java @@ -0,0 +1,141 @@ +package org.dromara.mall.product.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.MallAttributeTemplate; +import org.dromara.mall.product.domain.bo.MallAttributeTemplateBo; +import org.dromara.mall.product.domain.vo.MallAttributeTemplateVo; +import org.dromara.mall.product.mapper.MallAttributeTemplateMapper; +import org.dromara.mall.product.service.MallAttributeTemplateService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 属性模板Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallAttributeTemplateServiceImpl implements MallAttributeTemplateService { + + private final MallAttributeTemplateMapper baseMapper; + + /** + * 查询属性模板 + */ + @Override + public MallAttributeTemplateVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询属性模板列表 + */ + @Override + public TableDataInfo queryPageList(MallAttributeTemplateBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询属性模板列表 + */ + @Override + public List queryList(MallAttributeTemplateBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallAttributeTemplateBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.like(StringUtils.isNotBlank(bo.getName()), MallAttributeTemplate::getName, bo.getName()); + lqw.eq(bo.getCategoryId() != null, MallAttributeTemplate::getCategoryId, bo.getCategoryId()); + lqw.eq(bo.getStatus() != null, MallAttributeTemplate::getStatus, bo.getStatus()); + lqw.orderByAsc(MallAttributeTemplate::getSort); + lqw.orderByAsc(MallAttributeTemplate::getId); + return lqw; + } + + /** + * 新增属性模板 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallAttributeTemplateBo bo) { + MallAttributeTemplate add = MapstructUtils.convert(bo, MallAttributeTemplate.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getSort() == null) { + add.setSort(0); + } + if (add.getStatus() == null) { + add.setStatus(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改属性模板 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallAttributeTemplateBo bo) { + MallAttributeTemplate update = MapstructUtils.convert(bo, MallAttributeTemplate.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallAttributeTemplate entity) { + if (StringUtils.isBlank(entity.getName())) { + throw new ServiceException("模板名称不能为空"); + } + // 检查模板名称是否重复 + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallAttributeTemplate::getName, entity.getName()); + if (entity.getId() != null) { + lqw.ne(MallAttributeTemplate::getId, entity.getId()); + } + Long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new ServiceException("模板名称已存在"); + } + } + + /** + * 批量删除属性模板 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + for (Long id : ids) { + MallAttributeTemplate template = baseMapper.selectById(id); + if (template == null) { + throw new ServiceException("属性模板不存在"); + } + // 可以后续添加属性表关联查询,检查是否有关联的属性 + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeValueServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeValueServiceImpl.java new file mode 100644 index 0000000..23ec64b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallAttributeValueServiceImpl.java @@ -0,0 +1,158 @@ +package org.dromara.mall.product.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.MallAttributeValue; +import org.dromara.mall.product.domain.bo.MallAttributeValueBo; +import org.dromara.mall.product.domain.vo.MallAttributeValueVo; +import org.dromara.mall.product.mapper.MallAttributeValueMapper; +import org.dromara.mall.product.service.MallAttributeValueService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 属性值Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallAttributeValueServiceImpl implements MallAttributeValueService { + + private final MallAttributeValueMapper baseMapper; + + /** + * 查询属性值 + */ + @Override + public MallAttributeValueVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询属性值列表 + */ + @Override + public TableDataInfo queryPageList(MallAttributeValueBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询属性值列表 + */ + @Override + public List queryList(MallAttributeValueBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据属性ID查询属性值列表 + */ + @Override + public List queryListByAttributeId(Long attributeId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallAttributeValue::getAttributeId, attributeId); + lqw.eq(MallAttributeValue::getStatus, 0); + lqw.orderByAsc(MallAttributeValue::getSort); + lqw.orderByAsc(MallAttributeValue::getId); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallAttributeValueBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(bo.getAttributeId() != null, MallAttributeValue::getAttributeId, bo.getAttributeId()); + lqw.like(StringUtils.isNotBlank(bo.getValue()), MallAttributeValue::getValue, bo.getValue()); + lqw.eq(bo.getStatus() != null, MallAttributeValue::getStatus, bo.getStatus()); + lqw.orderByAsc(MallAttributeValue::getSort); + lqw.orderByAsc(MallAttributeValue::getId); + return lqw; + } + + /** + * 新增属性值 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallAttributeValueBo bo) { + MallAttributeValue add = MapstructUtils.convert(bo, MallAttributeValue.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getSort() == null) { + add.setSort(0); + } + if (add.getStatus() == null) { + add.setStatus(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改属性值 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallAttributeValueBo bo) { + MallAttributeValue update = MapstructUtils.convert(bo, MallAttributeValue.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallAttributeValue entity) { + if (entity.getAttributeId() == null) { + throw new ServiceException("属性ID不能为空"); + } + if (StringUtils.isBlank(entity.getValue())) { + throw new ServiceException("属性值不能为空"); + } + // 检查属性值是否重复(在同一属性下) + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallAttributeValue::getAttributeId, entity.getAttributeId()); + lqw.eq(MallAttributeValue::getValue, entity.getValue()); + if (entity.getId() != null) { + lqw.ne(MallAttributeValue::getId, entity.getId()); + } + Long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new ServiceException("属性值已存在"); + } + } + + /** + * 批量删除属性值 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + for (Long id : ids) { + MallAttributeValue value = baseMapper.selectById(id); + if (value == null) { + throw new ServiceException("属性值不存在"); + } + // 可以后续添加商品属性关联表查询,检查是否有关联的商品属性 + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallBrandServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallBrandServiceImpl.java new file mode 100644 index 0000000..8799dbf --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallBrandServiceImpl.java @@ -0,0 +1,141 @@ +package org.dromara.mall.product.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.MallBrand; +import org.dromara.mall.product.domain.bo.MallBrandBo; +import org.dromara.mall.product.domain.vo.MallBrandVo; +import org.dromara.mall.product.mapper.MallBrandMapper; +import org.dromara.mall.product.service.MallBrandService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 品牌Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallBrandServiceImpl implements MallBrandService { + + private final MallBrandMapper baseMapper; + + /** + * 查询品牌 + */ + @Override + public MallBrandVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询品牌列表 + */ + @Override + public TableDataInfo queryPageList(MallBrandBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询品牌列表 + */ + @Override + public List queryList(MallBrandBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallBrandBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.like(StringUtils.isNotBlank(bo.getName()), MallBrand::getName, bo.getName()); + lqw.eq(bo.getStatus() != null, MallBrand::getStatus, bo.getStatus()); + lqw.orderByAsc(MallBrand::getSort); + lqw.orderByAsc(MallBrand::getId); + return lqw; + } + + /** + * 新增品牌 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallBrandBo bo) { + MallBrand add = MapstructUtils.convert(bo, MallBrand.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getSort() == null) { + add.setSort(0); + } + if (add.getStatus() == null) { + add.setStatus(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改品牌 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallBrandBo bo) { + MallBrand update = MapstructUtils.convert(bo, MallBrand.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallBrand entity) { + if (StringUtils.isBlank(entity.getName())) { + throw new ServiceException("品牌名称不能为空"); + } + // 检查品牌名称是否重复 + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallBrand::getName, entity.getName()); + if (entity.getId() != null) { + lqw.ne(MallBrand::getId, entity.getId()); + } + Long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new ServiceException("品牌名称已存在"); + } + } + + /** + * 批量删除品牌 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 检查是否有关联的商品(这里需要关联商品表查询,暂时跳过) + // 可以后续添加商品表关联查询 + for (Long id : ids) { + MallBrand brand = baseMapper.selectById(id); + if (brand == null) { + throw new ServiceException("品牌不存在"); + } + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallCategoryServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallCategoryServiceImpl.java new file mode 100644 index 0000000..2628e71 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallCategoryServiceImpl.java @@ -0,0 +1,250 @@ +package org.dromara.mall.product.service.impl; + +import cn.hutool.core.collection.CollUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.MallCategory; +import org.dromara.mall.product.domain.bo.MallCategoryBo; +import org.dromara.mall.product.domain.vo.MallCategoryVo; +import org.dromara.mall.product.mapper.MallCategoryMapper; +import org.dromara.mall.product.service.MallCategoryService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 商品分类Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallCategoryServiceImpl implements MallCategoryService { + + private final MallCategoryMapper baseMapper; + + /** + * 查询商品分类 + */ + @Override + public MallCategoryVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询商品分类列表 + */ + @Override + public TableDataInfo queryPageList(MallCategoryBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询商品分类列表 + */ + @Override + public List queryList(MallCategoryBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + /** + * 查询商品分类树形列表 + */ + @Override + public List queryTreeList() { + MallCategoryBo bo = new MallCategoryBo(); + bo.setStatus(0); // 只查询启用的分类 + List list = queryList(bo); + return buildTree(list); + } + + private LambdaQueryWrapper buildQueryWrapper(MallCategoryBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.like(StringUtils.isNotBlank(bo.getName()), MallCategory::getName, bo.getName()); + lqw.eq(bo.getParentId() != null, MallCategory::getParentId, bo.getParentId()); + lqw.eq(bo.getLevel() != null, MallCategory::getLevel, bo.getLevel()); + lqw.eq(bo.getStatus() != null, MallCategory::getStatus, bo.getStatus()); + lqw.orderByAsc(MallCategory::getSort); + lqw.orderByAsc(MallCategory::getId); + return lqw; + } + + /** + * 构建树形结构 + */ + private List buildTree(List list) { + if (CollUtil.isEmpty(list)) { + return new ArrayList<>(); + } + // 按父ID分组 + Map> childrenMap = list.stream() + .filter(item -> item.getParentId() != null && item.getParentId() != 0) + .collect(Collectors.groupingBy(MallCategoryVo::getParentId)); + + // 设置子节点 + list.forEach(item -> { + List children = childrenMap.get(item.getId()); + item.setChildren(children != null ? children : new ArrayList<>()); + }); + + // 返回顶级节点 + return list.stream() + .filter(item -> item.getParentId() == null || item.getParentId() == 0) + .sorted(Comparator.comparing(MallCategoryVo::getSort, Comparator.nullsLast(Comparator.naturalOrder()))) + .collect(Collectors.toList()); + } + + /** + * 新增商品分类 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallCategoryBo bo) { + MallCategory add = MapstructUtils.convert(bo, MallCategory.class); + validEntityBeforeSave(add); + + // 设置父级ID和级别 + if (add.getParentId() == null || add.getParentId() == 0) { + add.setParentId(0L); + add.setLevel(1); + } else { + // 查询父分类 + MallCategory parent = baseMapper.selectById(add.getParentId()); + if (parent == null) { + throw new ServiceException("父分类不存在"); + } + if (parent.getStatus() != 0) { + throw new ServiceException("父分类已禁用,不能添加子分类"); + } + if (parent.getLevel() >= 3) { + throw new ServiceException("分类级别不能超过3级"); + } + add.setLevel(parent.getLevel() + 1); + } + + // 设置默认值 + if (add.getSort() == null) { + add.setSort(0); + } + if (add.getStatus() == null) { + add.setStatus(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改商品分类 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallCategoryBo bo) { + MallCategory update = MapstructUtils.convert(bo, MallCategory.class); + validEntityBeforeSave(update); + + // 不能将父分类设置为自己的子分类 + if (update.getParentId() != null && update.getParentId() != 0) { + if (update.getId().equals(update.getParentId())) { + throw new ServiceException("不能将分类设置为自己的父分类"); + } + // 检查是否会形成循环 + if (hasCircularReference(update.getId(), update.getParentId())) { + throw new ServiceException("不能将分类设置为自己的子分类的子分类"); + } + + // 查询父分类 + MallCategory parent = baseMapper.selectById(update.getParentId()); + if (parent == null) { + throw new ServiceException("父分类不存在"); + } + if (parent.getLevel() >= 3) { + throw new ServiceException("分类级别不能超过3级"); + } + update.setLevel(parent.getLevel() + 1); + } else { + update.setParentId(0L); + update.setLevel(1); + } + + return baseMapper.updateById(update) > 0; + } + + /** + * 检查是否存在循环引用 + */ + private boolean hasCircularReference(Long id, Long parentId) { + Long currentParentId = parentId; + Set visited = new HashSet<>(); + visited.add(id); + + while (currentParentId != null && currentParentId != 0) { + if (visited.contains(currentParentId)) { + return true; + } + visited.add(currentParentId); + MallCategory parent = baseMapper.selectById(currentParentId); + if (parent == null) { + break; + } + currentParentId = parent.getParentId(); + } + return false; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallCategory entity) { + if (StringUtils.isBlank(entity.getName())) { + throw new ServiceException("分类名称不能为空"); + } + // 检查同级分类名称是否重复 + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallCategory::getName, entity.getName()); + lqw.eq(entity.getParentId() != null, MallCategory::getParentId, entity.getParentId()); + if (entity.getId() != null) { + lqw.ne(MallCategory::getId, entity.getId()); + } + Long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new ServiceException("同级分类名称不能重复"); + } + } + + /** + * 批量删除商品分类 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 检查是否存在子分类 + for (Long id : ids) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallCategory::getParentId, id); + Long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new ServiceException("存在子分类,不能删除"); + } + // 检查是否有关联的商品(这里需要关联商品表查询,暂时跳过) + // 可以后续添加商品表关联查询 + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallProductAttributeServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallProductAttributeServiceImpl.java new file mode 100644 index 0000000..80507e8 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallProductAttributeServiceImpl.java @@ -0,0 +1,163 @@ +package org.dromara.mall.product.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.mall.product.domain.MallProductAttribute; +import org.dromara.mall.product.domain.bo.MallProductAttributeBo; +import org.dromara.mall.product.domain.vo.MallProductAttributeVo; +import org.dromara.mall.product.mapper.MallProductAttributeMapper; +import org.dromara.mall.product.service.MallProductAttributeService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 商品属性关联Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallProductAttributeServiceImpl implements MallProductAttributeService { + + private final MallProductAttributeMapper baseMapper; + + /** + * 查询商品属性关联 + */ + @Override + public MallProductAttributeVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 根据商品ID查询商品属性关联列表 + */ + @Override + public List queryListByProductId(Long productId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallProductAttribute::getProductId, productId); + lqw.orderByAsc(MallProductAttribute::getId); + return baseMapper.selectVoList(lqw); + } + + /** + * 新增商品属性关联 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallProductAttributeBo bo) { + MallProductAttribute add = MapstructUtils.convert(bo, MallProductAttribute.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 批量新增商品属性关联 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertBatch(List list) { + if (list == null || list.isEmpty()) { + return true; + } + List entities = MapstructUtils.convert(list, MallProductAttribute.class); + for (MallProductAttribute entity : entities) { + validEntityBeforeSave(entity); + } + // 批量插入 + for (MallProductAttribute entity : entities) { + baseMapper.insert(entity); + } + return true; + } + + /** + * 修改商品属性关联 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallProductAttributeBo bo) { + MallProductAttribute update = MapstructUtils.convert(bo, MallProductAttribute.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallProductAttribute entity) { + if (entity.getProductId() == null) { + throw new ServiceException("商品ID不能为空"); + } + if (entity.getAttributeId() == null) { + throw new ServiceException("属性ID不能为空"); + } + // 检查同一商品下是否已有相同的属性 + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallProductAttribute::getProductId, entity.getProductId()); + lqw.eq(MallProductAttribute::getAttributeId, entity.getAttributeId()); + if (entity.getId() != null) { + lqw.ne(MallProductAttribute::getId, entity.getId()); + } + Long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new ServiceException("商品已存在该属性"); + } + } + + /** + * 批量删除商品属性关联 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + for (Long id : ids) { + MallProductAttribute attribute = baseMapper.selectById(id); + if (attribute == null) { + throw new ServiceException("商品属性关联不存在"); + } + } + } + return baseMapper.deleteByIds(ids) > 0; + } + + /** + * 根据商品ID删除商品属性关联 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteByProductId(Long productId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallProductAttribute::getProductId, productId); + return baseMapper.delete(lqw) >= 0; + } + + /** + * 保存商品属性关联(先删除旧的,再插入新的) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean saveProductAttributes(Long productId, List list) { + // 删除旧的关联 + deleteByProductId(productId); + // 插入新的关联 + if (list != null && !list.isEmpty()) { + for (MallProductAttributeBo bo : list) { + bo.setProductId(productId); + } + return insertBatch(list); + } + return true; + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallProductServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallProductServiceImpl.java new file mode 100644 index 0000000..7d96871 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallProductServiceImpl.java @@ -0,0 +1,137 @@ +package org.dromara.mall.product.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.MallProduct; +import org.dromara.mall.product.domain.bo.MallProductBo; +import org.dromara.mall.product.domain.vo.MallProductVo; +import org.dromara.mall.product.mapper.MallProductMapper; +import org.dromara.mall.product.service.MallProductService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 商品Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallProductServiceImpl implements MallProductService { + + private final MallProductMapper baseMapper; + + /** + * 查询商品 + */ + @Override + public MallProductVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询商品列表 + */ + @Override + public TableDataInfo queryPageList(MallProductBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询商品列表 + */ + @Override + public List queryList(MallProductBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallProductBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.like(StringUtils.isNotBlank(bo.getName()), MallProduct::getName, bo.getName()); + lqw.eq(bo.getCategoryId() != null, MallProduct::getCategoryId, bo.getCategoryId()); + lqw.eq(bo.getBrandId() != null, MallProduct::getBrandId, bo.getBrandId()); + lqw.eq(bo.getStatus() != null, MallProduct::getStatus, bo.getStatus()); + lqw.orderByDesc(MallProduct::getSort); + lqw.orderByDesc(MallProduct::getId); + return lqw; + } + + /** + * 新增商品 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallProductBo bo) { + MallProduct add = MapstructUtils.convert(bo, MallProduct.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getSort() == null) { + add.setSort(0); + } + if (add.getStatus() == null) { + add.setStatus(0); + } + if (add.getSalesCount() == null) { + add.setSalesCount(0); + } + if (add.getViewCount() == null) { + add.setViewCount(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改商品 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallProductBo bo) { + MallProduct update = MapstructUtils.convert(bo, MallProduct.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallProduct entity) { + if (StringUtils.isBlank(entity.getName())) { + throw new ServiceException("商品名称不能为空"); + } + } + + /** + * 批量删除商品 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + for (Long id : ids) { + MallProduct product = baseMapper.selectById(id); + if (product == null) { + throw new ServiceException("商品不存在"); + } + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallSkuServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallSkuServiceImpl.java new file mode 100644 index 0000000..32443f0 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/java/org/dromara/mall/product/service/impl/MallSkuServiceImpl.java @@ -0,0 +1,158 @@ +package org.dromara.mall.product.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.product.domain.MallSku; +import org.dromara.mall.product.domain.bo.MallSkuBo; +import org.dromara.mall.product.domain.vo.MallSkuVo; +import org.dromara.mall.product.mapper.MallSkuMapper; +import org.dromara.mall.product.service.MallSkuService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * SKU Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallSkuServiceImpl implements MallSkuService { + + private final MallSkuMapper baseMapper; + + /** + * 查询SKU + */ + @Override + public MallSkuVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询SKU列表 + */ + @Override + public TableDataInfo queryPageList(MallSkuBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询SKU列表 + */ + @Override + public List queryList(MallSkuBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据商品ID查询SKU列表 + */ + @Override + public List queryListByProductId(Long productId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallSku::getProductId, productId); + lqw.eq(MallSku::getStatus, 0); // 只查询启用的SKU + lqw.orderByAsc(MallSku::getId); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallSkuBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(bo.getProductId() != null, MallSku::getProductId, bo.getProductId()); + lqw.eq(StringUtils.isNotBlank(bo.getSkuCode()), MallSku::getSkuCode, bo.getSkuCode()); + lqw.like(StringUtils.isNotBlank(bo.getName()), MallSku::getName, bo.getName()); + lqw.eq(bo.getStatus() != null, MallSku::getStatus, bo.getStatus()); + lqw.orderByAsc(MallSku::getId); + return lqw; + } + + /** + * 新增SKU + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallSkuBo bo) { + MallSku add = MapstructUtils.convert(bo, MallSku.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getStock() == null) { + add.setStock(0); + } + if (add.getStatus() == null) { + add.setStatus(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改SKU + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallSkuBo bo) { + MallSku update = MapstructUtils.convert(bo, MallSku.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallSku entity) { + if (entity.getProductId() == null) { + throw new ServiceException("商品ID不能为空"); + } + if (StringUtils.isBlank(entity.getSkuCode())) { + throw new ServiceException("SKU编码不能为空"); + } + if (entity.getPrice() == null) { + throw new ServiceException("价格不能为空"); + } + // 检查SKU编码是否重复 + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallSku::getSkuCode, entity.getSkuCode()); + if (entity.getId() != null) { + lqw.ne(MallSku::getId, entity.getId()); + } + Long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new ServiceException("SKU编码已存在"); + } + } + + /** + * 批量删除SKU + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + for (Long id : ids) { + MallSku sku = baseMapper.selectById(id); + if (sku == null) { + throw new ServiceException("SKU不存在"); + } + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/resources/META-INF/spring.factories b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..6c11263 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-product/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.dromara.mall.product.config.MallProductAutoConfiguration diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/README.md b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/README.md new file mode 100644 index 0000000..8144f2a --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/README.md @@ -0,0 +1,204 @@ +# 营销中心模块 (ruoyi-mall-promotion) + +## 模块简介 + +`ruoyi-mall-promotion` 是商城系统的营销中心模块,提供多样化的营销活动功能,包括活动、拼团、秒杀、砍价等。 + +## 功能特性 + +### 1. 营销活动管理 ✅ + +- ✅ 活动CRUD操作 +- ✅ 活动类型管理(普通活动、拼团、秒杀、砍价) +- ✅ 活动规则配置(JSON格式) +- ✅ 活动时间管理 +- ✅ 活动状态管理(启用/禁用) +- ✅ 活动导出功能 + +### 2. 拼团功能 ✅ + +- ✅ 拼团活动创建 +- ✅ 拼团活动查询(按SKU、可用列表) +- ✅ 拼团活动状态管理 +- ⏳ 拼团订单管理(需在交易中心模块集成) +- ⏳ 拼团成团处理(需在交易中心模块集成) +- ⏳ 拼团过期处理(需定时任务支持) + +### 3. 秒杀功能 ✅ + +- ✅ 秒杀活动创建 +- ✅ 秒杀库存管理(扣减、恢复) +- ✅ 秒杀活动查询(按SKU、可用列表) +- ✅ 秒杀活动状态管理 +- ✅ 秒杀活动状态自动更新(定时任务) + +### 4. 砍价功能 ✅ + +- ✅ 砍价活动创建 +- ✅ 砍价活动查询(按SKU、可用列表) +- ✅ 砍价活动状态管理 +- ⏳ 砍价记录管理(需在交易中心模块集成) +- ⏳ 砍价助力记录(需在交易中心模块集成) +- ⏳ 砍价订单处理(需在交易中心模块集成) + +## 数据库表 + +- `mall_promotion` - 营销活动表 +- `mall_group_buy` - 拼团表 +- `mall_group_buy_order` - 拼团订单表 +- `mall_seckill` - 秒杀表 +- `mall_bargain` - 砍价活动表 +- `mall_bargain_record` - 砍价记录表 +- `mall_bargain_help` - 砍价助力记录表 + +详细表结构请参考:`script/sql/ry_mall.sql` + +## 模块结构 + +``` +org.dromara.mall.promotion +├── controller +│ ├── admin // 管理端API +│ └── app // 用户端API +├── domain +│ ├── bo // 业务对象 +│ └── vo // 视图对象 +├── mapper // MyBatis Mapper +├── service // 业务逻辑层 +│ └── impl +├── job // 定时任务 +│ └── MallPromotionJob.java +├── enums // 枚举类 +└── config // 配置类 +``` + +## 依赖模块 + +- `ruoyi-mall-product` - 商品中心模块 +- `ruoyi-mall-coupon` - 优惠券中心模块 +- `ruoyi-common-*` - 通用工具模块 + +## API接口 + +### 管理端接口 + +#### 营销活动管理 (`/mall/promotion`) + +- `GET /mall/promotion/list` - 查询营销活动列表(分页) +- `GET /mall/promotion/{id}` - 获取营销活动详情 +- `POST /mall/promotion` - 新增营销活动 +- `PUT /mall/promotion` - 修改营销活动 +- `DELETE /mall/promotion/{ids}` - 删除营销活动 +- `POST /mall/promotion/export` - 导出营销活动列表 +- `PUT /mall/promotion/changeStatus/{id}` - 启用/禁用营销活动 + +### 移动端接口 + +#### 营销活动 (`/app/mall/promotion`) + +- `GET /app/mall/promotion/list` - 查询营销活动列表(可按类型筛选) +- `GET /app/mall/promotion/{id}` - 获取营销活动详情 + +#### 秒杀活动管理 (`/mall/seckill`) + +- `GET /mall/seckill/list` - 查询秒杀活动列表(分页) +- `GET /mall/seckill/{id}` - 获取秒杀活动详情 +- `POST /mall/seckill` - 新增秒杀活动 +- `PUT /mall/seckill` - 修改秒杀活动 +- `DELETE /mall/seckill/{ids}` - 删除秒杀活动 +- `POST /mall/seckill/export` - 导出秒杀活动列表 +- `PUT /mall/seckill/changeStatus/{id}` - 启用/禁用秒杀活动 + +#### 秒杀活动 (`/app/mall/seckill`) + +- `GET /app/mall/seckill/list` - 查询可用秒杀活动列表 +- `GET /app/mall/seckill/{id}` - 获取秒杀活动详情 +- `GET /app/mall/seckill/sku/{skuId}` - 根据SKU ID查询秒杀活动 + +#### 拼团活动管理 (`/mall/groupbuy`) + +- `GET /mall/groupbuy/list` - 查询拼团活动列表(分页) +- `GET /mall/groupbuy/{id}` - 获取拼团活动详情 +- `POST /mall/groupbuy` - 新增拼团活动 +- `PUT /mall/groupbuy` - 修改拼团活动 +- `DELETE /mall/groupbuy/{ids}` - 删除拼团活动 +- `POST /mall/groupbuy/export` - 导出拼团活动列表 +- `PUT /mall/groupbuy/changeStatus/{id}` - 启用/禁用拼团活动 + +#### 拼团活动 (`/app/mall/groupbuy`) + +- `GET /app/mall/groupbuy/list` - 查询可用拼团活动列表 +- `GET /app/mall/groupbuy/{id}` - 获取拼团活动详情 +- `GET /app/mall/groupbuy/sku/{skuId}` - 根据SKU ID查询拼团活动 + +#### 砍价活动管理 (`/mall/bargain`) + +- `GET /mall/bargain/list` - 查询砍价活动列表(分页) +- `GET /mall/bargain/{id}` - 获取砍价活动详情 +- `POST /mall/bargain` - 新增砍价活动 +- `PUT /mall/bargain` - 修改砍价活动 +- `DELETE /mall/bargain/{ids}` - 删除砍价活动 +- `POST /mall/bargain/export` - 导出砍价活动列表 +- `PUT /mall/bargain/changeStatus/{id}` - 启用/禁用砍价活动 + +#### 砍价活动 (`/app/mall/bargain`) + +- `GET /app/mall/bargain/list` - 查询可用砍价活动列表 +- `GET /app/mall/bargain/{id}` - 获取砍价活动详情 +- `GET /app/mall/bargain/sku/{skuId}` - 根据SKU ID查询砍价活动 + +## 待实现功能 + +- [x] 营销活动管理功能 ✅ +- [x] 拼团功能 ✅(基础功能已完成,订单相关功能需在交易中心模块集成) +- [x] 秒杀功能 ✅ +- [x] 砍价功能 ✅(基础功能已完成,记录和订单相关功能需在交易中心模块集成) +- [ ] 活动统计功能 +- [ ] 拼团订单管理和成团处理(需在交易中心模块集成) +- [ ] 砍价记录和助力记录管理(需在交易中心模块集成) + +## 使用说明 + +### 秒杀库存管理 + +秒杀库存使用独立的库存字段 `seckill_stock`,与商品SKU库存分离。在创建秒杀订单时,需要: + +1. 调用 `reduceStock` 方法扣减秒杀库存 +2. 如果订单取消,调用 `restoreStock` 方法恢复秒杀库存 +3. 秒杀库存扣减失败表示库存不足,订单创建失败 + +## 使用说明 + +### 秒杀库存管理 + +秒杀库存使用独立的库存字段 `seckill_stock`,与商品SKU库存分离。在创建秒杀订单时,需要: + +1. 调用 `reduceStock` 方法扣减秒杀库存 +2. 如果订单取消,调用 `restoreStock` 方法恢复秒杀库存 +3. 秒杀库存扣减失败表示库存不足,订单创建失败 + +### 拼团活动管理 + +拼团活动配置包括: +- `groupPrice`: 拼团价格(分) +- `groupSize`: 成团人数(至少2人) +- `durationHours`: 拼团有效期(小时,默认24小时) + +注意:拼团功能只实现了活动管理,实际的拼团订单创建、成团处理等逻辑需要在交易中心模块(ruoyi-mall-trade)中集成。 + +### 砍价活动管理 + +砍价活动配置包括: +- `originalPrice`: 原价(分) +- `bargainPrice`: 砍后价(分) +- `minPrice`: 最低价(分) +- `helpCount`: 需要助力人数(可选,默认0) +- `bargainStock`: 砍价库存 +- `durationHours`: 砍价有效期(小时,默认24小时) + +价格校验规则: +- 砍后价必须小于原价 +- 砍后价不能小于最低价 +- 最低价必须小于原价 + +注意:砍价功能只实现了活动管理,实际的砍价记录、助力记录、订单创建等逻辑需要在交易中心模块(ruoyi-mall-trade)中集成。 diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/pom.xml b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/pom.xml new file mode 100644 index 0000000..d558a86 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/pom.xml @@ -0,0 +1,81 @@ + + + + org.dromara + ruoyi-mall + ${revision} + + 4.0.0 + + ruoyi-mall-promotion + + + ruoyi-mall-promotion 营销中心模块 + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-log + + + + org.dromara + ruoyi-common-redis + + + + org.dromara + ruoyi-common-excel + + + + org.dromara + ruoyi-common-satoken + + + + + org.dromara + ruoyi-mall-product + ${revision} + + + + + org.dromara + ruoyi-mall-coupon + ${revision} + + + + + diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/config/MallPromotionAutoConfiguration.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/config/MallPromotionAutoConfiguration.java new file mode 100644 index 0000000..688e0e0 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/config/MallPromotionAutoConfiguration.java @@ -0,0 +1,23 @@ +package org.dromara.mall.promotion.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * 营销中心自动配置类 + * 主要用于扫描营销中心模块的组件 + * + * @author pengles + */ +@Slf4j +@Configuration +@ComponentScan(basePackages = "org.dromara.mall.promotion") +@ConditionalOnProperty(prefix = "ruoyi.mall.promotion", name = "enabled", havingValue = "true", matchIfMissing = true) +public class MallPromotionAutoConfiguration { + + public MallPromotionAutoConfiguration() { + log.info("营销中心模块已启用"); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallBargainController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallBargainController.java new file mode 100644 index 0000000..096920e --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallBargainController.java @@ -0,0 +1,126 @@ +package org.dromara.mall.promotion.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.promotion.domain.bo.MallBargainBo; +import org.dromara.mall.promotion.domain.vo.MallBargainVo; +import org.dromara.mall.promotion.service.MallBargainService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 砍价活动管理 + * + * @author pengles + */ +@Tag(name = "管理端-砍价活动管理", description = "砍价活动管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/bargain") +public class AdminMallBargainController extends BaseController { + + private final MallBargainService mallBargainService; + + /** + * 查询砍价活动列表 + */ + @Operation(summary = "查询砍价活动列表", description = "分页查询砍价活动列表") + @SaCheckPermission("mall:bargain:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "砍价活动查询条件") MallBargainBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallBargainService.queryPageList(bo, pageQuery); + } + + /** + * 导出砍价活动列表 + */ + @Operation(summary = "导出砍价活动列表", description = "导出砍价活动到Excel") + @SaCheckPermission("mall:bargain:export") + @Log(title = "砍价活动", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "砍价活动查询条件") MallBargainBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallBargainService.queryList(bo); + ExcelUtil.exportExcel(list, "砍价活动", MallBargainVo.class, response); + } + + /** + * 获取砍价活动详细信息 + */ + @Operation(summary = "获取砍价活动详情", description = "根据砍价活动ID获取详细信息") + @SaCheckPermission("mall:bargain:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "砍价活动ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallBargainService.queryById(id)); + } + + /** + * 新增砍价活动 + */ + @Operation(summary = "新增砍价活动", description = "创建新的砍价活动") + @SaCheckPermission("mall:bargain:add") + @Log(title = "砍价活动", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "砍价活动信息") @Validated @RequestBody MallBargainBo bo) { + return toAjax(mallBargainService.insertByBo(bo)); + } + + /** + * 修改砍价活动 + */ + @Operation(summary = "修改砍价活动", description = "更新砍价活动信息") + @SaCheckPermission("mall:bargain:edit") + @Log(title = "砍价活动", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "砍价活动信息") @Validated @RequestBody MallBargainBo bo) { + return toAjax(mallBargainService.updateByBo(bo)); + } + + /** + * 删除砍价活动 + */ + @Operation(summary = "删除砍价活动", description = "删除砍价活动") + @SaCheckPermission("mall:bargain:remove") + @Log(title = "砍价活动", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "砍价活动ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallBargainService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 启用/禁用砍价活动 + */ + @Operation(summary = "启用/禁用砍价活动", description = "更改砍价活动状态") + @SaCheckPermission("mall:bargain:edit") + @Log(title = "砍价活动", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus/{id}") + public R changeStatus(@Parameter(description = "砍价活动ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "状态(0启用 1禁用)", required = true) + @RequestParam @NotNull(message = "状态不能为空") Integer status) { + return toAjax(mallBargainService.changeStatus(id, status)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallGroupBuyController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallGroupBuyController.java new file mode 100644 index 0000000..9eb0f26 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallGroupBuyController.java @@ -0,0 +1,126 @@ +package org.dromara.mall.promotion.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.promotion.domain.bo.MallGroupBuyBo; +import org.dromara.mall.promotion.domain.vo.MallGroupBuyVo; +import org.dromara.mall.promotion.service.MallGroupBuyService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 拼团活动管理 + * + * @author pengles + */ +@Tag(name = "管理端-拼团活动管理", description = "拼团活动管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/groupbuy") +public class AdminMallGroupBuyController extends BaseController { + + private final MallGroupBuyService mallGroupBuyService; + + /** + * 查询拼团活动列表 + */ + @Operation(summary = "查询拼团活动列表", description = "分页查询拼团活动列表") + @SaCheckPermission("mall:groupbuy:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "拼团活动查询条件") MallGroupBuyBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallGroupBuyService.queryPageList(bo, pageQuery); + } + + /** + * 导出拼团活动列表 + */ + @Operation(summary = "导出拼团活动列表", description = "导出拼团活动到Excel") + @SaCheckPermission("mall:groupbuy:export") + @Log(title = "拼团活动", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "拼团活动查询条件") MallGroupBuyBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallGroupBuyService.queryList(bo); + ExcelUtil.exportExcel(list, "拼团活动", MallGroupBuyVo.class, response); + } + + /** + * 获取拼团活动详细信息 + */ + @Operation(summary = "获取拼团活动详情", description = "根据拼团活动ID获取详细信息") + @SaCheckPermission("mall:groupbuy:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "拼团活动ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallGroupBuyService.queryById(id)); + } + + /** + * 新增拼团活动 + */ + @Operation(summary = "新增拼团活动", description = "创建新的拼团活动") + @SaCheckPermission("mall:groupbuy:add") + @Log(title = "拼团活动", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "拼团活动信息") @Validated @RequestBody MallGroupBuyBo bo) { + return toAjax(mallGroupBuyService.insertByBo(bo)); + } + + /** + * 修改拼团活动 + */ + @Operation(summary = "修改拼团活动", description = "更新拼团活动信息") + @SaCheckPermission("mall:groupbuy:edit") + @Log(title = "拼团活动", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "拼团活动信息") @Validated @RequestBody MallGroupBuyBo bo) { + return toAjax(mallGroupBuyService.updateByBo(bo)); + } + + /** + * 删除拼团活动 + */ + @Operation(summary = "删除拼团活动", description = "删除拼团活动") + @SaCheckPermission("mall:groupbuy:remove") + @Log(title = "拼团活动", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "拼团活动ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallGroupBuyService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 启用/禁用拼团活动 + */ + @Operation(summary = "启用/禁用拼团活动", description = "更改拼团活动状态") + @SaCheckPermission("mall:groupbuy:edit") + @Log(title = "拼团活动", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus/{id}") + public R changeStatus(@Parameter(description = "拼团活动ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "状态(0启用 1禁用)", required = true) + @RequestParam @NotNull(message = "状态不能为空") Integer status) { + return toAjax(mallGroupBuyService.changeStatus(id, status)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallPromotionController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallPromotionController.java new file mode 100644 index 0000000..5466ea6 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallPromotionController.java @@ -0,0 +1,126 @@ +package org.dromara.mall.promotion.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.promotion.domain.bo.MallPromotionBo; +import org.dromara.mall.promotion.domain.vo.MallPromotionVo; +import org.dromara.mall.promotion.service.MallPromotionService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 营销活动管理 + * + * @author pengles + */ +@Tag(name = "管理端-营销活动管理", description = "营销活动管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/promotion") +public class AdminMallPromotionController extends BaseController { + + private final MallPromotionService mallPromotionService; + + /** + * 查询营销活动列表 + */ + @Operation(summary = "查询营销活动列表", description = "分页查询营销活动列表") + @SaCheckPermission("mall:promotion:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "营销活动查询条件") MallPromotionBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallPromotionService.queryPageList(bo, pageQuery); + } + + /** + * 导出营销活动列表 + */ + @Operation(summary = "导出营销活动列表", description = "导出营销活动到Excel") + @SaCheckPermission("mall:promotion:export") + @Log(title = "营销活动", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "营销活动查询条件") MallPromotionBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallPromotionService.queryList(bo); + ExcelUtil.exportExcel(list, "营销活动", MallPromotionVo.class, response); + } + + /** + * 获取营销活动详细信息 + */ + @Operation(summary = "获取营销活动详情", description = "根据营销活动ID获取详细信息") + @SaCheckPermission("mall:promotion:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "营销活动ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallPromotionService.queryById(id)); + } + + /** + * 新增营销活动 + */ + @Operation(summary = "新增营销活动", description = "创建新的营销活动") + @SaCheckPermission("mall:promotion:add") + @Log(title = "营销活动", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "营销活动信息") @Validated @RequestBody MallPromotionBo bo) { + return toAjax(mallPromotionService.insertByBo(bo)); + } + + /** + * 修改营销活动 + */ + @Operation(summary = "修改营销活动", description = "更新营销活动信息") + @SaCheckPermission("mall:promotion:edit") + @Log(title = "营销活动", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "营销活动信息") @Validated @RequestBody MallPromotionBo bo) { + return toAjax(mallPromotionService.updateByBo(bo)); + } + + /** + * 删除营销活动 + */ + @Operation(summary = "删除营销活动", description = "删除营销活动") + @SaCheckPermission("mall:promotion:remove") + @Log(title = "营销活动", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "营销活动ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallPromotionService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 启用/禁用营销活动 + */ + @Operation(summary = "启用/禁用营销活动", description = "更改营销活动状态") + @SaCheckPermission("mall:promotion:edit") + @Log(title = "营销活动", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus/{id}") + public R changeStatus(@Parameter(description = "营销活动ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "状态(0启用 1禁用)", required = true) + @RequestParam @NotNull(message = "状态不能为空") Integer status) { + return toAjax(mallPromotionService.changeStatus(id, status)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallSeckillController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallSeckillController.java new file mode 100644 index 0000000..bceea3b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/admin/AdminMallSeckillController.java @@ -0,0 +1,126 @@ +package org.dromara.mall.promotion.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.promotion.domain.bo.MallSeckillBo; +import org.dromara.mall.promotion.domain.vo.MallSeckillVo; +import org.dromara.mall.promotion.service.MallSeckillService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 秒杀活动管理 + * + * @author pengles + */ +@Tag(name = "管理端-秒杀活动管理", description = "秒杀活动管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/seckill") +public class AdminMallSeckillController extends BaseController { + + private final MallSeckillService mallSeckillService; + + /** + * 查询秒杀活动列表 + */ + @Operation(summary = "查询秒杀活动列表", description = "分页查询秒杀活动列表") + @SaCheckPermission("mall:seckill:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "秒杀活动查询条件") MallSeckillBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallSeckillService.queryPageList(bo, pageQuery); + } + + /** + * 导出秒杀活动列表 + */ + @Operation(summary = "导出秒杀活动列表", description = "导出秒杀活动到Excel") + @SaCheckPermission("mall:seckill:export") + @Log(title = "秒杀活动", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "秒杀活动查询条件") MallSeckillBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallSeckillService.queryList(bo); + ExcelUtil.exportExcel(list, "秒杀活动", MallSeckillVo.class, response); + } + + /** + * 获取秒杀活动详细信息 + */ + @Operation(summary = "获取秒杀活动详情", description = "根据秒杀活动ID获取详细信息") + @SaCheckPermission("mall:seckill:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "秒杀活动ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallSeckillService.queryById(id)); + } + + /** + * 新增秒杀活动 + */ + @Operation(summary = "新增秒杀活动", description = "创建新的秒杀活动") + @SaCheckPermission("mall:seckill:add") + @Log(title = "秒杀活动", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "秒杀活动信息") @Validated @RequestBody MallSeckillBo bo) { + return toAjax(mallSeckillService.insertByBo(bo)); + } + + /** + * 修改秒杀活动 + */ + @Operation(summary = "修改秒杀活动", description = "更新秒杀活动信息") + @SaCheckPermission("mall:seckill:edit") + @Log(title = "秒杀活动", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "秒杀活动信息") @Validated @RequestBody MallSeckillBo bo) { + return toAjax(mallSeckillService.updateByBo(bo)); + } + + /** + * 删除秒杀活动 + */ + @Operation(summary = "删除秒杀活动", description = "删除秒杀活动") + @SaCheckPermission("mall:seckill:remove") + @Log(title = "秒杀活动", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "秒杀活动ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallSeckillService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 启用/禁用秒杀活动 + */ + @Operation(summary = "启用/禁用秒杀活动", description = "更改秒杀活动状态") + @SaCheckPermission("mall:seckill:edit") + @Log(title = "秒杀活动", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus/{id}") + public R changeStatus(@Parameter(description = "秒杀活动ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "状态(0启用 1禁用)", required = true) + @RequestParam @NotNull(message = "状态不能为空") Integer status) { + return toAjax(mallSeckillService.changeStatus(id, status)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallBargainController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallBargainController.java new file mode 100644 index 0000000..7c39376 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallBargainController.java @@ -0,0 +1,64 @@ +package org.dromara.mall.promotion.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.promotion.domain.vo.MallBargainVo; +import org.dromara.mall.promotion.service.MallBargainService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 砍价活动(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-砍价活动", description = "砍价活动接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/bargain") +public class AppMallBargainController extends BaseController { + + private final MallBargainService mallBargainService; + + /** + * 查询可用砍价活动列表 + */ + @Operation(summary = "查询可用砍价活动列表", description = "查询当前可用的砍价活动列表") + @GetMapping("/list") + public R> list() { + List list = mallBargainService.queryAvailableList(); + return R.ok(list); + } + + /** + * 获取砍价活动详情 + */ + @Operation(summary = "获取砍价活动详情", description = "根据砍价活动ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "砍价活动ID", required = true) + @PathVariable Long id) { + return R.ok(mallBargainService.queryById(id)); + } + + /** + * 根据SKU ID查询砍价活动 + */ + @Operation(summary = "根据SKU ID查询砍价活动", description = "查询指定SKU的砍价活动") + @GetMapping("/sku/{skuId}") + public R getBySkuId(@Parameter(description = "SKU ID", required = true) + @PathVariable Long skuId) { + MallBargainVo vo = mallBargainService.queryBySkuId(skuId); + return R.ok(vo); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallGroupBuyController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallGroupBuyController.java new file mode 100644 index 0000000..a1f5a73 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallGroupBuyController.java @@ -0,0 +1,64 @@ +package org.dromara.mall.promotion.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.promotion.domain.vo.MallGroupBuyVo; +import org.dromara.mall.promotion.service.MallGroupBuyService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 拼团活动(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-拼团活动", description = "拼团活动接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/groupbuy") +public class AppMallGroupBuyController extends BaseController { + + private final MallGroupBuyService mallGroupBuyService; + + /** + * 查询可用拼团活动列表 + */ + @Operation(summary = "查询可用拼团活动列表", description = "查询当前可用的拼团活动列表") + @GetMapping("/list") + public R> list() { + List list = mallGroupBuyService.queryAvailableList(); + return R.ok(list); + } + + /** + * 获取拼团活动详情 + */ + @Operation(summary = "获取拼团活动详情", description = "根据拼团活动ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "拼团活动ID", required = true) + @PathVariable Long id) { + return R.ok(mallGroupBuyService.queryById(id)); + } + + /** + * 根据SKU ID查询拼团活动 + */ + @Operation(summary = "根据SKU ID查询拼团活动", description = "查询指定SKU的拼团活动") + @GetMapping("/sku/{skuId}") + public R getBySkuId(@Parameter(description = "SKU ID", required = true) + @PathVariable Long skuId) { + MallGroupBuyVo vo = mallGroupBuyService.queryBySkuId(skuId); + return R.ok(vo); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallPromotionController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallPromotionController.java new file mode 100644 index 0000000..a83e2f3 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallPromotionController.java @@ -0,0 +1,57 @@ +package org.dromara.mall.promotion.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.promotion.domain.bo.MallPromotionBo; +import org.dromara.mall.promotion.domain.vo.MallPromotionVo; +import org.dromara.mall.promotion.service.MallPromotionService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 营销活动(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-营销活动", description = "营销活动接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/promotion") +public class AppMallPromotionController extends BaseController { + + private final MallPromotionService mallPromotionService; + + /** + * 查询营销活动列表(按类型) + */ + @Operation(summary = "查询营销活动列表", description = "根据活动类型查询启用的营销活动列表") + @GetMapping("/list") + public R> list(@Parameter(description = "活动类型(0普通活动 1拼团 2秒杀 3砍价)") + @RequestParam(required = false) Integer type) { + List list; + if (type != null) { + list = mallPromotionService.queryListByType(type); + } else { + list = mallPromotionService.queryList(new MallPromotionBo()); + } + return R.ok(list); + } + + /** + * 获取营销活动详情 + */ + @Operation(summary = "获取营销活动详情", description = "根据营销活动ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "营销活动ID", required = true) + @PathVariable Long id) { + return R.ok(mallPromotionService.queryById(id)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallSeckillController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallSeckillController.java new file mode 100644 index 0000000..920f1ae --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/controller/app/AppMallSeckillController.java @@ -0,0 +1,64 @@ +package org.dromara.mall.promotion.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.promotion.domain.vo.MallSeckillVo; +import org.dromara.mall.promotion.service.MallSeckillService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 秒杀活动(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-秒杀活动", description = "秒杀活动接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/seckill") +public class AppMallSeckillController extends BaseController { + + private final MallSeckillService mallSeckillService; + + /** + * 查询可用秒杀活动列表 + */ + @Operation(summary = "查询可用秒杀活动列表", description = "查询当前可用的秒杀活动列表") + @GetMapping("/list") + public R> list() { + List list = mallSeckillService.queryAvailableList(); + return R.ok(list); + } + + /** + * 获取秒杀活动详情 + */ + @Operation(summary = "获取秒杀活动详情", description = "根据秒杀活动ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "秒杀活动ID", required = true) + @PathVariable Long id) { + return R.ok(mallSeckillService.queryById(id)); + } + + /** + * 根据SKU ID查询秒杀活动 + */ + @Operation(summary = "根据SKU ID查询秒杀活动", description = "查询指定SKU的秒杀活动") + @GetMapping("/sku/{skuId}") + public R getBySkuId(@Parameter(description = "SKU ID", required = true) + @PathVariable Long skuId) { + MallSeckillVo vo = mallSeckillService.queryBySkuId(skuId); + return R.ok(vo); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallBargain.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallBargain.java new file mode 100644 index 0000000..592a4fa --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallBargain.java @@ -0,0 +1,102 @@ +package org.dromara.mall.promotion.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 砍价活动对象 mall_bargain + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_bargain") +public class MallBargain implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 砍价ID + */ + @TableId + private Long id; + + /** + * 活动ID + */ + private Long promotionId; + + /** + * SKU ID + */ + private Long skuId; + + /** + * 砍后价(分) + */ + private Integer bargainPrice; + + /** + * 原价(分) + */ + private Integer originalPrice; + + /** + * 砍价库存 + */ + private Integer bargainStock; + + /** + * 最低价(分) + */ + private Integer minPrice; + + /** + * 需要助力人数 + */ + private Integer helpCount; + + /** + * 砍价有效期(小时) + */ + private Integer durationHours; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallGroupBuy.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallGroupBuy.java new file mode 100644 index 0000000..84c6f67 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallGroupBuy.java @@ -0,0 +1,87 @@ +package org.dromara.mall.promotion.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 拼团活动对象 mall_group_buy + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_group_buy") +public class MallGroupBuy implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 拼团ID + */ + @TableId + private Long id; + + /** + * 活动ID + */ + private Long promotionId; + + /** + * SKU ID + */ + private Long skuId; + + /** + * 拼团价(分) + */ + private Integer groupPrice; + + /** + * 成团人数 + */ + private Integer groupSize; + + /** + * 拼团有效期(小时) + */ + private Integer durationHours; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallGroupBuyOrder.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallGroupBuyOrder.java new file mode 100644 index 0000000..4f03cd7 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallGroupBuyOrder.java @@ -0,0 +1,71 @@ +package org.dromara.mall.promotion.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 拼团订单对象 mall_group_buy_order + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_group_buy_order") +public class MallGroupBuyOrder implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * ID + */ + @TableId + private Long id; + + /** + * 拼团ID + */ + private Long groupBuyId; + + /** + * 拼团编号 + */ + private String groupNo; + + /** + * 订单ID + */ + private Long orderId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 状态(0拼团中 1已成团 2已失败) + */ + private Integer status; + + /** + * 成团时间 + */ + private Date successTime; + + /** + * 过期时间 + */ + private Date expireTime; + + /** + * 创建时间 + */ + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallPromotion.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallPromotion.java new file mode 100644 index 0000000..927b2b0 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallPromotion.java @@ -0,0 +1,92 @@ +package org.dromara.mall.promotion.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 营销活动对象 mall_promotion + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_promotion") +public class MallPromotion implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 活动ID + */ + @TableId + private Long id; + + /** + * 活动名称 + */ + private String name; + + /** + * 活动类型(0普通活动 1拼团 2秒杀 3砍价) + */ + private Integer type; + + /** + * 开始时间 + */ + private Date startTime; + + /** + * 结束时间 + */ + private Date endTime; + + /** + * 活动规则(JSON) + */ + private String rules; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallSeckill.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallSeckill.java new file mode 100644 index 0000000..b424a98 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/MallSeckill.java @@ -0,0 +1,92 @@ +package org.dromara.mall.promotion.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 秒杀活动对象 mall_seckill + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_seckill") +public class MallSeckill implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 秒杀ID + */ + @TableId + private Long id; + + /** + * 活动ID + */ + private Long promotionId; + + /** + * SKU ID + */ + private Long skuId; + + /** + * 秒杀价(分) + */ + private Integer seckillPrice; + + /** + * 秒杀库存 + */ + private Integer seckillStock; + + /** + * 开始时间 + */ + private Date startTime; + + /** + * 结束时间 + */ + private Date endTime; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallBargainBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallBargainBo.java new file mode 100644 index 0000000..6cf2510 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallBargainBo.java @@ -0,0 +1,86 @@ +package org.dromara.mall.promotion.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.promotion.domain.MallBargain; + +/** + * 砍价活动业务对象 mall_bargain + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallBargain.class, reverseConvertGenerate = false) +public class MallBargainBo extends BaseEntity { + + /** + * 砍价ID + */ + @NotNull(message = "砍价ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 活动ID + */ + @NotNull(message = "活动ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long promotionId; + + /** + * SKU ID + */ + @NotNull(message = "SKU ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long skuId; + + /** + * 砍后价(分) + */ + @NotNull(message = "砍后价不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 1, message = "砍后价必须大于0") + private Integer bargainPrice; + + /** + * 原价(分) + */ + @NotNull(message = "原价不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 1, message = "原价必须大于0") + private Integer originalPrice; + + /** + * 砍价库存 + */ + @NotNull(message = "砍价库存不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 1, message = "砍价库存必须大于0") + private Integer bargainStock; + + /** + * 最低价(分) + */ + @NotNull(message = "最低价不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 1, message = "最低价必须大于0") + private Integer minPrice; + + /** + * 需要助力人数 + */ + @Min(value = 0, message = "需要助力人数不能为负数") + private Integer helpCount; + + /** + * 砍价有效期(小时) + */ + @Min(value = 1, message = "砍价有效期必须大于0") + private Integer durationHours; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallGroupBuyBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallGroupBuyBo.java new file mode 100644 index 0000000..f05f657 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallGroupBuyBo.java @@ -0,0 +1,66 @@ +package org.dromara.mall.promotion.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.promotion.domain.MallGroupBuy; + +/** + * 拼团活动业务对象 mall_group_buy + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallGroupBuy.class, reverseConvertGenerate = false) +public class MallGroupBuyBo extends BaseEntity { + + /** + * 拼团ID + */ + @NotNull(message = "拼团ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 活动ID + */ + @NotNull(message = "活动ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long promotionId; + + /** + * SKU ID + */ + @NotNull(message = "SKU ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long skuId; + + /** + * 拼团价(分) + */ + @NotNull(message = "拼团价不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 1, message = "拼团价必须大于0") + private Integer groupPrice; + + /** + * 成团人数 + */ + @NotNull(message = "成团人数不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 2, message = "成团人数至少为2人") + private Integer groupSize; + + /** + * 拼团有效期(小时) + */ + @Min(value = 1, message = "拼团有效期必须大于0") + private Integer durationHours; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallPromotionBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallPromotionBo.java new file mode 100644 index 0000000..9f23cbf --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallPromotionBo.java @@ -0,0 +1,73 @@ +package org.dromara.mall.promotion.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.promotion.domain.MallPromotion; + +import java.util.Date; + +/** + * 营销活动业务对象 mall_promotion + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallPromotion.class, reverseConvertGenerate = false) +public class MallPromotionBo extends BaseEntity { + + /** + * 活动ID + */ + @NotNull(message = "活动ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 活动名称 + */ + @NotBlank(message = "活动名称不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 128, message = "活动名称长度不能超过128个字符") + private String name; + + /** + * 活动类型(0普通活动 1拼团 2秒杀 3砍价) + */ + @NotNull(message = "活动类型不能为空", groups = {AddGroup.class, EditGroup.class}) + private Integer type; + + /** + * 开始时间 + */ + @NotNull(message = "开始时间不能为空", groups = {AddGroup.class, EditGroup.class}) + private Date startTime; + + /** + * 结束时间 + */ + @NotNull(message = "结束时间不能为空", groups = {AddGroup.class, EditGroup.class}) + private Date endTime; + + /** + * 活动规则(JSON) + */ + private String rules; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + + /** + * 备注 + */ + @Size(max = 512, message = "备注长度不能超过512个字符") + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallSeckillBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallSeckillBo.java new file mode 100644 index 0000000..f8901a4 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/bo/MallSeckillBo.java @@ -0,0 +1,74 @@ +package org.dromara.mall.promotion.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.promotion.domain.MallSeckill; + +import java.util.Date; + +/** + * 秒杀活动业务对象 mall_seckill + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallSeckill.class, reverseConvertGenerate = false) +public class MallSeckillBo extends BaseEntity { + + /** + * 秒杀ID + */ + @NotNull(message = "秒杀ID不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 活动ID + */ + @NotNull(message = "活动ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long promotionId; + + /** + * SKU ID + */ + @NotNull(message = "SKU ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long skuId; + + /** + * 秒杀价(分) + */ + @NotNull(message = "秒杀价不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 1, message = "秒杀价必须大于0") + private Integer seckillPrice; + + /** + * 秒杀库存 + */ + @NotNull(message = "秒杀库存不能为空", groups = {AddGroup.class, EditGroup.class}) + @Min(value = 1, message = "秒杀库存必须大于0") + private Integer seckillStock; + + /** + * 开始时间 + */ + @NotNull(message = "开始时间不能为空", groups = {AddGroup.class, EditGroup.class}) + private Date startTime; + + /** + * 结束时间 + */ + @NotNull(message = "结束时间不能为空", groups = {AddGroup.class, EditGroup.class}) + private Date endTime; + + /** + * 状态(0启用 1禁用) + */ + private Integer status; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallBargainVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallBargainVo.java new file mode 100644 index 0000000..f901142 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallBargainVo.java @@ -0,0 +1,98 @@ +package org.dromara.mall.promotion.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.promotion.domain.MallBargain; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 砍价活动视图对象 mall_bargain + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = MallBargain.class) +public class MallBargainVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 砍价ID + */ + @ExcelProperty(value = "砍价ID") + private Long id; + + /** + * 活动ID + */ + @ExcelProperty(value = "活动ID") + private Long promotionId; + + /** + * SKU ID + */ + @ExcelProperty(value = "SKU ID") + private Long skuId; + + /** + * 砍后价(分) + */ + @ExcelProperty(value = "砍后价(分)") + private Integer bargainPrice; + + /** + * 原价(分) + */ + @ExcelProperty(value = "原价(分)") + private Integer originalPrice; + + /** + * 砍价库存 + */ + @ExcelProperty(value = "砍价库存") + private Integer bargainStock; + + /** + * 最低价(分) + */ + @ExcelProperty(value = "最低价(分)") + private Integer minPrice; + + /** + * 需要助力人数 + */ + @ExcelProperty(value = "需要助力人数") + private Integer helpCount; + + /** + * 砍价有效期(小时) + */ + @ExcelProperty(value = "砍价有效期(小时)") + private Integer durationHours; + + /** + * 状态(0启用 1禁用) + */ + @ExcelProperty(value = "状态") + private Integer status; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallGroupBuyVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallGroupBuyVo.java new file mode 100644 index 0000000..5e1ebf0 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallGroupBuyVo.java @@ -0,0 +1,80 @@ +package org.dromara.mall.promotion.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.promotion.domain.MallGroupBuy; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 拼团活动视图对象 mall_group_buy + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = MallGroupBuy.class) +public class MallGroupBuyVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 拼团ID + */ + @ExcelProperty(value = "拼团ID") + private Long id; + + /** + * 活动ID + */ + @ExcelProperty(value = "活动ID") + private Long promotionId; + + /** + * SKU ID + */ + @ExcelProperty(value = "SKU ID") + private Long skuId; + + /** + * 拼团价(分) + */ + @ExcelProperty(value = "拼团价(分)") + private Integer groupPrice; + + /** + * 成团人数 + */ + @ExcelProperty(value = "成团人数") + private Integer groupSize; + + /** + * 拼团有效期(小时) + */ + @ExcelProperty(value = "拼团有效期(小时)") + private Integer durationHours; + + /** + * 状态(0启用 1禁用) + */ + @ExcelProperty(value = "状态") + private Integer status; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallPromotionVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallPromotionVo.java new file mode 100644 index 0000000..f9faf47 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallPromotionVo.java @@ -0,0 +1,86 @@ +package org.dromara.mall.promotion.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.promotion.domain.MallPromotion; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 营销活动视图对象 mall_promotion + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = MallPromotion.class) +public class MallPromotionVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 活动ID + */ + @ExcelProperty(value = "活动ID") + private Long id; + + /** + * 活动名称 + */ + @ExcelProperty(value = "活动名称") + private String name; + + /** + * 活动类型(0普通活动 1拼团 2秒杀 3砍价) + */ + @ExcelProperty(value = "活动类型") + private Integer type; + + /** + * 开始时间 + */ + @ExcelProperty(value = "开始时间") + private Date startTime; + + /** + * 结束时间 + */ + @ExcelProperty(value = "结束时间") + private Date endTime; + + /** + * 活动规则(JSON) + */ + @ExcelProperty(value = "活动规则") + private String rules; + + /** + * 状态(0启用 1禁用) + */ + @ExcelProperty(value = "状态") + private Integer status; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallSeckillVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallSeckillVo.java new file mode 100644 index 0000000..c93aa5b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/domain/vo/MallSeckillVo.java @@ -0,0 +1,86 @@ +package org.dromara.mall.promotion.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.promotion.domain.MallSeckill; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 秒杀活动视图对象 mall_seckill + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = MallSeckill.class) +public class MallSeckillVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 秒杀ID + */ + @ExcelProperty(value = "秒杀ID") + private Long id; + + /** + * 活动ID + */ + @ExcelProperty(value = "活动ID") + private Long promotionId; + + /** + * SKU ID + */ + @ExcelProperty(value = "SKU ID") + private Long skuId; + + /** + * 秒杀价(分) + */ + @ExcelProperty(value = "秒杀价(分)") + private Integer seckillPrice; + + /** + * 秒杀库存 + */ + @ExcelProperty(value = "秒杀库存") + private Integer seckillStock; + + /** + * 开始时间 + */ + @ExcelProperty(value = "开始时间") + private Date startTime; + + /** + * 结束时间 + */ + @ExcelProperty(value = "结束时间") + private Date endTime; + + /** + * 状态(0启用 1禁用) + */ + @ExcelProperty(value = "状态") + private Integer status; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/enums/PromotionType.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/enums/PromotionType.java new file mode 100644 index 0000000..e6deeab --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/enums/PromotionType.java @@ -0,0 +1,57 @@ +package org.dromara.mall.promotion.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 营销活动类型枚举 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum PromotionType { + + /** + * 普通活动 + */ + NORMAL(0, "普通活动"), + + /** + * 拼团活动 + */ + GROUP_BUY(1, "拼团活动"), + + /** + * 秒杀活动 + */ + SECKILL(2, "秒杀活动"), + + /** + * 砍价活动 + */ + BARGAIN(3, "砍价活动"); + + /** + * 类型值 + */ + private final Integer value; + + /** + * 类型名称 + */ + private final String name; + + /** + * 根据值获取枚举 + */ + public static PromotionType getByValue(Integer value) { + for (PromotionType type : values()) { + if (type.getValue().equals(value)) { + return type; + } + } + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/job/MallPromotionJob.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/job/MallPromotionJob.java new file mode 100644 index 0000000..5501039 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/job/MallPromotionJob.java @@ -0,0 +1,78 @@ +package org.dromara.mall.promotion.job; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.mall.promotion.domain.MallSeckill; +import org.dromara.mall.promotion.mapper.MallSeckillMapper; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.List; + +/** + * 营销活动定时任务 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class MallPromotionJob { + + private final MallSeckillMapper seckillMapper; + + /** + * 秒杀活动状态更新任务 + * 每5分钟执行一次 + * - 扫描即将开始的活动(start_time <= 当前时间 且 status=1禁用),自动启用(status=0) + * - 扫描已结束的活动(end_time < 当前时间 且 status=0启用),自动禁用(status=1) + */ + @Scheduled(cron = "0 0/5 * * * ?") + public void updateSeckillStatus() { + Date now = new Date(); + int updatedCount = 0; + + // 扫描即将开始的活动(start_time <= 当前时间 且 status=1禁用),自动启用 + LambdaQueryWrapper startWrapper = new LambdaQueryWrapper<>(); + startWrapper.le(MallSeckill::getStartTime, now); + startWrapper.gt(MallSeckill::getEndTime, now); // 还未结束 + startWrapper.eq(MallSeckill::getStatus, 1); // 当前状态为禁用 + List toStart = seckillMapper.selectList(startWrapper); + + if (!toStart.isEmpty()) { + for (MallSeckill seckill : toStart) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(MallSeckill::getId, seckill.getId()); + updateWrapper.set(MallSeckill::getStatus, 0); // 启用 + seckillMapper.update(null, updateWrapper); + updatedCount++; + log.info("秒杀活动自动启用: id={}, promotionId={}", seckill.getId(), seckill.getPromotionId()); + } + } + + // 扫描已结束的活动(end_time < 当前时间 且 status=0启用),自动禁用 + LambdaQueryWrapper endWrapper = new LambdaQueryWrapper<>(); + endWrapper.lt(MallSeckill::getEndTime, now); + endWrapper.eq(MallSeckill::getStatus, 0); // 当前状态为启用 + List toEnd = seckillMapper.selectList(endWrapper); + + if (!toEnd.isEmpty()) { + for (MallSeckill seckill : toEnd) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(MallSeckill::getId, seckill.getId()); + updateWrapper.set(MallSeckill::getStatus, 1); // 禁用 + seckillMapper.update(null, updateWrapper); + updatedCount++; + log.info("秒杀活动自动禁用: id={}, promotionId={}", seckill.getId(), seckill.getPromotionId()); + } + } + + if (updatedCount > 0) { + log.info("秒杀活动状态更新完成,共更新{}个活动", updatedCount); + } + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallBargainMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallBargainMapper.java new file mode 100644 index 0000000..afe3dcf --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallBargainMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.promotion.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.promotion.domain.MallBargain; +import org.dromara.mall.promotion.domain.vo.MallBargainVo; + +/** + * 砍价活动Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallBargainMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallGroupBuyMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallGroupBuyMapper.java new file mode 100644 index 0000000..bf925c3 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallGroupBuyMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.promotion.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.promotion.domain.MallGroupBuy; +import org.dromara.mall.promotion.domain.vo.MallGroupBuyVo; + +/** + * 拼团活动Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallGroupBuyMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallPromotionMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallPromotionMapper.java new file mode 100644 index 0000000..16e36b5 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallPromotionMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.promotion.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.promotion.domain.MallPromotion; +import org.dromara.mall.promotion.domain.vo.MallPromotionVo; + +/** + * 营销活动Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallPromotionMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallSeckillMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallSeckillMapper.java new file mode 100644 index 0000000..eefe0d5 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/mapper/MallSeckillMapper.java @@ -0,0 +1,35 @@ +package org.dromara.mall.promotion.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.promotion.domain.MallSeckill; +import org.dromara.mall.promotion.domain.vo.MallSeckillVo; + +/** + * 秒杀活动Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallSeckillMapper extends BaseMapperPlus { + + /** + * 扣减秒杀库存 + * + * @param id 秒杀ID + * @param quantity 扣减数量 + * @return 影响行数 + */ + int reduceStock(@Param("id") Long id, @Param("quantity") Integer quantity); + + /** + * 恢复秒杀库存 + * + * @param id 秒杀ID + * @param quantity 恢复数量 + * @return 影响行数 + */ + int restoreStock(@Param("id") Long id, @Param("quantity") Integer quantity); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallBargainService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallBargainService.java new file mode 100644 index 0000000..d528b0f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallBargainService.java @@ -0,0 +1,63 @@ +package org.dromara.mall.promotion.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.promotion.domain.bo.MallBargainBo; +import org.dromara.mall.promotion.domain.vo.MallBargainVo; + +import java.util.Collection; +import java.util.List; + +/** + * 砍价活动Service接口 + * + * @author pengles + */ +public interface MallBargainService { + + /** + * 查询砍价活动 + */ + MallBargainVo queryById(Long id); + + /** + * 查询砍价活动列表 + */ + TableDataInfo queryPageList(MallBargainBo bo, PageQuery pageQuery); + + /** + * 查询砍价活动列表 + */ + List queryList(MallBargainBo bo); + + /** + * 查询可用的砍价活动列表(移动端) + */ + List queryAvailableList(); + + /** + * 根据SKU ID查询砍价活动 + */ + MallBargainVo queryBySkuId(Long skuId); + + /** + * 新增砍价活动 + */ + Boolean insertByBo(MallBargainBo bo); + + /** + * 修改砍价活动 + */ + Boolean updateByBo(MallBargainBo bo); + + /** + * 校验并批量删除砍价活动信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 启用/禁用砍价活动 + */ + Boolean changeStatus(Long id, Integer status); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallGroupBuyService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallGroupBuyService.java new file mode 100644 index 0000000..a1b8052 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallGroupBuyService.java @@ -0,0 +1,63 @@ +package org.dromara.mall.promotion.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.promotion.domain.bo.MallGroupBuyBo; +import org.dromara.mall.promotion.domain.vo.MallGroupBuyVo; + +import java.util.Collection; +import java.util.List; + +/** + * 拼团活动Service接口 + * + * @author pengles + */ +public interface MallGroupBuyService { + + /** + * 查询拼团活动 + */ + MallGroupBuyVo queryById(Long id); + + /** + * 查询拼团活动列表 + */ + TableDataInfo queryPageList(MallGroupBuyBo bo, PageQuery pageQuery); + + /** + * 查询拼团活动列表 + */ + List queryList(MallGroupBuyBo bo); + + /** + * 查询可用的拼团活动列表(移动端) + */ + List queryAvailableList(); + + /** + * 根据SKU ID查询拼团活动 + */ + MallGroupBuyVo queryBySkuId(Long skuId); + + /** + * 新增拼团活动 + */ + Boolean insertByBo(MallGroupBuyBo bo); + + /** + * 修改拼团活动 + */ + Boolean updateByBo(MallGroupBuyBo bo); + + /** + * 校验并批量删除拼团活动信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 启用/禁用拼团活动 + */ + Boolean changeStatus(Long id, Integer status); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallPromotionService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallPromotionService.java new file mode 100644 index 0000000..35dc713 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallPromotionService.java @@ -0,0 +1,60 @@ +package org.dromara.mall.promotion.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.promotion.domain.MallPromotion; +import org.dromara.mall.promotion.domain.bo.MallPromotionBo; +import org.dromara.mall.promotion.domain.vo.MallPromotionVo; + +import java.util.Collection; +import java.util.List; + +/** + * 营销活动Service接口 + * + * @author pengles + */ +public interface MallPromotionService extends IService { + + /** + * 查询营销活动 + */ + MallPromotionVo queryById(Long id); + + /** + * 查询营销活动列表 + */ + TableDataInfo queryPageList(MallPromotionBo bo, PageQuery pageQuery); + + /** + * 查询营销活动列表 + */ + List queryList(MallPromotionBo bo); + + /** + * 新增营销活动 + */ + Boolean insertByBo(MallPromotionBo bo); + + /** + * 修改营销活动 + */ + Boolean updateByBo(MallPromotionBo bo); + + /** + * 校验并批量删除营销活动信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 启用/禁用营销活动 + */ + Boolean changeStatus(Long id, Integer status); + + /** + * 根据活动类型查询活动列表 + */ + List queryListByType(Integer type); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallSeckillService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallSeckillService.java new file mode 100644 index 0000000..850f06a --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/MallSeckillService.java @@ -0,0 +1,89 @@ +package org.dromara.mall.promotion.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.promotion.domain.bo.MallSeckillBo; +import org.dromara.mall.promotion.domain.vo.MallSeckillVo; + +import java.util.Collection; +import java.util.List; + +/** + * 秒杀活动Service接口 + * + * @author pengles + */ +public interface MallSeckillService { + + /** + * 查询秒杀活动 + */ + MallSeckillVo queryById(Long id); + + /** + * 查询秒杀活动列表 + */ + TableDataInfo queryPageList(MallSeckillBo bo, PageQuery pageQuery); + + /** + * 查询秒杀活动列表 + */ + List queryList(MallSeckillBo bo); + + /** + * 查询可用的秒杀活动列表(移动端) + */ + List queryAvailableList(); + + /** + * 根据SKU ID查询秒杀活动 + */ + MallSeckillVo queryBySkuId(Long skuId); + + /** + * 新增秒杀活动 + */ + Boolean insertByBo(MallSeckillBo bo); + + /** + * 修改秒杀活动 + */ + Boolean updateByBo(MallSeckillBo bo); + + /** + * 校验并批量删除秒杀活动信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 启用/禁用秒杀活动 + */ + Boolean changeStatus(Long id, Integer status); + + /** + * 扣减秒杀库存 + * + * @param id 秒杀ID + * @param quantity 扣减数量 + * @return 是否成功 + */ + Boolean reduceStock(Long id, Integer quantity); + + /** + * 恢复秒杀库存 + * + * @param id 秒杀ID + * @param quantity 恢复数量 + * @return 是否成功 + */ + Boolean restoreStock(Long id, Integer quantity); + + /** + * 检查秒杀活动是否可用 + * + * @param id 秒杀ID + * @return 是否可用 + */ + Boolean checkAvailable(Long id); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallBargainServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallBargainServiceImpl.java new file mode 100644 index 0000000..b596592 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallBargainServiceImpl.java @@ -0,0 +1,210 @@ +package org.dromara.mall.promotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.promotion.domain.MallBargain; +import org.dromara.mall.promotion.domain.MallPromotion; +import org.dromara.mall.promotion.domain.bo.MallBargainBo; +import org.dromara.mall.promotion.domain.vo.MallBargainVo; +import org.dromara.mall.promotion.mapper.MallBargainMapper; +import org.dromara.mall.promotion.mapper.MallPromotionMapper; +import org.dromara.mall.promotion.service.MallBargainService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 砍价活动Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallBargainServiceImpl extends ServiceImpl implements MallBargainService { + + private final MallBargainMapper baseMapper; + private final MallPromotionMapper promotionMapper; + + /** + * 查询砍价活动 + */ + @Override + public MallBargainVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询砍价活动列表 + */ + @Override + public TableDataInfo queryPageList(MallBargainBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询砍价活动列表 + */ + @Override + public List queryList(MallBargainBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallBargainBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getPromotionId() != null, MallBargain::getPromotionId, bo.getPromotionId()); + lqw.eq(bo.getSkuId() != null, MallBargain::getSkuId, bo.getSkuId()); + lqw.eq(bo.getStatus() != null, MallBargain::getStatus, bo.getStatus()); + lqw.orderByDesc(MallBargain::getCreateTime); + return lqw; + } + + /** + * 查询可用的砍价活动列表(移动端) + */ + @Override + public List queryAvailableList() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(MallBargain::getStatus, 0); // 启用状态 + lqw.gt(MallBargain::getBargainStock, 0); // 有库存 + lqw.orderByDesc(MallBargain::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据SKU ID查询砍价活动 + */ + @Override + public MallBargainVo queryBySkuId(Long skuId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(MallBargain::getSkuId, skuId); + lqw.eq(MallBargain::getStatus, 0); // 启用状态 + lqw.gt(MallBargain::getBargainStock, 0); // 有库存 + lqw.orderByDesc(MallBargain::getCreateTime); + lqw.last("LIMIT 1"); + return baseMapper.selectVoOne(lqw); + } + + /** + * 新增砍价活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallBargainBo bo) { + MallBargain add = MapstructUtils.convert(bo, MallBargain.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getStatus() == null) { + add.setStatus(0); // 默认启用 + } + if (add.getDurationHours() == null) { + add.setDurationHours(24); // 默认24小时 + } + if (add.getHelpCount() == null) { + add.setHelpCount(0); // 默认0人 + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改砍价活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallBargainBo bo) { + MallBargain update = MapstructUtils.convert(bo, MallBargain.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallBargain entity) { + // TODO: 可以添加业务校验逻辑,如检查活动是否存在、SKU是否存在等 + if (entity.getOriginalPrice() != null && entity.getBargainPrice() != null) { + if (entity.getBargainPrice() >= entity.getOriginalPrice()) { + throw new ServiceException("砍后价必须小于原价"); + } + } + if (entity.getMinPrice() != null && entity.getBargainPrice() != null) { + if (entity.getBargainPrice() < entity.getMinPrice()) { + throw new ServiceException("砍后价不能小于最低价"); + } + } + if (entity.getMinPrice() != null && entity.getOriginalPrice() != null) { + if (entity.getMinPrice() >= entity.getOriginalPrice()) { + throw new ServiceException("最低价必须小于原价"); + } + } + } + + /** + * 批量删除砍价活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + Date now = new Date(); + for (Long id : ids) { + MallBargain bargain = baseMapper.selectById(id); + if (bargain == null) { + continue; + } + + // 检查关联的营销活动是否正在进行中 + if (bargain.getPromotionId() != null) { + MallPromotion promotion = promotionMapper.selectById(bargain.getPromotionId()); + if (promotion != null && promotion.getStartTime() != null && promotion.getEndTime() != null) { + // 判断当前时间是否在活动时间范围内(开始时间 <= 当前时间 <= 结束时间) + if (!promotion.getStartTime().after(now) && !promotion.getEndTime().before(now)) { + throw new ServiceException("砍价活动ID[" + id + "]关联的营销活动正在进行中,不能删除"); + } + } + } + + // 注意:如果有砍价记录表(mall_bargain_record),还可以检查是否有进行中的砍价记录(status=0) + // 但由于当前模块中可能还未实现MallBargainRecordMapper,此处暂不添加该检查 + // 如需添加,可注入MallBargainRecordMapper,然后查询count(bargain_id=? AND status=0) > 0 + } + } + return baseMapper.deleteByIds(ids) > 0; + } + + /** + * 启用/禁用砍价活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean changeStatus(Long id, Integer status) { + MallBargain bargain = baseMapper.selectById(id); + if (bargain == null) { + throw new ServiceException("砍价活动不存在"); + } + + MallBargain update = new MallBargain(); + update.setId(id); + update.setStatus(status); + return baseMapper.updateById(update) > 0; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallGroupBuyServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallGroupBuyServiceImpl.java new file mode 100644 index 0000000..d75857f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallGroupBuyServiceImpl.java @@ -0,0 +1,169 @@ +package org.dromara.mall.promotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.promotion.domain.MallGroupBuy; +import org.dromara.mall.promotion.domain.bo.MallGroupBuyBo; +import org.dromara.mall.promotion.domain.vo.MallGroupBuyVo; +import org.dromara.mall.promotion.mapper.MallGroupBuyMapper; +import org.dromara.mall.promotion.service.MallGroupBuyService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 拼团活动Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallGroupBuyServiceImpl extends ServiceImpl implements MallGroupBuyService { + + private final MallGroupBuyMapper baseMapper; + + /** + * 查询拼团活动 + */ + @Override + public MallGroupBuyVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询拼团活动列表 + */ + @Override + public TableDataInfo queryPageList(MallGroupBuyBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询拼团活动列表 + */ + @Override + public List queryList(MallGroupBuyBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallGroupBuyBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getPromotionId() != null, MallGroupBuy::getPromotionId, bo.getPromotionId()); + lqw.eq(bo.getSkuId() != null, MallGroupBuy::getSkuId, bo.getSkuId()); + lqw.eq(bo.getStatus() != null, MallGroupBuy::getStatus, bo.getStatus()); + lqw.orderByDesc(MallGroupBuy::getCreateTime); + return lqw; + } + + /** + * 查询可用的拼团活动列表(移动端) + */ + @Override + public List queryAvailableList() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(MallGroupBuy::getStatus, 0); // 启用状态 + lqw.orderByDesc(MallGroupBuy::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据SKU ID查询拼团活动 + */ + @Override + public MallGroupBuyVo queryBySkuId(Long skuId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(MallGroupBuy::getSkuId, skuId); + lqw.eq(MallGroupBuy::getStatus, 0); // 启用状态 + lqw.orderByDesc(MallGroupBuy::getCreateTime); + lqw.last("LIMIT 1"); + return baseMapper.selectVoOne(lqw); + } + + /** + * 新增拼团活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallGroupBuyBo bo) { + MallGroupBuy add = MapstructUtils.convert(bo, MallGroupBuy.class); + validEntityBeforeSave(add); + + // 设置默认值 + if (add.getStatus() == null) { + add.setStatus(0); // 默认启用 + } + if (add.getDurationHours() == null) { + add.setDurationHours(24); // 默认24小时 + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改拼团活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallGroupBuyBo bo) { + MallGroupBuy update = MapstructUtils.convert(bo, MallGroupBuy.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallGroupBuy entity) { + // TODO: 可以添加业务校验逻辑,如检查活动是否存在、SKU是否存在等 + if (entity.getGroupSize() != null && entity.getGroupSize() < 2) { + throw new ServiceException("成团人数至少为2人"); + } + } + + /** + * 批量删除拼团活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO: 可以添加业务校验逻辑,如检查是否有进行中的拼团活动等 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 启用/禁用拼团活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean changeStatus(Long id, Integer status) { + MallGroupBuy groupBuy = baseMapper.selectById(id); + if (groupBuy == null) { + throw new ServiceException("拼团活动不存在"); + } + + MallGroupBuy update = new MallGroupBuy(); + update.setId(id); + update.setStatus(status); + return baseMapper.updateById(update) > 0; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallPromotionServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallPromotionServiceImpl.java new file mode 100644 index 0000000..ecc2d34 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallPromotionServiceImpl.java @@ -0,0 +1,173 @@ +package org.dromara.mall.promotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.promotion.domain.MallPromotion; +import org.dromara.mall.promotion.domain.bo.MallPromotionBo; +import org.dromara.mall.promotion.domain.vo.MallPromotionVo; +import org.dromara.mall.promotion.mapper.MallPromotionMapper; +import org.dromara.mall.promotion.service.MallPromotionService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 营销活动Service业务层处理 + * + * @author pengles + */ +@Service +public class MallPromotionServiceImpl extends ServiceImpl implements MallPromotionService { + + /** + * 查询营销活动 + */ + @Override + public MallPromotionVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询营销活动列表 + */ + @Override + public TableDataInfo queryPageList(MallPromotionBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询营销活动列表 + */ + @Override + public List queryList(MallPromotionBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallPromotionBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getName()), MallPromotion::getName, bo.getName()); + lqw.eq(bo.getType() != null, MallPromotion::getType, bo.getType()); + lqw.eq(bo.getStatus() != null, MallPromotion::getStatus, bo.getStatus()); + lqw.ge(bo.getStartTime() != null, MallPromotion::getStartTime, bo.getStartTime()); + lqw.le(bo.getEndTime() != null, MallPromotion::getEndTime, bo.getEndTime()); + lqw.orderByDesc(MallPromotion::getCreateTime); + return lqw; + } + + /** + * 新增营销活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallPromotionBo bo) { + MallPromotion add = MapstructUtils.convert(bo, MallPromotion.class); + validEntityBeforeSave(add); + + // 设置默认状态 + if (add.getStatus() == null) { + add.setStatus(0); // 默认启用 + } + + // 校验时间 + validateTimeRange(add.getStartTime(), add.getEndTime()); + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改营销活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallPromotionBo bo) { + MallPromotion update = MapstructUtils.convert(bo, MallPromotion.class); + validEntityBeforeSave(update); + + // 校验时间 + if (update.getStartTime() != null && update.getEndTime() != null) { + validateTimeRange(update.getStartTime(), update.getEndTime()); + } + + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallPromotion entity) { + // TODO: 可以添加业务校验逻辑,如活动类型校验等 + } + + /** + * 校验时间范围 + */ + private void validateTimeRange(Date startTime, Date endTime) { + if (startTime != null && endTime != null) { + if (startTime.after(endTime)) { + throw new ServiceException("开始时间不能晚于结束时间"); + } + } + } + + /** + * 批量删除营销活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO: 可以添加业务校验逻辑,如检查是否有进行中的活动等 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 启用/禁用营销活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean changeStatus(Long id, Integer status) { + MallPromotion promotion = baseMapper.selectById(id); + if (promotion == null) { + throw new ServiceException("营销活动不存在"); + } + + MallPromotion update = new MallPromotion(); + update.setId(id); + update.setStatus(status); + return baseMapper.updateById(update) > 0; + } + + /** + * 根据活动类型查询活动列表 + */ + @Override + public List queryListByType(Integer type) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(MallPromotion::getType, type); + lqw.eq(MallPromotion::getStatus, 0); // 只查询启用的活动 + Date now = new Date(); + lqw.le(MallPromotion::getStartTime, now); // 开始时间已到 + lqw.ge(MallPromotion::getEndTime, now); // 结束时间未到 + lqw.orderByDesc(MallPromotion::getCreateTime); + return baseMapper.selectVoList(lqw); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallSeckillServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallSeckillServiceImpl.java new file mode 100644 index 0000000..71af697 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/java/org/dromara/mall/promotion/service/impl/MallSeckillServiceImpl.java @@ -0,0 +1,263 @@ +package org.dromara.mall.promotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.promotion.domain.MallSeckill; +import org.dromara.mall.promotion.domain.bo.MallSeckillBo; +import org.dromara.mall.promotion.domain.vo.MallSeckillVo; +import org.dromara.mall.promotion.mapper.MallSeckillMapper; +import org.dromara.mall.promotion.service.MallSeckillService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 秒杀活动Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallSeckillServiceImpl implements MallSeckillService { + + private final MallSeckillMapper baseMapper; + + /** + * 查询秒杀活动 + */ + @Override + public MallSeckillVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询秒杀活动列表 + */ + @Override + public TableDataInfo queryPageList(MallSeckillBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询秒杀活动列表 + */ + @Override + public List queryList(MallSeckillBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallSeckillBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getPromotionId() != null, MallSeckill::getPromotionId, bo.getPromotionId()); + lqw.eq(bo.getSkuId() != null, MallSeckill::getSkuId, bo.getSkuId()); + lqw.eq(bo.getStatus() != null, MallSeckill::getStatus, bo.getStatus()); + lqw.ge(bo.getStartTime() != null, MallSeckill::getStartTime, bo.getStartTime()); + lqw.le(bo.getEndTime() != null, MallSeckill::getEndTime, bo.getEndTime()); + lqw.orderByDesc(MallSeckill::getCreateTime); + return lqw; + } + + /** + * 查询可用的秒杀活动列表(移动端) + */ + @Override + public List queryAvailableList() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(MallSeckill::getStatus, 0); // 启用状态 + Date now = new Date(); + lqw.le(MallSeckill::getStartTime, now); // 已开始 + lqw.ge(MallSeckill::getEndTime, now); // 未结束 + lqw.gt(MallSeckill::getSeckillStock, 0); // 有库存 + lqw.orderByDesc(MallSeckill::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据SKU ID查询秒杀活动 + */ + @Override + public MallSeckillVo queryBySkuId(Long skuId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(MallSeckill::getSkuId, skuId); + lqw.eq(MallSeckill::getStatus, 0); // 启用状态 + Date now = new Date(); + lqw.le(MallSeckill::getStartTime, now); // 已开始 + lqw.ge(MallSeckill::getEndTime, now); // 未结束 + lqw.gt(MallSeckill::getSeckillStock, 0); // 有库存 + lqw.orderByDesc(MallSeckill::getCreateTime); + lqw.last("LIMIT 1"); + return baseMapper.selectVoOne(lqw); + } + + /** + * 新增秒杀活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallSeckillBo bo) { + MallSeckill add = MapstructUtils.convert(bo, MallSeckill.class); + validEntityBeforeSave(add); + + // 设置默认状态 + if (add.getStatus() == null) { + add.setStatus(0); // 默认启用 + } + + // 校验时间 + validateTimeRange(add.getStartTime(), add.getEndTime()); + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改秒杀活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallSeckillBo bo) { + MallSeckill update = MapstructUtils.convert(bo, MallSeckill.class); + validEntityBeforeSave(update); + + // 校验时间 + if (update.getStartTime() != null && update.getEndTime() != null) { + validateTimeRange(update.getStartTime(), update.getEndTime()); + } + + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallSeckill entity) { + // TODO: 可以添加业务校验逻辑,如检查活动是否存在、SKU是否存在等 + } + + /** + * 校验时间范围 + */ + private void validateTimeRange(Date startTime, Date endTime) { + if (startTime != null && endTime != null) { + if (startTime.after(endTime)) { + throw new ServiceException("开始时间不能晚于结束时间"); + } + } + } + + /** + * 批量删除秒杀活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO: 可以添加业务校验逻辑,如检查是否有进行中的秒杀活动等 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 启用/禁用秒杀活动 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean changeStatus(Long id, Integer status) { + MallSeckill seckill = baseMapper.selectById(id); + if (seckill == null) { + throw new ServiceException("秒杀活动不存在"); + } + + MallSeckill update = new MallSeckill(); + update.setId(id); + update.setStatus(status); + return baseMapper.updateById(update) > 0; + } + + /** + * 扣减秒杀库存 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean reduceStock(Long id, Integer quantity) { + if (quantity == null || quantity <= 0) { + throw new ServiceException("扣减数量必须大于0"); + } + + MallSeckill seckill = baseMapper.selectById(id); + if (seckill == null) { + throw new ServiceException("秒杀活动不存在"); + } + + if (seckill.getSeckillStock() < quantity) { + throw new ServiceException("秒杀库存不足"); + } + + int rows = baseMapper.reduceStock(id, quantity); + return rows > 0; + } + + /** + * 恢复秒杀库存 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean restoreStock(Long id, Integer quantity) { + if (quantity == null || quantity <= 0) { + throw new ServiceException("恢复数量必须大于0"); + } + + MallSeckill seckill = baseMapper.selectById(id); + if (seckill == null) { + throw new ServiceException("秒杀活动不存在"); + } + + int rows = baseMapper.restoreStock(id, quantity); + return rows > 0; + } + + /** + * 检查秒杀活动是否可用 + */ + @Override + public Boolean checkAvailable(Long id) { + MallSeckill seckill = baseMapper.selectById(id); + if (seckill == null) { + return false; + } + + // 检查状态 + if (seckill.getStatus() != 0) { + return false; + } + + // 检查时间 + Date now = new Date(); + if (seckill.getStartTime().after(now) || seckill.getEndTime().before(now)) { + return false; + } + + // 检查库存 + if (seckill.getSeckillStock() <= 0) { + return false; + } + + return true; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/resources/META-INF/spring.factories b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..5a3f956 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.dromara.mall.promotion.config.MallPromotionAutoConfiguration diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/resources/mapper/promotion/MallSeckillMapper.xml b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/resources/mapper/promotion/MallSeckillMapper.xml new file mode 100644 index 0000000..2f21ec9 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-promotion/src/main/resources/mapper/promotion/MallSeckillMapper.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE mall_seckill + SET seckill_stock = seckill_stock - #{quantity} + WHERE id = #{id} + AND seckill_stock >= #{quantity} + + + + + UPDATE mall_seckill + SET seckill_stock = seckill_stock + #{quantity} + WHERE id = #{id} + + + diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/README.md b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/README.md new file mode 100644 index 0000000..bb5c283 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/README.md @@ -0,0 +1,109 @@ +# 统计中心模块 (ruoyi-mall-statistics) + +## 模块简介 + +`ruoyi-mall-statistics` 是商城系统的统计中心模块,提供交易分析、会员分析等数据统计功能。 + +## 功能特性 + +### 1. 交易分析 ✅ + +- ✅ 订单统计(总订单数、已支付订单数) +- ✅ 销售额统计(总销售额、已支付金额、退款金额) +- ✅ 今日统计(今日订单数、今日销售额) +- ✅ 商品销售排行(按销售金额排序) +- ✅ 交易趋势分析(按日期统计) + +### 2. 会员分析 ⏳ + +- ⏳ 会员增长统计(需会员模块支持) +- ⏳ 会员消费统计(需会员模块支持) +- ⏳ 会员活跃度分析(需会员模块支持) + +### 3. 商品分析 ⏳ + +- ⏳ 商品销售统计(可通过交易统计的商品排行实现) +- ⏳ 商品浏览统计(需商品模块支持浏览统计) +- ⏳ 商品转化率分析(需商品模块支持) + +### 4. 营销分析 ⏳ + +- ⏳ 活动效果统计(需营销模块支持) +- ⏳ 优惠券使用统计(需优惠券模块支持) +- ⏳ 营销ROI分析(需营销模块支持) + +## 数据库表 + +统计模块主要基于现有业务表进行数据分析和统计,可根据需要创建统计汇总表。 + +## 模块结构 + +``` +org.dromara.mall.statistics +├── controller +│ ├── admin // 管理端API +│ └── app // 用户端API(可选) +├── domain +│ ├── bo // 业务对象 +│ └── vo // 视图对象 +├── mapper // MyBatis Mapper +├── service // 业务逻辑层 +│ └── impl +├── enums // 枚举类 +└── config // 配置类 +``` + +## 依赖模块 + +- `ruoyi-mall-product` - 商品中心模块 +- `ruoyi-mall-trade` - 交易中心模块 +- `ruoyi-common-*` - 通用工具模块 + +## API接口 + +### 管理端接口 + +#### 统计数据 (`/mall/statistics`) + +- `GET /mall/statistics/transaction` - 获取交易统计数据 + - 支持按日期范围查询(startDate, endDate) + - 支持设置商品销售排行数量限制(limit,默认10) + - 返回数据包括:总订单数、总销售额、已支付订单数、已支付金额、退款金额、今日统计、趋势数据、商品销售排行 + +- `GET /mall/statistics/member` - 获取会员统计数据 + - 支持按日期范围查询(startDate, endDate) + - 返回数据包括:总会员数、今日新增会员数、活跃会员数、增长趋势、消费统计 + - ⚠️ 注意:会员统计功能需要会员模块支持,当前返回基础结构 + +## 待实现功能 + +- [x] 交易统计分析 ✅ + - [x] 订单统计 ✅ + - [x] 销售额统计 ✅ + - [x] 商品销售排行 ✅ + - [x] 交易趋势分析 ✅ +- [ ] 会员统计分析(需会员模块支持) +- [ ] 商品统计分析(部分功能可通过交易统计实现) +- [ ] 营销效果分析(需营销模块支持) +- [ ] 数据报表导出 + +## 使用说明 + +### 交易统计 + +交易统计基于 `mall_order` 和 `mall_order_item` 表进行数据聚合分析: + +1. **订单统计**:统计指定时间范围内的订单总数和总金额 +2. **已支付统计**:统计已支付(pay_status=1)的订单数和金额 +3. **退款统计**:统计已完成的退款金额(基于 `mall_after_sale` 表,status=5) +4. **今日统计**:当未指定日期范围时,自动计算今日的订单和销售额 +5. **趋势数据**:按日期分组统计订单数和销售额 +6. **商品销售排行**:按销售金额排序,支持限制返回数量 + +### 查询参数 + +- `startDate`: 开始日期(格式:yyyy-MM-dd),可选 +- `endDate`: 结束日期(格式:yyyy-MM-dd),可选 +- `limit`: 商品销售排行数量限制,默认10,可选 + +**注意**:如果不指定日期范围,统计的是全部数据;如果指定了日期范围,则只统计该范围内的数据。 diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/pom.xml b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/pom.xml new file mode 100644 index 0000000..fde9d37 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/pom.xml @@ -0,0 +1,71 @@ + + + + org.dromara + ruoyi-mall + ${revision} + + 4.0.0 + + ruoyi-mall-statistics + + + ruoyi-mall-statistics 统计中心模块 + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-log + + + + org.dromara + ruoyi-common-redis + + + + + org.dromara + ruoyi-mall-product + ${revision} + + + + + org.dromara + ruoyi-mall-trade + ${revision} + + + + + diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/config/MallStatisticsAutoConfiguration.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/config/MallStatisticsAutoConfiguration.java new file mode 100644 index 0000000..3b712ae --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/config/MallStatisticsAutoConfiguration.java @@ -0,0 +1,23 @@ +package org.dromara.mall.statistics.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * 统计中心自动配置类 + * 主要用于扫描统计中心模块的组件 + * + * @author pengles + */ +@Slf4j +@Configuration +@ComponentScan(basePackages = "org.dromara.mall.statistics") +@ConditionalOnProperty(prefix = "ruoyi.mall.statistics", name = "enabled", havingValue = "true", matchIfMissing = true) +public class MallStatisticsAutoConfiguration { + + public MallStatisticsAutoConfiguration() { + log.info("统计中心模块已启用"); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/controller/admin/AdminMallStatisticsController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/controller/admin/AdminMallStatisticsController.java new file mode 100644 index 0000000..4d85eac --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/controller/admin/AdminMallStatisticsController.java @@ -0,0 +1,82 @@ +package org.dromara.mall.statistics.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.statistics.domain.bo.StatisticsQueryBo; +import org.dromara.mall.statistics.domain.vo.MemberStatisticsVo; +import org.dromara.mall.statistics.domain.vo.TransactionStatisticsVo; +import org.dromara.mall.statistics.service.MallStatisticsService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Date; + +/** + * 统计管理控制器 + * + * @author pengles + */ +@Tag(name = "管理端-统计数据", description = "统计数据查询接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/statistics") +public class AdminMallStatisticsController extends BaseController { + + private final MallStatisticsService statisticsService; + + /** + * 获取交易统计 + */ + @Operation(summary = "获取交易统计", description = "获取交易统计数据,包括订单统计、销售额统计、商品销售排行等") + @SaCheckPermission("mall:statistics:query") + @GetMapping("/transaction") + public R getTransactionStatistics( + @Parameter(description = "开始日期") + @RequestParam(required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate, + @Parameter(description = "结束日期") + @RequestParam(required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate, + @Parameter(description = "商品销售排行限制数量") + @RequestParam(required = false, defaultValue = "10") Integer limit) { + StatisticsQueryBo queryBo = new StatisticsQueryBo(); + queryBo.setStartDate(startDate); + queryBo.setEndDate(endDate); + queryBo.setLimit(limit); + + TransactionStatisticsVo result = statisticsService.getTransactionStatistics(queryBo); + return R.ok(result); + } + + /** + * 获取会员统计 + */ + @Operation(summary = "获取会员统计", description = "获取会员统计数据,包括会员增长、消费统计等") + @SaCheckPermission("mall:statistics:query") + @GetMapping("/member") + public R getMemberStatistics( + @Parameter(description = "开始日期") + @RequestParam(required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate, + @Parameter(description = "结束日期") + @RequestParam(required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate) { + StatisticsQueryBo queryBo = new StatisticsQueryBo(); + queryBo.setStartDate(startDate); + queryBo.setEndDate(endDate); + + MemberStatisticsVo result = statisticsService.getMemberStatistics(queryBo); + return R.ok(result); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/bo/StatisticsQueryBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/bo/StatisticsQueryBo.java new file mode 100644 index 0000000..cd1fc58 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/bo/StatisticsQueryBo.java @@ -0,0 +1,50 @@ +package org.dromara.mall.statistics.domain.bo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 统计查询业务对象 + * + * @author pengles + */ +@Data +public class StatisticsQueryBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 开始日期 + */ + private Date startDate; + + /** + * 结束日期 + */ + private Date endDate; + + /** + * 商品ID(可选) + */ + private Long productId; + + /** + * SKU ID(可选) + */ + private Long skuId; + + /** + * 用户ID(可选) + */ + private Long userId; + + /** + * 限制数量(用于排行) + */ + private Integer limit; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/vo/MemberStatisticsVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/vo/MemberStatisticsVo.java new file mode 100644 index 0000000..c5eee02 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/vo/MemberStatisticsVo.java @@ -0,0 +1,88 @@ +package org.dromara.mall.statistics.domain.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 会员统计视图对象 + * + * @author pengles + */ +@Data +public class MemberStatisticsVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 总会员数 + */ + private Long totalMemberCount; + + /** + * 今日新增会员数 + */ + private Long todayNewMemberCount; + + /** + * 活跃会员数(有订单的会员) + */ + private Long activeMemberCount; + + /** + * 会员增长趋势 + */ + private List growthTrend; + + /** + * 会员消费统计 + */ + private List consumptionStats; + + /** + * 每日会员增长 + */ + @Data + public static class DailyMemberGrowthVo implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 日期 + */ + private String date; + + /** + * 新增会员数 + */ + private Long newMemberCount; + } + + /** + * 会员消费统计 + */ + @Data + public static class MemberConsumptionVo implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + private Long userId; + + /** + * 订单数 + */ + private Integer orderCount; + + /** + * 消费金额(分) + */ + private Long consumptionAmount; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/vo/TransactionStatisticsVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/vo/TransactionStatisticsVo.java new file mode 100644 index 0000000..b5480d7 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/domain/vo/TransactionStatisticsVo.java @@ -0,0 +1,129 @@ +package org.dromara.mall.statistics.domain.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 交易统计视图对象 + * + * @author pengles + */ +@Data +public class TransactionStatisticsVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 总订单数 + */ + private Long totalOrderCount; + + /** + * 总销售额(分) + */ + private Long totalSalesAmount; + + /** + * 已支付订单数 + */ + private Long paidOrderCount; + + /** + * 已支付金额(分) + */ + private Long paidAmount; + + /** + * 退款金额(分) + */ + private Long refundAmount; + + /** + * 今日订单数 + */ + private Long todayOrderCount; + + /** + * 今日销售额(分) + */ + private Long todaySalesAmount; + + /** + * 趋势数据(按日期) + */ + private List trendData; + + /** + * 商品销售排行 + */ + private List productSalesRank; + + /** + * 每日统计 + */ + @Data + public static class DailyStatisticsVo implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 日期 + */ + private String date; + + /** + * 订单数 + */ + private Long orderCount; + + /** + * 销售额(分) + */ + private Long salesAmount; + } + + /** + * 商品销售排行 + */ + @Data + public static class ProductSalesRankVo implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 商品ID + */ + private Long productId; + + /** + * 商品名称 + */ + private String productName; + + /** + * SKU ID + */ + private Long skuId; + + /** + * SKU名称 + */ + private String skuName; + + /** + * 销售数量 + */ + private Integer salesQuantity; + + /** + * 销售金额(分) + */ + private Long salesAmount; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/mapper/MallStatisticsMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/mapper/MallStatisticsMapper.java new file mode 100644 index 0000000..5a42f17 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/mapper/MallStatisticsMapper.java @@ -0,0 +1,50 @@ +package org.dromara.mall.statistics.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.dromara.mall.statistics.domain.vo.TransactionStatisticsVo; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 统计Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallStatisticsMapper { + + /** + * 统计订单总数和总金额 + */ + Map countOrderStatistics(@Param("startDate") Date startDate, + @Param("endDate") Date endDate); + + /** + * 统计已支付订单数和金额 + */ + Map countPaidOrderStatistics(@Param("startDate") Date startDate, + @Param("endDate") Date endDate); + + /** + * 统计退款金额 + */ + Long sumRefundAmount(@Param("startDate") Date startDate, + @Param("endDate") Date endDate); + + /** + * 统计每日订单数据 + */ + List countDailyOrderStatistics(@Param("startDate") Date startDate, + @Param("endDate") Date endDate); + + /** + * 统计商品销售排行 + */ + List queryProductSalesRank(@Param("startDate") Date startDate, + @Param("endDate") Date endDate, + @Param("limit") Integer limit); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/service/MallStatisticsService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/service/MallStatisticsService.java new file mode 100644 index 0000000..7593ee6 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/service/MallStatisticsService.java @@ -0,0 +1,30 @@ +package org.dromara.mall.statistics.service; + +import org.dromara.mall.statistics.domain.bo.StatisticsQueryBo; +import org.dromara.mall.statistics.domain.vo.MemberStatisticsVo; +import org.dromara.mall.statistics.domain.vo.TransactionStatisticsVo; + +/** + * 统计服务接口 + * + * @author pengles + */ +public interface MallStatisticsService { + + /** + * 获取交易统计 + * + * @param queryBo 查询条件 + * @return 交易统计结果 + */ + TransactionStatisticsVo getTransactionStatistics(StatisticsQueryBo queryBo); + + /** + * 获取会员统计 + * + * @param queryBo 查询条件 + * @return 会员统计结果 + */ + MemberStatisticsVo getMemberStatistics(StatisticsQueryBo queryBo); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/service/impl/MallStatisticsServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/service/impl/MallStatisticsServiceImpl.java new file mode 100644 index 0000000..2d5ee94 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/java/org/dromara/mall/statistics/service/impl/MallStatisticsServiceImpl.java @@ -0,0 +1,128 @@ +package org.dromara.mall.statistics.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DateUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.mall.statistics.domain.bo.StatisticsQueryBo; +import org.dromara.mall.statistics.domain.vo.MemberStatisticsVo; +import org.dromara.mall.statistics.domain.vo.TransactionStatisticsVo; +import org.dromara.mall.statistics.mapper.MallStatisticsMapper; +import org.dromara.mall.statistics.service.MallStatisticsService; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 统计服务实现类 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class MallStatisticsServiceImpl implements MallStatisticsService { + + private final MallStatisticsMapper statisticsMapper; + + /** + * 获取交易统计 + */ + @Override + public TransactionStatisticsVo getTransactionStatistics(StatisticsQueryBo queryBo) { + Date startDate = queryBo != null ? queryBo.getStartDate() : null; + Date endDate = queryBo != null ? queryBo.getEndDate() : null; + Integer limit = queryBo != null && queryBo.getLimit() != null ? queryBo.getLimit() : 10; + + TransactionStatisticsVo vo = new TransactionStatisticsVo(); + + // 统计总订单数和总金额 + Map orderStats = statisticsMapper.countOrderStatistics(startDate, endDate); + if (orderStats != null) { + vo.setTotalOrderCount(getLongValue(orderStats, "totalOrderCount")); + vo.setTotalSalesAmount(getLongValue(orderStats, "totalSalesAmount")); + } + + // 统计已支付订单数和金额 + Map paidStats = statisticsMapper.countPaidOrderStatistics(startDate, endDate); + if (paidStats != null) { + vo.setPaidOrderCount(getLongValue(paidStats, "paidOrderCount")); + vo.setPaidAmount(getLongValue(paidStats, "paidAmount")); + } + + // 统计退款金额 + Long refundAmount = statisticsMapper.sumRefundAmount(startDate, endDate); + vo.setRefundAmount(refundAmount != null ? refundAmount : 0L); + + // 今日统计(如果没有指定日期范围,计算今日) + if (startDate == null && endDate == null) { + Date todayStart = DateUtil.beginOfDay(new Date()); + Date todayEnd = DateUtil.endOfDay(new Date()); + + Map todayOrderStats = statisticsMapper.countOrderStatistics(todayStart, todayEnd); + if (todayOrderStats != null) { + vo.setTodayOrderCount(getLongValue(todayOrderStats, "totalOrderCount")); + vo.setTodaySalesAmount(getLongValue(todayOrderStats, "totalSalesAmount")); + } else { + vo.setTodayOrderCount(0L); + vo.setTodaySalesAmount(0L); + } + } else { + // 如果指定了日期范围,今日统计为0或null + vo.setTodayOrderCount(0L); + vo.setTodaySalesAmount(0L); + } + + // 趋势数据 + List trendData = statisticsMapper.countDailyOrderStatistics(startDate, endDate); + vo.setTrendData(trendData != null ? trendData : CollUtil.newArrayList()); + + // 商品销售排行 + List productRank = statisticsMapper.queryProductSalesRank(startDate, endDate, limit); + vo.setProductSalesRank(productRank != null ? productRank : CollUtil.newArrayList()); + + return vo; + } + + /** + * 获取会员统计 + */ + @Override + public MemberStatisticsVo getMemberStatistics(StatisticsQueryBo queryBo) { + // TODO: 实现会员统计功能 + // 由于会员模块可能不在mall模块中,这里先返回基础结构 + MemberStatisticsVo vo = new MemberStatisticsVo(); + vo.setTotalMemberCount(0L); + vo.setTodayNewMemberCount(0L); + vo.setActiveMemberCount(0L); + vo.setGrowthTrend(CollUtil.newArrayList()); + vo.setConsumptionStats(CollUtil.newArrayList()); + return vo; + } + + /** + * 从Map中获取Long值 + */ + private Long getLongValue(Map map, String key) { + Object value = map.get(key); + if (value == null) { + return 0L; + } + if (value instanceof Long) { + return (Long) value; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.parseLong(value.toString()); + } catch (Exception e) { + log.warn("无法转换值 {} 为Long类型: {}", value, e.getMessage()); + return 0L; + } + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/resources/META-INF/spring.factories b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..fa3ef3b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.dromara.mall.statistics.config.MallStatisticsAutoConfiguration diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/resources/mapper/statistics/MallStatisticsMapper.xml b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/resources/mapper/statistics/MallStatisticsMapper.xml new file mode 100644 index 0000000..d88aa62 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-statistics/src/main/resources/mapper/statistics/MallStatisticsMapper.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/README.md b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/README.md new file mode 100644 index 0000000..6abbbb5 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/README.md @@ -0,0 +1,201 @@ +# 交易中心模块 (ruoyi-mall-trade) + +## 模块简介 + +`ruoyi-mall-trade` 是商城系统的交易中心模块,负责交易流程的核心功能,包括购物车、订单、售后等。 + +## 功能特性 + +### 1. 购物车管理 + +- ✅ 添加/删除购物车 +- ✅ 购物车数量修改 +- ✅ 购物车选中状态管理 +- ✅ 购物车详情查询 + +### 2. 订单管理 + +- ✅ 订单创建 +- ✅ 订单查询(列表、详情) +- ✅ 订单取消 +- ✅ 订单状态管理 +- ✅ 订单支付集成 + +### 3. 售后管理 ✅ + +- ✅ 订单退款申请 +- ✅ 退货申请 +- ✅ 换货申请 +- ✅ 售后审核(同意/拒绝) +- ✅ 售后状态跟踪 +- ✅ 退货物流信息填写 +- ✅ 商家确认收货 +- ✅ 完成售后处理 +- ✅ 取消售后申请 + +### 4. 订单评价 ✅ + +- ✅ 订单评价提交 +- ✅ 评价列表查询(支持按订单、商品查询) +- ✅ 评价审核(通过/驳回) +- ✅ 评价回复(商家回复) +- ✅ 评价导出功能 + +### 5. 地址管理 ✅ + +- ✅ 收货地址CRUD操作 +- ✅ 用户地址列表查询 +- ✅ 默认地址查询 +- ✅ 设置默认地址 +- ✅ 地址导出功能(管理端) + +## 数据库表 + +- `mall_cart` - 购物车表 +- `mall_order` - 订单表 +- `mall_order_item` - 订单明细表 +- `mall_after_sale` - 售后申请表 +- `mall_order_evaluate` - 订单评价表 +- `mall_order_coupon` - 订单优惠券使用记录表 +- `mall_address` - 收货地址表 + +详细表结构请参考:`script/sql/ry_mall.sql` + +## 模块结构 + +``` +org.dromara.mall.trade +├── controller +│ ├── admin // 管理端API +│ └── app // 用户端API +├── domain +│ ├── bo // 业务对象 +│ └── vo // 视图对象 +├── mapper // MyBatis Mapper +├── service // 业务逻辑层 +│ └── impl +├── enums // 枚举类 +└── config // 配置类 +``` + +## 依赖模块 + +- `ruoyi-mall-product` - 商品中心模块 +- `ruoyi-pay` - 支付中心模块 +- `ruoyi-common-*` - 通用工具模块 + +## 业务流程 + +### 订单提交流程 + +1. 用户选择商品(从购物车或直接购买) +2. 调用订单提交接口 +3. 系统创建订单和订单明细 +4. 调用支付中心创建支付单 +5. 返回支付参数给前端 +6. 前端调起支付 +7. 支付成功后,支付中心回调 +8. 系统更新订单状态并扣减库存 + +## 模块结构 + +``` +org.dromara.mall.trade +├── controller +│ ├── admin // 管理端API +│ │ ├── AdminMallAfterSaleController.java +│ │ ├── AdminMallOrderEvaluateController.java +│ │ └── AdminMallAddressController.java +│ └── app // 用户端API +│ ├── AppMallAfterSaleController.java +│ ├── AppMallOrderEvaluateController.java +│ └── AppMallAddressController.java +├── domain +│ ├── MallAfterSale.java +│ ├── MallOrderEvaluate.java +│ ├── MallOrderCoupon.java +│ ├── MallAddress.java +│ ├── bo // 业务对象 +│ │ ├── MallAfterSaleBo.java +│ │ ├── MallOrderEvaluateBo.java +│ │ ├── MallOrderCouponBo.java +│ │ └── MallAddressBo.java +│ └── vo // 视图对象 +│ ├── MallAfterSaleVo.java +│ ├── MallOrderEvaluateVo.java +│ ├── MallOrderCouponVo.java +│ └── MallAddressVo.java +├── mapper +│ ├── MallAfterSaleMapper.java +│ ├── MallOrderEvaluateMapper.java +│ ├── MallOrderCouponMapper.java +│ └── MallAddressMapper.java +├── service +│ ├── MallAfterSaleService.java +│ ├── MallOrderEvaluateService.java +│ ├── MallOrderCouponService.java +│ ├── MallAddressService.java +│ ├── SeckillOrderService.java +│ └── impl +│ ├── MallAfterSaleServiceImpl.java +│ ├── MallOrderEvaluateServiceImpl.java +│ ├── MallOrderCouponServiceImpl.java +│ ├── MallAddressServiceImpl.java +│ └── SeckillOrderServiceImpl.java +├── utils +│ └── CouponCalculator.java +└── config + └── MallTradeAutoConfiguration.java +``` + +## API接口 + +### 管理端接口 + +#### 售后申请管理 (`/mall/order/after-sale`) + +- `GET /mall/order/after-sale/list` - 查询售后申请列表(分页) +- `GET /mall/order/after-sale/{id}` - 获取售后申请详情 +- `DELETE /mall/order/after-sale/{ids}` - 删除售后申请 +- `POST /mall/order/after-sale/export` - 导出售后申请列表 +- `PUT /mall/order/after-sale/audit/{id}` - 审核售后申请(同意/拒绝) +- `PUT /mall/order/after-sale/receive/{id}` - 确认收货(商家确认收到退货) +- `PUT /mall/order/after-sale/complete/{id}` - 完成售后 + +#### 订单评价管理 (`/mall/order/evaluate`) + +- `GET /mall/order/evaluate/list` - 查询订单评价列表(分页) +- `GET /mall/order/evaluate/{id}` - 获取订单评价详情 +- `DELETE /mall/order/evaluate/{ids}` - 删除订单评价 +- `POST /mall/order/evaluate/export` - 导出订单评价列表 +- `PUT /mall/order/evaluate/audit/{id}` - 审核评价(通过/驳回) +- `PUT /mall/order/evaluate/reply/{id}` - 回复评价 + +### 移动端接口 + +#### 售后申请 (`/app/mall/order/after-sale`) + +- `GET /app/mall/order/after-sale/list` - 查询售后申请列表 +- `GET /app/mall/order/after-sale/order/{orderId}` - 根据订单ID查询售后申请列表 +- `GET /app/mall/order/after-sale/{id}` - 获取售后申请详情 +- `POST /app/mall/order/after-sale` - 新增售后申请(退款/退货/换货) +- `PUT /app/mall/order/after-sale/logistics/{id}` - 填写退货物流信息 +- `PUT /app/mall/order/after-sale/cancel/{id}` - 取消售后申请 + +#### 订单评价 (`/app/mall/order/evaluate`) + +- `GET /app/mall/order/evaluate/list` - 查询订单评价列表 +- `GET /app/mall/order/evaluate/order/{orderId}` - 根据订单ID查询评价列表 +- `GET /app/mall/order/evaluate/product/{productId}` - 根据商品ID查询评价列表(仅已审核) +- `GET /app/mall/order/evaluate/{id}` - 获取订单评价详情 +- `POST /app/mall/order/evaluate` - 提交订单评价 + +#### 收货地址 (`/app/mall/address`) + +- `GET /app/mall/address/list` - 查询收货地址列表 +- `GET /app/mall/address/default` - 获取默认收货地址 +- `GET /app/mall/address/{id}` - 获取收货地址详情 +- `POST /app/mall/address` - 新增收货地址 +- `PUT /app/mall/address` - 修改收货地址 +- `DELETE /app/mall/address/{ids}` - 删除收货地址 +- `PUT /app/mall/address/default/{id}` - 设置默认地址 diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/pom.xml b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/pom.xml new file mode 100644 index 0000000..5c8a516 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/pom.xml @@ -0,0 +1,105 @@ + + + + org.dromara + ruoyi-mall + ${revision} + + 4.0.0 + + ruoyi-mall-trade + + + ruoyi-mall-trade 交易中心模块 + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-json + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-log + + + + org.dromara + ruoyi-common-excel + + + + org.dromara + ruoyi-common-idempotent + + + + org.dromara + ruoyi-common-redis + + + + org.dromara + ruoyi-common-satoken + + + + + org.dromara + ruoyi-mall-product + ${revision} + + + + + org.dromara + ruoyi-pay + ${revision} + + + + + org.dromara + ruoyi-mall-promotion + ${revision} + + + + + org.dromara + ruoyi-mall-coupon + ${revision} + + + + + diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/config/MallTradeAutoConfiguration.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/config/MallTradeAutoConfiguration.java new file mode 100644 index 0000000..327ead6 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/config/MallTradeAutoConfiguration.java @@ -0,0 +1,23 @@ +package org.dromara.mall.trade.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * 交易中心自动配置类 + * 主要用于扫描交易中心模块的组件 + * + * @author pengles + */ +@Slf4j +@Configuration +@ComponentScan(basePackages = "org.dromara.mall.trade") +@ConditionalOnProperty(prefix = "ruoyi.mall.trade", name = "enabled", havingValue = "true", matchIfMissing = true) +public class MallTradeAutoConfiguration { + + public MallTradeAutoConfiguration() { + log.info("交易中心模块已启用"); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallAddressController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallAddressController.java new file mode 100644 index 0000000..2b31152 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallAddressController.java @@ -0,0 +1,111 @@ +package org.dromara.mall.trade.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.trade.domain.bo.MallAddressBo; +import org.dromara.mall.trade.domain.vo.MallAddressVo; +import org.dromara.mall.trade.service.MallAddressService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 收货地址管理 + * + * @author pengles + */ +@Tag(name = "管理端-收货地址管理", description = "收货地址管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/address") +public class AdminMallAddressController extends BaseController { + + private final MallAddressService mallAddressService; + + /** + * 查询收货地址列表 + */ + @Operation(summary = "查询收货地址列表", description = "分页查询收货地址列表") + @SaCheckPermission("mall:address:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "收货地址查询条件") MallAddressBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallAddressService.queryPageList(bo, pageQuery); + } + + /** + * 导出收货地址列表 + */ + @Operation(summary = "导出收货地址列表", description = "导出收货地址到Excel") + @SaCheckPermission("mall:address:export") + @Log(title = "收货地址", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "收货地址查询条件") MallAddressBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallAddressService.queryList(bo); + ExcelUtil.exportExcel(list, "收货地址", MallAddressVo.class, response); + } + + /** + * 获取收货地址详细信息 + */ + @Operation(summary = "获取收货地址详情", description = "根据地址ID获取详细信息") + @SaCheckPermission("mall:address:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "地址ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallAddressService.queryById(id)); + } + + /** + * 新增收货地址 + */ + @Operation(summary = "新增收货地址", description = "创建新的收货地址") + @SaCheckPermission("mall:address:add") + @Log(title = "收货地址", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Parameter(description = "收货地址信息") @Validated @RequestBody MallAddressBo bo) { + return toAjax(mallAddressService.insertByBo(bo)); + } + + /** + * 修改收货地址 + */ + @Operation(summary = "修改收货地址", description = "更新收货地址信息") + @SaCheckPermission("mall:address:edit") + @Log(title = "收货地址", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "收货地址信息") @Validated @RequestBody MallAddressBo bo) { + return toAjax(mallAddressService.updateByBo(bo)); + } + + /** + * 删除收货地址 + */ + @Operation(summary = "删除收货地址", description = "删除收货地址") + @SaCheckPermission("mall:address:remove") + @Log(title = "收货地址", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "地址ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallAddressService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallAfterSaleController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallAfterSaleController.java new file mode 100644 index 0000000..6f25e1b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallAfterSaleController.java @@ -0,0 +1,132 @@ +package org.dromara.mall.trade.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.trade.domain.bo.MallAfterSaleBo; +import org.dromara.mall.trade.domain.vo.MallAfterSaleVo; +import org.dromara.mall.trade.service.MallAfterSaleService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 售后申请管理 + * + * @author pengles + */ +@Tag(name = "管理端-售后申请管理", description = "售后申请管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/order/after-sale") +public class AdminMallAfterSaleController extends BaseController { + + private final MallAfterSaleService mallAfterSaleService; + + /** + * 查询售后申请列表 + */ + @Operation(summary = "查询售后申请列表", description = "分页查询售后申请列表") + @SaCheckPermission("mall:order:after-sale:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "售后申请查询条件") MallAfterSaleBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallAfterSaleService.queryPageList(bo, pageQuery); + } + + /** + * 导出售后申请列表 + */ + @Operation(summary = "导出售后申请列表", description = "导出售后申请到Excel") + @SaCheckPermission("mall:order:after-sale:export") + @Log(title = "售后申请", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "售后申请查询条件") MallAfterSaleBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallAfterSaleService.queryList(bo); + ExcelUtil.exportExcel(list, "售后申请", MallAfterSaleVo.class, response); + } + + /** + * 获取售后申请详细信息 + */ + @Operation(summary = "获取售后申请详情", description = "根据售后申请ID获取详细信息") + @SaCheckPermission("mall:order:after-sale:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "售后申请ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallAfterSaleService.queryById(id)); + } + + /** + * 删除售后申请 + */ + @Operation(summary = "删除售后申请", description = "删除售后申请") + @SaCheckPermission("mall:order:after-sale:remove") + @Log(title = "售后申请", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "售后申请ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallAfterSaleService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 审核售后申请(同意/拒绝) + */ + @Operation(summary = "审核售后申请", description = "审核售后申请,1-同意,2-拒绝") + @SaCheckPermission("mall:order:after-sale:audit") + @Log(title = "售后申请", businessType = BusinessType.UPDATE) + @PutMapping("/audit/{id}") + public R audit(@Parameter(description = "售后申请ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "审核状态", required = true) + @NotNull(message = "审核状态不能为空") + @RequestParam Integer status, + @Parameter(description = "拒绝原因") + @RequestParam(required = false) String refuseReason) { + return toAjax(mallAfterSaleService.audit(id, status, refuseReason)); + } + + /** + * 确认收货(商家确认收到退货) + */ + @Operation(summary = "确认收货", description = "商家确认收到退货商品") + @SaCheckPermission("mall:order:after-sale:receive") + @Log(title = "售后申请", businessType = BusinessType.UPDATE) + @PutMapping("/receive/{id}") + public R confirmReceive(@Parameter(description = "售后申请ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return toAjax(mallAfterSaleService.confirmReceive(id)); + } + + /** + * 完成售后(退款完成或换货完成) + */ + @Operation(summary = "完成售后", description = "完成售后申请(退款完成或换货完成)") + @SaCheckPermission("mall:order:after-sale:complete") + @Log(title = "售后申请", businessType = BusinessType.UPDATE) + @PutMapping("/complete/{id}") + public R complete(@Parameter(description = "售后申请ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return toAjax(mallAfterSaleService.complete(id)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallOrderEvaluateController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallOrderEvaluateController.java new file mode 100644 index 0000000..339aa40 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/admin/AdminMallOrderEvaluateController.java @@ -0,0 +1,120 @@ +package org.dromara.mall.trade.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.trade.domain.bo.MallOrderEvaluateBo; +import org.dromara.mall.trade.domain.vo.MallOrderEvaluateVo; +import org.dromara.mall.trade.service.MallOrderEvaluateService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 订单评价管理 + * + * @author pengles + */ +@Tag(name = "管理端-订单评价管理", description = "订单评价管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/mall/order/evaluate") +public class AdminMallOrderEvaluateController extends BaseController { + + private final MallOrderEvaluateService mallOrderEvaluateService; + + /** + * 查询订单评价列表 + */ + @Operation(summary = "查询订单评价列表", description = "分页查询订单评价列表") + @SaCheckPermission("mall:order:evaluate:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "评价查询条件") MallOrderEvaluateBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return mallOrderEvaluateService.queryPageList(bo, pageQuery); + } + + /** + * 导出订单评价列表 + */ + @Operation(summary = "导出订单评价列表", description = "导出订单评价到Excel") + @SaCheckPermission("mall:order:evaluate:export") + @Log(title = "订单评价", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "评价查询条件") MallOrderEvaluateBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = mallOrderEvaluateService.queryList(bo); + ExcelUtil.exportExcel(list, "订单评价", MallOrderEvaluateVo.class, response); + } + + /** + * 获取订单评价详细信息 + */ + @Operation(summary = "获取订单评价详情", description = "根据评价ID获取详细信息") + @SaCheckPermission("mall:order:evaluate:query") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "评价ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallOrderEvaluateService.queryById(id)); + } + + /** + * 删除订单评价 + */ + @Operation(summary = "删除订单评价", description = "删除订单评价") + @SaCheckPermission("mall:order:evaluate:remove") + @Log(title = "订单评价", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "评价ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallOrderEvaluateService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 审核评价(通过/驳回) + */ + @Operation(summary = "审核评价", description = "审核评价,1-通过,2-驳回") + @SaCheckPermission("mall:order:evaluate:audit") + @Log(title = "订单评价", businessType = BusinessType.UPDATE) + @PutMapping("/audit/{id}") + public R audit(@Parameter(description = "评价ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "审核状态", required = true) + @NotNull(message = "审核状态不能为空") + @RequestParam Integer status) { + return toAjax(mallOrderEvaluateService.audit(id, status)); + } + + /** + * 回复评价 + */ + @Operation(summary = "回复评价", description = "商家回复评价") + @SaCheckPermission("mall:order:evaluate:reply") + @Log(title = "订单评价", businessType = BusinessType.UPDATE) + @PutMapping("/reply/{id}") + public R reply(@Parameter(description = "评价ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "回复内容", required = true) + @NotNull(message = "回复内容不能为空") + @RequestParam String replyContent) { + return toAjax(mallOrderEvaluateService.reply(id, replyContent)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallAddressController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallAddressController.java new file mode 100644 index 0000000..e5267fe --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallAddressController.java @@ -0,0 +1,106 @@ +package org.dromara.mall.trade.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mall.trade.domain.bo.MallAddressBo; +import org.dromara.mall.trade.domain.vo.MallAddressVo; +import org.dromara.mall.trade.service.MallAddressService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 收货地址(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-收货地址", description = "收货地址接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/address") +public class AppMallAddressController extends BaseController { + + private final MallAddressService mallAddressService; + + /** + * 查询收货地址列表 + */ + @Operation(summary = "查询收货地址列表", description = "查询用户的收货地址列表") + @GetMapping("/list") + public R> list() { + Long userId = org.dromara.common.satoken.utils.LoginHelper.getUserId(); + List list = mallAddressService.queryListByUserId(userId); + return R.ok(list); + } + + /** + * 获取默认收货地址 + */ + @Operation(summary = "获取默认收货地址", description = "查询用户的默认收货地址") + @GetMapping("/default") + public R getDefault() { + Long userId = org.dromara.common.satoken.utils.LoginHelper.getUserId(); + MallAddressVo address = mallAddressService.queryDefaultByUserId(userId); + return R.ok(address); + } + + /** + * 获取收货地址详细信息 + */ + @Operation(summary = "获取收货地址详情", description = "根据地址ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "地址ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallAddressService.queryById(id)); + } + + /** + * 新增收货地址 + */ + @Operation(summary = "新增收货地址", description = "创建新的收货地址") + @PostMapping + public R add(@Parameter(description = "收货地址信息") @Validated @RequestBody MallAddressBo bo) { + return toAjax(mallAddressService.insertByBo(bo)); + } + + /** + * 修改收货地址 + */ + @Operation(summary = "修改收货地址", description = "更新收货地址信息") + @PutMapping + public R edit(@Parameter(description = "收货地址信息") @Validated @RequestBody MallAddressBo bo) { + return toAjax(mallAddressService.updateByBo(bo)); + } + + /** + * 删除收货地址 + */ + @Operation(summary = "删除收货地址", description = "删除收货地址") + @DeleteMapping("/{ids}") + public R remove(@Parameter(description = "地址ID数组", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(mallAddressService.deleteWithValidByIds(Arrays.asList(ids), true)); + } + + /** + * 设置默认地址 + */ + @Operation(summary = "设置默认地址", description = "将指定地址设置为默认收货地址") + @PutMapping("/default/{id}") + public R setDefault(@Parameter(description = "地址ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return toAjax(mallAddressService.setDefault(id)); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallAfterSaleController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallAfterSaleController.java new file mode 100644 index 0000000..9aa3d37 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallAfterSaleController.java @@ -0,0 +1,106 @@ +package org.dromara.mall.trade.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.trade.domain.bo.MallAfterSaleBo; +import org.dromara.mall.trade.domain.vo.MallAfterSaleVo; +import org.dromara.mall.trade.service.MallAfterSaleService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 售后申请(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-售后申请", description = "售后申请接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/order/after-sale") +public class AppMallAfterSaleController { + + private final MallAfterSaleService mallAfterSaleService; + + /** + * 查询售后申请列表(移动端) + */ + @Operation(summary = "查询售后申请列表", description = "查询用户的售后申请列表") + @GetMapping("/list") + public R> list(@Parameter(description = "售后申请查询条件") MallAfterSaleBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + TableDataInfo result = mallAfterSaleService.queryPageList(bo, pageQuery); + return R.ok(result); + } + + /** + * 根据订单ID查询售后申请列表 + */ + @Operation(summary = "查询订单售后申请列表", description = "根据订单ID查询售后申请列表") + @GetMapping("/order/{orderId}") + public R> listByOrderId(@Parameter(description = "订单ID", required = true) + @NotNull(message = "订单ID不能为空") + @PathVariable Long orderId) { + MallAfterSaleBo bo = new MallAfterSaleBo(); + bo.setOrderId(orderId); + List list = mallAfterSaleService.queryList(bo); + return R.ok(list); + } + + /** + * 获取售后申请详细信息 + */ + @Operation(summary = "获取售后申请详情", description = "根据售后申请ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "售后申请ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallAfterSaleService.queryById(id)); + } + + /** + * 新增售后申请 + */ + @Operation(summary = "新增售后申请", description = "提交售后申请(退款/退货/换货)") + @PostMapping + public R add(@Parameter(description = "售后申请信息") @Validated @RequestBody MallAfterSaleBo bo) { + return R.ok(mallAfterSaleService.insertByBo(bo)); + } + + /** + * 用户填写退货物流信息 + */ + @Operation(summary = "填写退货物流信息", description = "用户填写退货物流信息(仅退货和换货类型)") + @PutMapping("/logistics/{id}") + public R fillLogistics(@Parameter(description = "售后申请ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id, + @Parameter(description = "物流公司", required = true) + @NotBlank(message = "物流公司不能为空") + @RequestParam String logisticsCompany, + @Parameter(description = "物流单号", required = true) + @NotBlank(message = "物流单号不能为空") + @RequestParam String logisticsNo) { + return R.ok(mallAfterSaleService.fillLogistics(id, logisticsCompany, logisticsNo)); + } + + /** + * 取消售后申请 + */ + @Operation(summary = "取消售后申请", description = "用户取消售后申请") + @PutMapping("/cancel/{id}") + public R cancel(@Parameter(description = "售后申请ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallAfterSaleService.cancel(id)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallOrderEvaluateController.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallOrderEvaluateController.java new file mode 100644 index 0000000..df7ccea --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/controller/app/AppMallOrderEvaluateController.java @@ -0,0 +1,93 @@ +package org.dromara.mall.trade.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.trade.domain.bo.MallOrderEvaluateBo; +import org.dromara.mall.trade.domain.vo.MallOrderEvaluateVo; +import org.dromara.mall.trade.service.MallOrderEvaluateService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 订单评价(移动端) + * + * @author pengles + */ +@Tag(name = "移动端-订单评价", description = "订单评价接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/mall/order/evaluate") +public class AppMallOrderEvaluateController { + + private final MallOrderEvaluateService mallOrderEvaluateService; + + /** + * 查询订单评价列表(移动端) + */ + @Operation(summary = "查询订单评价列表", description = "查询订单评价列表") + @GetMapping("/list") + public R> list(@Parameter(description = "评价查询条件") MallOrderEvaluateBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + TableDataInfo result = mallOrderEvaluateService.queryPageList(bo, pageQuery); + return R.ok(result); + } + + /** + * 根据订单ID查询评价列表 + */ + @Operation(summary = "查询订单评价列表", description = "根据订单ID查询评价列表") + @GetMapping("/order/{orderId}") + public R> listByOrderId(@Parameter(description = "订单ID", required = true) + @NotNull(message = "订单ID不能为空") + @PathVariable Long orderId) { + MallOrderEvaluateBo bo = new MallOrderEvaluateBo(); + bo.setOrderId(orderId); + List list = mallOrderEvaluateService.queryList(bo); + return R.ok(list); + } + + /** + * 根据商品ID查询评价列表 + */ + @Operation(summary = "查询商品评价列表", description = "根据商品ID查询评价列表(仅已通过审核的)") + @GetMapping("/product/{productId}") + public R> listByProductId(@Parameter(description = "商品ID", required = true) + @NotNull(message = "商品ID不能为空") + @PathVariable Long productId, + @Parameter(description = "分页参数") PageQuery pageQuery) { + MallOrderEvaluateBo bo = new MallOrderEvaluateBo(); + bo.setProductId(productId); + bo.setStatus(1); // 只查询已通过审核的评价 + TableDataInfo result = mallOrderEvaluateService.queryPageList(bo, pageQuery); + return R.ok(result); + } + + /** + * 获取订单评价详细信息 + */ + @Operation(summary = "获取订单评价详情", description = "根据评价ID获取详细信息") + @GetMapping("/{id}") + public R getInfo(@Parameter(description = "评价ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(mallOrderEvaluateService.queryById(id)); + } + + /** + * 新增订单评价 + */ + @Operation(summary = "新增订单评价", description = "提交订单评价") + @PostMapping + public R add(@Parameter(description = "评价信息") @Validated @RequestBody MallOrderEvaluateBo bo) { + return R.ok(mallOrderEvaluateService.insertByBo(bo)); + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallAddress.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallAddress.java new file mode 100644 index 0000000..ad24f6b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallAddress.java @@ -0,0 +1,81 @@ +package org.dromara.mall.trade.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 收货地址对象 mall_address + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_address") +public class MallAddress implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 地址ID + */ + @TableId + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 收货人姓名 + */ + private String receiverName; + + /** + * 收货人电话 + */ + private String receiverPhone; + + /** + * 省份 + */ + private String province; + + /** + * 城市 + */ + private String city; + + /** + * 区/县 + */ + private String district; + + /** + * 详细地址 + */ + private String address; + + /** + * 是否默认地址(0否 1是) + */ + private Integer isDefault; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallAfterSale.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallAfterSale.java new file mode 100644 index 0000000..ff0f881 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallAfterSale.java @@ -0,0 +1,150 @@ +package org.dromara.mall.trade.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 售后申请对象 mall_after_sale + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_after_sale") +public class MallAfterSale implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 售后ID + */ + @TableId + private Long id; + + /** + * 售后单号 + */ + private String afterSaleNo; + + /** + * 订单ID + */ + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 订单明细ID + */ + private Long orderItemId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 售后类型(0退款 1退货 2换货) + */ + private Integer type; + + /** + * 申请原因 + */ + private String reason; + + /** + * 问题描述 + */ + private String description; + + /** + * 凭证图片(JSON数组) + */ + private String images; + + /** + * 退款金额(分) + */ + private Integer refundAmount; + + /** + * 数量 + */ + private Integer quantity; + + /** + * 状态(0待审核 1已同意 2已拒绝 3待退货 4待收货 5已完成 6已取消) + */ + private Integer status; + + /** + * 拒绝原因 + */ + private String refuseReason; + + /** + * 退款单号 + */ + private String refundOrderNo; + + /** + * 物流公司 + */ + private String logisticsCompany; + + /** + * 物流单号 + */ + private String logisticsNo; + + /** + * 申请时间 + */ + private Date applyTime; + + /** + * 审核时间 + */ + private Date auditTime; + + /** + * 退货时间 + */ + private Date returnTime; + + /** + * 收货时间 + */ + private Date receiveTime; + + /** + * 退款时间 + */ + private Date refundTime; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallOrderCoupon.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallOrderCoupon.java new file mode 100644 index 0000000..ee88c9d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallOrderCoupon.java @@ -0,0 +1,61 @@ +package org.dromara.mall.trade.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 订单优惠券使用记录对象 mall_order_coupon + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_order_coupon") +public class MallOrderCoupon implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * ID + */ + @TableId + private Long id; + + /** + * 订单ID + */ + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 优惠券ID + */ + private Long couponId; + + /** + * 用户优惠券ID + */ + private Long couponUserId; + + /** + * 优惠金额(分) + */ + private Integer discountAmount; + + /** + * 使用时间 + */ + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallOrderEvaluate.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallOrderEvaluate.java new file mode 100644 index 0000000..30bc658 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/MallOrderEvaluate.java @@ -0,0 +1,105 @@ +package org.dromara.mall.trade.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 订单评价对象 mall_order_evaluate + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("mall_order_evaluate") +public class MallOrderEvaluate implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 评价ID + */ + @TableId + private Long id; + + /** + * 订单ID + */ + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 订单明细ID + */ + private Long orderItemId; + + /** + * 商品ID + */ + private Long productId; + + /** + * SKU ID + */ + private Long skuId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 评分(1-5) + */ + private Integer score; + + /** + * 评价内容 + */ + private String content; + + /** + * 评价图片(JSON数组) + */ + private String images; + + /** + * 回复内容 + */ + private String replyContent; + + /** + * 回复时间 + */ + private Date replyTime; + + /** + * 状态(0待审核 1已通过 2已驳回) + */ + private Integer status; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallAddressBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallAddressBo.java new file mode 100644 index 0000000..83868b0 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallAddressBo.java @@ -0,0 +1,76 @@ +package org.dromara.mall.trade.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.trade.domain.MallAddress; + +/** + * 收货地址业务对象 mall_address + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallAddress.class, reverseConvertGenerate = false) +public class MallAddressBo extends BaseEntity { + + /** + * 地址ID + */ + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 收货人姓名 + */ + @NotBlank(message = "收货人姓名不能为空") + @Size(max = 64, message = "收货人姓名长度不能超过64个字符") + private String receiverName; + + /** + * 收货人电话 + */ + @NotBlank(message = "收货人电话不能为空") + @Size(max = 32, message = "收货人电话长度不能超过32个字符") + private String receiverPhone; + + /** + * 省份 + */ + @Size(max = 64, message = "省份长度不能超过64个字符") + private String province; + + /** + * 城市 + */ + @Size(max = 64, message = "城市长度不能超过64个字符") + private String city; + + /** + * 区/县 + */ + @Size(max = 64, message = "区/县长度不能超过64个字符") + private String district; + + /** + * 详细地址 + */ + @NotBlank(message = "详细地址不能为空") + @Size(max = 512, message = "详细地址长度不能超过512个字符") + private String address; + + /** + * 是否默认地址(0否 1是) + */ + private Integer isDefault; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallAfterSaleBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallAfterSaleBo.java new file mode 100644 index 0000000..5cb6801 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallAfterSaleBo.java @@ -0,0 +1,110 @@ +package org.dromara.mall.trade.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.trade.domain.MallAfterSale; + +/** + * 售后申请业务对象 mall_after_sale + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallAfterSale.class, reverseConvertGenerate = false) +public class MallAfterSaleBo extends BaseEntity { + + /** + * 售后ID + */ + private Long id; + + /** + * 售后单号 + */ + private String afterSaleNo; + + /** + * 订单ID + */ + @NotNull(message = "订单ID不能为空") + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 订单明细ID + */ + @NotNull(message = "订单明细ID不能为空") + private Long orderItemId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 售后类型(0退款 1退货 2换货) + */ + @NotNull(message = "售后类型不能为空") + private Integer type; + + /** + * 申请原因 + */ + @NotBlank(message = "申请原因不能为空") + private String reason; + + /** + * 问题描述 + */ + private String description; + + /** + * 凭证图片(JSON数组) + */ + private String images; + + /** + * 退款金额(分) + */ + private Integer refundAmount; + + /** + * 数量 + */ + private Integer quantity; + + /** + * 状态(0待审核 1已同意 2已拒绝 3待退货 4待收货 5已完成 6已取消) + */ + private Integer status; + + /** + * 拒绝原因 + */ + private String refuseReason; + + /** + * 退款单号 + */ + private String refundOrderNo; + + /** + * 物流公司 + */ + private String logisticsCompany; + + /** + * 物流单号 + */ + private String logisticsNo; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallOrderCouponBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallOrderCouponBo.java new file mode 100644 index 0000000..3b15673 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallOrderCouponBo.java @@ -0,0 +1,56 @@ +package org.dromara.mall.trade.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.trade.domain.MallOrderCoupon; + +/** + * 订单优惠券使用记录业务对象 mall_order_coupon + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallOrderCoupon.class, reverseConvertGenerate = false) +public class MallOrderCouponBo extends BaseEntity { + + /** + * ID + */ + private Long id; + + /** + * 订单ID + */ + @NotNull(message = "订单ID不能为空") + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 优惠券ID + */ + @NotNull(message = "优惠券ID不能为空") + private Long couponId; + + /** + * 用户优惠券ID + */ + @NotNull(message = "用户优惠券ID不能为空") + private Long couponUserId; + + /** + * 优惠金额(分) + */ + @NotNull(message = "优惠金额不能为空") + @Min(value = 0, message = "优惠金额不能为负数") + private Integer discountAmount; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallOrderEvaluateBo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallOrderEvaluateBo.java new file mode 100644 index 0000000..fb8e043 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/bo/MallOrderEvaluateBo.java @@ -0,0 +1,87 @@ +package org.dromara.mall.trade.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.mall.trade.domain.MallOrderEvaluate; + +/** + * 订单评价业务对象 mall_order_evaluate + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = MallOrderEvaluate.class, reverseConvertGenerate = false) +public class MallOrderEvaluateBo extends BaseEntity { + + /** + * 评价ID + */ + private Long id; + + /** + * 订单ID + */ + @NotNull(message = "订单ID不能为空") + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 订单明细ID + */ + @NotNull(message = "订单明细ID不能为空") + private Long orderItemId; + + /** + * 商品ID + */ + private Long productId; + + /** + * SKU ID + */ + private Long skuId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 评分(1-5) + */ + @NotNull(message = "评分不能为空") + @Min(value = 1, message = "评分不能小于1") + @Max(value = 5, message = "评分不能大于5") + private Integer score; + + /** + * 评价内容 + */ + private String content; + + /** + * 评价图片(JSON数组) + */ + private String images; + + /** + * 回复内容 + */ + private String replyContent; + + /** + * 状态(0待审核 1已通过 2已驳回) + */ + private Integer status; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallAddressVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallAddressVo.java new file mode 100644 index 0000000..86371ac --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallAddressVo.java @@ -0,0 +1,98 @@ +package org.dromara.mall.trade.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.trade.domain.MallAddress; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 收货地址视图对象 mall_address + * + * @author pengles + */ +@Data +@AutoMapper(target = MallAddress.class) +public class MallAddressVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 地址ID + */ + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 收货人姓名 + */ + private String receiverName; + + /** + * 收货人电话 + */ + private String receiverPhone; + + /** + * 省份 + */ + private String province; + + /** + * 城市 + */ + private String city; + + /** + * 区/县 + */ + private String district; + + /** + * 详细地址 + */ + private String address; + + /** + * 是否默认地址(0否 1是) + */ + private Integer isDefault; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + + /** + * 完整地址(省份+城市+区/县+详细地址) + */ + public String getFullAddress() { + StringBuilder fullAddress = new StringBuilder(); + if (province != null && !province.isEmpty()) { + fullAddress.append(province); + } + if (city != null && !city.isEmpty()) { + fullAddress.append(city); + } + if (district != null && !district.isEmpty()) { + fullAddress.append(district); + } + if (address != null && !address.isEmpty()) { + fullAddress.append(address); + } + return fullAddress.toString(); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallAfterSaleVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallAfterSaleVo.java new file mode 100644 index 0000000..ea32b17 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallAfterSaleVo.java @@ -0,0 +1,143 @@ +package org.dromara.mall.trade.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.trade.domain.MallAfterSale; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 售后申请视图对象 mall_after_sale + * + * @author pengles + */ +@Data +@AutoMapper(target = MallAfterSale.class) +public class MallAfterSaleVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 售后ID + */ + private Long id; + + /** + * 售后单号 + */ + private String afterSaleNo; + + /** + * 订单ID + */ + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 订单明细ID + */ + private Long orderItemId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 售后类型(0退款 1退货 2换货) + */ + private Integer type; + + /** + * 申请原因 + */ + private String reason; + + /** + * 问题描述 + */ + private String description; + + /** + * 凭证图片(JSON数组) + */ + private String images; + + /** + * 退款金额(分) + */ + private Integer refundAmount; + + /** + * 数量 + */ + private Integer quantity; + + /** + * 状态(0待审核 1已同意 2已拒绝 3待退货 4待收货 5已完成 6已取消) + */ + private Integer status; + + /** + * 拒绝原因 + */ + private String refuseReason; + + /** + * 退款单号 + */ + private String refundOrderNo; + + /** + * 物流公司 + */ + private String logisticsCompany; + + /** + * 物流单号 + */ + private String logisticsNo; + + /** + * 申请时间 + */ + private Date applyTime; + + /** + * 审核时间 + */ + private Date auditTime; + + /** + * 退货时间 + */ + private Date returnTime; + + /** + * 收货时间 + */ + private Date receiveTime; + + /** + * 退款时间 + */ + private Date refundTime; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallOrderCouponVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallOrderCouponVo.java new file mode 100644 index 0000000..8cb9dca --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallOrderCouponVo.java @@ -0,0 +1,58 @@ +package org.dromara.mall.trade.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.trade.domain.MallOrderCoupon; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 订单优惠券使用记录视图对象 mall_order_coupon + * + * @author pengles + */ +@Data +@AutoMapper(target = MallOrderCoupon.class) +public class MallOrderCouponVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * ID + */ + private Long id; + + /** + * 订单ID + */ + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 优惠券ID + */ + private Long couponId; + + /** + * 用户优惠券ID + */ + private Long couponUserId; + + /** + * 优惠金额(分) + */ + private Integer discountAmount; + + /** + * 使用时间 + */ + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallOrderEvaluateVo.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallOrderEvaluateVo.java new file mode 100644 index 0000000..0d5947d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/domain/vo/MallOrderEvaluateVo.java @@ -0,0 +1,98 @@ +package org.dromara.mall.trade.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.mall.trade.domain.MallOrderEvaluate; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 订单评价视图对象 mall_order_evaluate + * + * @author pengles + */ +@Data +@AutoMapper(target = MallOrderEvaluate.class) +public class MallOrderEvaluateVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 评价ID + */ + private Long id; + + /** + * 订单ID + */ + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 订单明细ID + */ + private Long orderItemId; + + /** + * 商品ID + */ + private Long productId; + + /** + * SKU ID + */ + private Long skuId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 评分(1-5) + */ + private Integer score; + + /** + * 评价内容 + */ + private String content; + + /** + * 评价图片(JSON数组) + */ + private String images; + + /** + * 回复内容 + */ + private String replyContent; + + /** + * 回复时间 + */ + private Date replyTime; + + /** + * 状态(0待审核 1已通过 2已驳回) + */ + private Integer status; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallAddressMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallAddressMapper.java new file mode 100644 index 0000000..67898e8 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallAddressMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.trade.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.trade.domain.MallAddress; +import org.dromara.mall.trade.domain.vo.MallAddressVo; + +/** + * 收货地址Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallAddressMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallAfterSaleMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallAfterSaleMapper.java new file mode 100644 index 0000000..173e052 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallAfterSaleMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.trade.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.trade.domain.MallAfterSale; +import org.dromara.mall.trade.domain.vo.MallAfterSaleVo; + +/** + * 售后申请Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallAfterSaleMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallOrderCouponMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallOrderCouponMapper.java new file mode 100644 index 0000000..397381b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallOrderCouponMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.trade.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.trade.domain.MallOrderCoupon; +import org.dromara.mall.trade.domain.vo.MallOrderCouponVo; + +/** + * 订单优惠券使用记录Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallOrderCouponMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallOrderEvaluateMapper.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallOrderEvaluateMapper.java new file mode 100644 index 0000000..8ac5fc9 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/mapper/MallOrderEvaluateMapper.java @@ -0,0 +1,16 @@ +package org.dromara.mall.trade.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.mall.trade.domain.MallOrderEvaluate; +import org.dromara.mall.trade.domain.vo.MallOrderEvaluateVo; + +/** + * 订单评价Mapper接口 + * + * @author pengles + */ +@Mapper +public interface MallOrderEvaluateMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallAddressService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallAddressService.java new file mode 100644 index 0000000..911fc9d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallAddressService.java @@ -0,0 +1,63 @@ +package org.dromara.mall.trade.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.trade.domain.bo.MallAddressBo; +import org.dromara.mall.trade.domain.vo.MallAddressVo; + +import java.util.Collection; +import java.util.List; + +/** + * 收货地址Service接口 + * + * @author pengles + */ +public interface MallAddressService { + + /** + * 查询收货地址 + */ + MallAddressVo queryById(Long id); + + /** + * 查询收货地址列表 + */ + TableDataInfo queryPageList(MallAddressBo bo, PageQuery pageQuery); + + /** + * 查询收货地址列表 + */ + List queryList(MallAddressBo bo); + + /** + * 查询用户的收货地址列表 + */ + List queryListByUserId(Long userId); + + /** + * 查询用户的默认地址 + */ + MallAddressVo queryDefaultByUserId(Long userId); + + /** + * 新增收货地址 + */ + Boolean insertByBo(MallAddressBo bo); + + /** + * 修改收货地址 + */ + Boolean updateByBo(MallAddressBo bo); + + /** + * 校验并批量删除收货地址信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 设置默认地址 + */ + Boolean setDefault(Long id); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallAfterSaleService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallAfterSaleService.java new file mode 100644 index 0000000..7401a86 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallAfterSaleService.java @@ -0,0 +1,78 @@ +package org.dromara.mall.trade.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.trade.domain.bo.MallAfterSaleBo; +import org.dromara.mall.trade.domain.vo.MallAfterSaleVo; + +import java.util.Collection; +import java.util.List; + +/** + * 售后申请Service接口 + * + * @author pengles + */ +public interface MallAfterSaleService { + + /** + * 查询售后申请 + */ + MallAfterSaleVo queryById(Long id); + + /** + * 根据售后单号查询 + */ + MallAfterSaleVo queryByAfterSaleNo(String afterSaleNo); + + /** + * 查询售后申请列表 + */ + TableDataInfo queryPageList(MallAfterSaleBo bo, PageQuery pageQuery); + + /** + * 查询售后申请列表 + */ + List queryList(MallAfterSaleBo bo); + + /** + * 新增售后申请 + */ + Boolean insertByBo(MallAfterSaleBo bo); + + /** + * 修改售后申请 + */ + Boolean updateByBo(MallAfterSaleBo bo); + + /** + * 校验并批量删除售后申请信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 审核售后申请(同意/拒绝) + */ + Boolean audit(Long id, Integer status, String refuseReason); + + /** + * 用户填写退货物流信息 + */ + Boolean fillLogistics(Long id, String logisticsCompany, String logisticsNo); + + /** + * 确认收货(商家确认收到退货) + */ + Boolean confirmReceive(Long id); + + /** + * 完成售后(退款完成或换货完成) + */ + Boolean complete(Long id); + + /** + * 取消售后申请 + */ + Boolean cancel(Long id); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallOrderCouponService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallOrderCouponService.java new file mode 100644 index 0000000..7fcd731 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallOrderCouponService.java @@ -0,0 +1,77 @@ +package org.dromara.mall.trade.service; + +import org.dromara.mall.trade.domain.bo.MallOrderCouponBo; +import org.dromara.mall.trade.domain.vo.MallOrderCouponVo; + +import java.util.Collection; +import java.util.List; + +/** + * 订单优惠券使用记录Service接口 + * + * @author pengles + */ +public interface MallOrderCouponService { + + /** + * 查询订单优惠券使用记录 + */ + MallOrderCouponVo queryById(Long id); + + /** + * 根据订单ID查询订单优惠券使用记录列表 + */ + List queryListByOrderId(Long orderId); + + /** + * 根据订单号查询订单优惠券使用记录列表 + */ + List queryListByOrderNo(String orderNo); + + /** + * 新增订单优惠券使用记录 + */ + Boolean insertByBo(MallOrderCouponBo bo); + + /** + * 校验并批量删除订单优惠券使用记录信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 计算并使用优惠券(订单创建时调用) + * 计算优惠券折扣金额,创建订单优惠券使用记录,并标记用户优惠券为已使用 + * + * @param couponUserId 用户优惠券ID + * @param orderId 订单ID + * @param orderNo 订单号 + * @param orderAmount 订单金额(单位:分) + * @return 优惠金额(单位:分) + */ + Long calculateAndUseCoupon(Long couponUserId, Long orderId, String orderNo, Long orderAmount); + + /** + * 根据订单ID统计优惠金额 + * + * @param orderId 订单ID + * @return 优惠金额总和(单位:分) + */ + Long sumDiscountAmountByOrderId(Long orderId); + + /** + * 根据订单号统计优惠金额 + * + * @param orderNo 订单号 + * @return 优惠金额总和(单位:分) + */ + Long sumDiscountAmountByOrderNo(String orderNo); + + /** + * 根据优惠券ID统计核销金额 + * + * @param couponId 优惠券ID + * @return 核销金额总和(单位:分) + */ + Long sumDiscountAmountByCouponId(Long couponId); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallOrderEvaluateService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallOrderEvaluateService.java new file mode 100644 index 0000000..38a2248 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/MallOrderEvaluateService.java @@ -0,0 +1,58 @@ +package org.dromara.mall.trade.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.mall.trade.domain.bo.MallOrderEvaluateBo; +import org.dromara.mall.trade.domain.vo.MallOrderEvaluateVo; + +import java.util.Collection; +import java.util.List; + +/** + * 订单评价Service接口 + * + * @author pengles + */ +public interface MallOrderEvaluateService { + + /** + * 查询订单评价 + */ + MallOrderEvaluateVo queryById(Long id); + + /** + * 查询订单评价列表 + */ + TableDataInfo queryPageList(MallOrderEvaluateBo bo, PageQuery pageQuery); + + /** + * 查询订单评价列表 + */ + List queryList(MallOrderEvaluateBo bo); + + /** + * 新增订单评价 + */ + Boolean insertByBo(MallOrderEvaluateBo bo); + + /** + * 修改订单评价 + */ + Boolean updateByBo(MallOrderEvaluateBo bo); + + /** + * 校验并批量删除订单评价信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 审核评价(通过/驳回) + */ + Boolean audit(Long id, Integer status); + + /** + * 回复评价(商家回复) + */ + Boolean reply(Long id, String replyContent); + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/SeckillOrderService.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/SeckillOrderService.java new file mode 100644 index 0000000..5ddaba8 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/SeckillOrderService.java @@ -0,0 +1,102 @@ +package org.dromara.mall.trade.service; + +import java.util.Map; + +/** + * 秒杀订单处理Service接口 + * 提供秒杀订单创建、验证、库存管理等核心业务逻辑 + * + * @author pengles + */ +public interface SeckillOrderService { + + /** + * 验证秒杀活动是否可用 + * + * @param seckillId 秒杀活动ID + * @param quantity 购买数量 + * @return 验证结果信息(包含是否可用、错误信息等) + */ + SeckillValidationResult validateSeckillActivity(Long seckillId, Integer quantity); + + /** + * 验证秒杀限购 + * + * @param seckillId 秒杀活动ID + * @param userId 用户ID + * @param skuId SKU ID + * @return 是否允许购买 + */ + Boolean validatePurchaseLimit(Long seckillId, Long userId, Long skuId); + + /** + * 预扣秒杀库存 + * + * @param seckillId 秒杀活动ID + * @param quantity 购买数量 + * @return 是否成功 + */ + Boolean preDeductSeckillStock(Long seckillId, Integer quantity); + + /** + * 恢复秒杀库存(订单取消时调用) + * + * @param seckillId 秒杀活动ID + * @param quantity 恢复数量 + * @return 是否成功 + */ + Boolean restoreSeckillStock(Long seckillId, Integer quantity); + + /** + * 获取秒杀价格 + * + * @param seckillId 秒杀活动ID + * @return 秒杀价(分) + */ + Integer getSeckillPrice(Long seckillId); + + /** + * 秒杀验证结果 + */ + class SeckillValidationResult { + private Boolean valid; + private String errorMessage; + private Map data; + + public SeckillValidationResult(Boolean valid, String errorMessage) { + this.valid = valid; + this.errorMessage = errorMessage; + } + + public SeckillValidationResult(Boolean valid, String errorMessage, Map data) { + this.valid = valid; + this.errorMessage = errorMessage; + this.data = data; + } + + public Boolean getValid() { + return valid; + } + + public void setValid(Boolean valid) { + this.valid = valid; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public Map getData() { + return data; + } + + public void setData(Map data) { + this.data = data; + } + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallAddressServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallAddressServiceImpl.java new file mode 100644 index 0000000..51f8161 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallAddressServiceImpl.java @@ -0,0 +1,207 @@ +package org.dromara.mall.trade.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.mall.trade.domain.MallAddress; +import org.dromara.mall.trade.domain.bo.MallAddressBo; +import org.dromara.mall.trade.domain.vo.MallAddressVo; +import org.dromara.mall.trade.mapper.MallAddressMapper; +import org.dromara.mall.trade.service.MallAddressService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 收货地址Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class MallAddressServiceImpl implements MallAddressService { + + private final MallAddressMapper baseMapper; + + /** + * 查询收货地址 + */ + @Override + public MallAddressVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询收货地址列表 + */ + @Override + public TableDataInfo queryPageList(MallAddressBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoPage(pageQuery.build(), lqw); + } + + /** + * 查询收货地址列表 + */ + @Override + public List queryList(MallAddressBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + /** + * 查询用户的收货地址列表 + */ + @Override + public List queryListByUserId(Long userId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallAddress::getUserId, userId); + lqw.orderByDesc(MallAddress::getIsDefault); + lqw.orderByDesc(MallAddress::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 查询用户的默认地址 + */ + @Override + public MallAddressVo queryDefaultByUserId(Long userId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallAddress::getUserId, userId); + lqw.eq(MallAddress::getIsDefault, 1); + lqw.last("LIMIT 1"); + List list = baseMapper.selectVoList(lqw); + return list.isEmpty() ? null : list.get(0); + } + + private LambdaQueryWrapper buildQueryWrapper(MallAddressBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(bo.getUserId() != null, MallAddress::getUserId, bo.getUserId()); + lqw.like(StringUtils.isNotBlank(bo.getReceiverName()), MallAddress::getReceiverName, bo.getReceiverName()); + lqw.eq(StringUtils.isNotBlank(bo.getReceiverPhone()), MallAddress::getReceiverPhone, bo.getReceiverPhone()); + lqw.eq(bo.getIsDefault() != null, MallAddress::getIsDefault, bo.getIsDefault()); + lqw.orderByDesc(MallAddress::getIsDefault); + lqw.orderByDesc(MallAddress::getCreateTime); + return lqw; + } + + /** + * 新增收货地址 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallAddressBo bo) { + MallAddress add = MapstructUtils.convert(bo, MallAddress.class); + validEntityBeforeSave(add); + + // 设置用户ID(移动端用户) + if (add.getUserId() == null) { + add.setUserId(LoginHelper.getUserId()); + } + + // 如果设置为默认地址,则取消其他默认地址 + if (add.getIsDefault() != null && add.getIsDefault() == 1) { + cancelOtherDefaultAddress(add.getUserId()); + } else { + add.setIsDefault(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改收货地址 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallAddressBo bo) { + MallAddress update = MapstructUtils.convert(bo, MallAddress.class); + validEntityBeforeSave(update); + + // 如果设置为默认地址,则取消其他默认地址 + if (update.getIsDefault() != null && update.getIsDefault() == 1) { + cancelOtherDefaultAddress(update.getUserId(), update.getId()); + } + + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallAddress entity) { + // 可以添加业务校验逻辑,如手机号格式校验等 + } + + /** + * 取消其他默认地址 + */ + private void cancelOtherDefaultAddress(Long userId) { + cancelOtherDefaultAddress(userId, null); + } + + /** + * 取消其他默认地址(排除指定地址ID) + */ + private void cancelOtherDefaultAddress(Long userId, Long excludeId) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(MallAddress::getUserId, userId); + updateWrapper.eq(MallAddress::getIsDefault, 1); + if (excludeId != null) { + updateWrapper.ne(MallAddress::getId, excludeId); + } + updateWrapper.set(MallAddress::getIsDefault, 0); + baseMapper.update(null, updateWrapper); + } + + /** + * 批量删除收货地址信息 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 可以添加业务校验逻辑 + } + return baseMapper.deleteByIds(ids) > 0; + } + + /** + * 设置默认地址 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean setDefault(Long id) { + MallAddress address = baseMapper.selectById(id); + if (address == null) { + throw new ServiceException("收货地址不存在"); + } + + Long userId = address.getUserId(); + if (userId == null) { + userId = LoginHelper.getUserId(); + } + + // 取消其他默认地址 + cancelOtherDefaultAddress(userId, id); + + // 设置当前地址为默认地址 + address.setIsDefault(1); + return baseMapper.updateById(address) > 0; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallAfterSaleServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallAfterSaleServiceImpl.java new file mode 100644 index 0000000..31ac28d --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallAfterSaleServiceImpl.java @@ -0,0 +1,293 @@ +package org.dromara.mall.trade.service.impl; + +import cn.hutool.core.util.IdUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.mall.trade.domain.MallAfterSale; +import org.dromara.mall.trade.domain.bo.MallAfterSaleBo; +import org.dromara.mall.trade.domain.vo.MallAfterSaleVo; +import org.dromara.mall.trade.mapper.MallAfterSaleMapper; +import org.dromara.mall.trade.service.MallAfterSaleService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 售后申请Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallAfterSaleServiceImpl implements MallAfterSaleService { + + private final MallAfterSaleMapper baseMapper; + + /** + * 查询售后申请 + */ + @Override + public MallAfterSaleVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 根据售后单号查询 + */ + @Override + public MallAfterSaleVo queryByAfterSaleNo(String afterSaleNo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallAfterSale::getAfterSaleNo, afterSaleNo); + return baseMapper.selectVoOne(lqw); + } + + /** + * 查询售后申请列表 + */ + @Override + public TableDataInfo queryPageList(MallAfterSaleBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询售后申请列表 + */ + @Override + public List queryList(MallAfterSaleBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallAfterSaleBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(StringUtils.isNotBlank(bo.getAfterSaleNo()), MallAfterSale::getAfterSaleNo, bo.getAfterSaleNo()); + lqw.eq(bo.getOrderId() != null, MallAfterSale::getOrderId, bo.getOrderId()); + lqw.eq(StringUtils.isNotBlank(bo.getOrderNo()), MallAfterSale::getOrderNo, bo.getOrderNo()); + lqw.eq(bo.getOrderItemId() != null, MallAfterSale::getOrderItemId, bo.getOrderItemId()); + lqw.eq(bo.getUserId() != null, MallAfterSale::getUserId, bo.getUserId()); + lqw.eq(bo.getType() != null, MallAfterSale::getType, bo.getType()); + lqw.eq(bo.getStatus() != null, MallAfterSale::getStatus, bo.getStatus()); + lqw.orderByDesc(MallAfterSale::getCreateTime); + return lqw; + } + + /** + * 生成售后单号 + */ + private String generateAfterSaleNo() { + return "AS" + System.currentTimeMillis() + IdUtil.fastSimpleUUID().substring(0, 6).toUpperCase(); + } + + /** + * 新增售后申请 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallAfterSaleBo bo) { + MallAfterSale add = MapstructUtils.convert(bo, MallAfterSale.class); + validEntityBeforeSave(add); + + // 设置用户ID(移动端用户) + if (add.getUserId() == null) { + add.setUserId(LoginHelper.getUserId()); + } + + // 生成售后单号 + if (StringUtils.isBlank(add.getAfterSaleNo())) { + add.setAfterSaleNo(generateAfterSaleNo()); + } + + // 设置默认状态为待审核 + if (add.getStatus() == null) { + add.setStatus(0); + } + + // 设置申请时间 + if (add.getApplyTime() == null) { + add.setApplyTime(new Date()); + } + + // 设置默认数量 + if (add.getQuantity() == null) { + add.setQuantity(1); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改售后申请 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallAfterSaleBo bo) { + MallAfterSale update = MapstructUtils.convert(bo, MallAfterSale.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallAfterSale entity) { + // 可以添加业务校验逻辑 + } + + /** + * 批量删除售后申请 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 可以添加业务校验逻辑 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 审核售后申请(同意/拒绝) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean audit(Long id, Integer status, String refuseReason) { + MallAfterSale afterSale = baseMapper.selectById(id); + if (afterSale == null) { + throw new ServiceException("售后申请不存在"); + } + if (afterSale.getStatus() != 0) { + throw new ServiceException("售后申请已审核,不能重复审核"); + } + if (status == null || (status != 1 && status != 2)) { + throw new ServiceException("审核状态不正确"); + } + + afterSale.setStatus(status); + afterSale.setAuditTime(new Date()); + + if (status == 2) { + // 拒绝 + if (StringUtils.isBlank(refuseReason)) { + throw new ServiceException("拒绝原因不能为空"); + } + afterSale.setRefuseReason(refuseReason); + } else if (status == 1) { + // 同意 + if (afterSale.getType() == 1 || afterSale.getType() == 2) { + // 退货或换货,状态变为待退货 + afterSale.setStatus(3); + } + // 退款类型,状态保持为已同意,等待退款处理 + } + + return baseMapper.updateById(afterSale) > 0; + } + + /** + * 用户填写退货物流信息 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean fillLogistics(Long id, String logisticsCompany, String logisticsNo) { + MallAfterSale afterSale = baseMapper.selectById(id); + if (afterSale == null) { + throw new ServiceException("售后申请不存在"); + } + if (afterSale.getStatus() != 3) { + throw new ServiceException("当前状态不能填写物流信息"); + } + if (afterSale.getType() == 0) { + throw new ServiceException("退款类型不需要填写物流信息"); + } + + afterSale.setLogisticsCompany(logisticsCompany); + afterSale.setLogisticsNo(logisticsNo); + afterSale.setReturnTime(new Date()); + afterSale.setStatus(4); // 待收货 + + return baseMapper.updateById(afterSale) > 0; + } + + /** + * 确认收货(商家确认收到退货) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean confirmReceive(Long id) { + MallAfterSale afterSale = baseMapper.selectById(id); + if (afterSale == null) { + throw new ServiceException("售后申请不存在"); + } + if (afterSale.getStatus() != 4) { + throw new ServiceException("当前状态不能确认收货"); + } + + afterSale.setReceiveTime(new Date()); + // 状态变为已完成(退款或换货完成需要调用支付中心退款接口或换货接口) + // 这里暂时设置为已完成,实际业务中需要调用支付中心退款接口 + afterSale.setStatus(5); + + return baseMapper.updateById(afterSale) > 0; + } + + /** + * 完成售后(退款完成或换货完成) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean complete(Long id) { + MallAfterSale afterSale = baseMapper.selectById(id); + if (afterSale == null) { + throw new ServiceException("售后申请不存在"); + } + if (afterSale.getStatus() == 5 || afterSale.getStatus() == 6) { + throw new ServiceException("售后申请已完成或已取消"); + } + + afterSale.setStatus(5); + if (afterSale.getType() == 0 || afterSale.getType() == 1) { + // 退款或退货,设置退款时间 + afterSale.setRefundTime(new Date()); + } + + return baseMapper.updateById(afterSale) > 0; + } + + /** + * 取消售后申请 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean cancel(Long id) { + MallAfterSale afterSale = baseMapper.selectById(id); + if (afterSale == null) { + throw new ServiceException("售后申请不存在"); + } + if (afterSale.getStatus() == 5 || afterSale.getStatus() == 6) { + throw new ServiceException("售后申请已完成或已取消"); + } + if (afterSale.getStatus() == 4) { + throw new ServiceException("商家已收货,不能取消"); + } + + afterSale.setStatus(6); + return baseMapper.updateById(afterSale) > 0; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallOrderCouponServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallOrderCouponServiceImpl.java new file mode 100644 index 0000000..156fbec --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallOrderCouponServiceImpl.java @@ -0,0 +1,227 @@ +package org.dromara.mall.trade.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.mall.coupon.domain.vo.MallCouponVo; +import org.dromara.mall.coupon.service.MallCouponService; +import org.dromara.mall.coupon.service.MallCouponUserService; +import org.dromara.mall.trade.domain.MallOrderCoupon; +import org.dromara.mall.trade.domain.bo.MallOrderCouponBo; +import org.dromara.mall.trade.domain.vo.MallOrderCouponVo; +import org.dromara.mall.trade.mapper.MallOrderCouponMapper; +import org.dromara.mall.trade.service.MallOrderCouponService; +import org.dromara.mall.trade.utils.CouponCalculator; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 订单优惠券使用记录Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class MallOrderCouponServiceImpl implements MallOrderCouponService { + + private final MallOrderCouponMapper baseMapper; + private final MallCouponService couponService; + private final MallCouponUserService couponUserService; + + /** + * 查询订单优惠券使用记录 + */ + @Override + public MallOrderCouponVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 根据订单ID查询订单优惠券使用记录列表 + */ + @Override + public List queryListByOrderId(Long orderId) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallOrderCoupon::getOrderId, orderId); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据订单号查询订单优惠券使用记录列表 + */ + @Override + public List queryListByOrderNo(String orderNo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallOrderCoupon::getOrderNo, orderNo); + return baseMapper.selectVoList(lqw); + } + + /** + * 新增订单优惠券使用记录 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallOrderCouponBo bo) { + MallOrderCoupon add = MapstructUtils.convert(bo, MallOrderCoupon.class); + validEntityBeforeSave(add); + + // 设置使用时间 + if (add.getCreateTime() == null) { + add.setCreateTime(new Date()); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallOrderCoupon entity) { + // 可以添加业务校验逻辑 + } + + /** + * 批量删除订单优惠券使用记录信息 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 可以添加业务校验逻辑 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 计算并使用优惠券(订单创建时调用) + * 计算优惠券折扣金额,创建订单优惠券使用记录,并标记用户优惠券为已使用 + * + * @param couponUserId 用户优惠券ID + * @param orderId 订单ID + * @param orderNo 订单号 + * @param orderAmount 订单金额(单位:分) + * @return 优惠金额(单位:分) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long calculateAndUseCoupon(Long couponUserId, Long orderId, String orderNo, Long orderAmount) { + if (couponUserId == null) { + throw new ServiceException("用户优惠券ID不能为空"); + } + if (orderId == null) { + throw new ServiceException("订单ID不能为空"); + } + if (orderNo == null || orderNo.trim().isEmpty()) { + throw new ServiceException("订单号不能为空"); + } + if (orderAmount == null || orderAmount <= 0) { + throw new ServiceException("订单金额必须大于0"); + } + + // 查询用户优惠券信息 + org.dromara.mall.coupon.domain.vo.MallCouponUserVo couponUser = couponUserService.queryById(couponUserId); + if (couponUser == null) { + throw new ServiceException("用户优惠券不存在"); + } + if (couponUser.getStatus() != 0) { + throw new ServiceException("优惠券已使用或已过期"); + } + + // 查询优惠券信息 + MallCouponVo coupon = couponService.queryById(couponUser.getCouponId()); + if (coupon == null) { + throw new ServiceException("优惠券不存在"); + } + + // 计算优惠金额 + Long discountAmount = CouponCalculator.calculateDiscountAmount(coupon, orderAmount); + if (discountAmount <= 0) { + throw new ServiceException("订单金额不满足优惠券使用门槛"); + } + + // 标记用户优惠券为已使用(调用优惠券服务的useCoupon方法) + couponUserService.useCoupon(couponUserId, orderId); + + // 创建订单优惠券使用记录 + MallOrderCouponBo orderCouponBo = new MallOrderCouponBo(); + orderCouponBo.setOrderId(orderId); + orderCouponBo.setOrderNo(orderNo); + orderCouponBo.setCouponId(coupon.getId()); + orderCouponBo.setCouponUserId(couponUserId); + orderCouponBo.setDiscountAmount(discountAmount.intValue()); + + boolean flag = insertByBo(orderCouponBo); + if (!flag) { + throw new ServiceException("创建订单优惠券使用记录失败"); + } + + log.info("优惠券使用成功 - 用户优惠券ID: {}, 订单ID: {}, 订单号: {}, 优惠金额: {}", + couponUserId, orderId, orderNo, discountAmount); + return discountAmount; + } + + /** + * 根据订单ID统计优惠金额 + * + * @param orderId 订单ID + * @return 优惠金额总和(单位:分) + */ + @Override + public Long sumDiscountAmountByOrderId(Long orderId) { + if (orderId == null) { + return 0L; + } + List list = queryListByOrderId(orderId); + return list.stream() + .mapToLong(vo -> vo.getDiscountAmount() != null ? vo.getDiscountAmount().longValue() : 0L) + .sum(); + } + + /** + * 根据订单号统计优惠金额 + * + * @param orderNo 订单号 + * @return 优惠金额总和(单位:分) + */ + @Override + public Long sumDiscountAmountByOrderNo(String orderNo) { + if (orderNo == null || orderNo.trim().isEmpty()) { + return 0L; + } + List list = queryListByOrderNo(orderNo); + return list.stream() + .mapToLong(vo -> vo.getDiscountAmount() != null ? vo.getDiscountAmount().longValue() : 0L) + .sum(); + } + + /** + * 根据优惠券ID统计核销金额 + * + * @param couponId 优惠券ID + * @return 核销金额总和(单位:分) + */ + @Override + public Long sumDiscountAmountByCouponId(Long couponId) { + if (couponId == null) { + return 0L; + } + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(MallOrderCoupon::getCouponId, couponId); + List list = baseMapper.selectVoList(lqw); + return list.stream() + .mapToLong(vo -> vo.getDiscountAmount() != null ? vo.getDiscountAmount().longValue() : 0L) + .sum(); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallOrderEvaluateServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallOrderEvaluateServiceImpl.java new file mode 100644 index 0000000..dd180b1 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/MallOrderEvaluateServiceImpl.java @@ -0,0 +1,172 @@ +package org.dromara.mall.trade.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.mall.trade.domain.MallOrderEvaluate; +import org.dromara.mall.trade.domain.bo.MallOrderEvaluateBo; +import org.dromara.mall.trade.domain.vo.MallOrderEvaluateVo; +import org.dromara.mall.trade.mapper.MallOrderEvaluateMapper; +import org.dromara.mall.trade.service.MallOrderEvaluateService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 订单评价Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class MallOrderEvaluateServiceImpl implements MallOrderEvaluateService { + + private final MallOrderEvaluateMapper baseMapper; + + /** + * 查询订单评价 + */ + @Override + public MallOrderEvaluateVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询订单评价列表 + */ + @Override + public TableDataInfo queryPageList(MallOrderEvaluateBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询订单评价列表 + */ + @Override + public List queryList(MallOrderEvaluateBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(MallOrderEvaluateBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(bo.getOrderId() != null, MallOrderEvaluate::getOrderId, bo.getOrderId()); + lqw.eq(StringUtils.isNotBlank(bo.getOrderNo()), MallOrderEvaluate::getOrderNo, bo.getOrderNo()); + lqw.eq(bo.getOrderItemId() != null, MallOrderEvaluate::getOrderItemId, bo.getOrderItemId()); + lqw.eq(bo.getProductId() != null, MallOrderEvaluate::getProductId, bo.getProductId()); + lqw.eq(bo.getSkuId() != null, MallOrderEvaluate::getSkuId, bo.getSkuId()); + lqw.eq(bo.getUserId() != null, MallOrderEvaluate::getUserId, bo.getUserId()); + lqw.eq(bo.getScore() != null, MallOrderEvaluate::getScore, bo.getScore()); + lqw.eq(bo.getStatus() != null, MallOrderEvaluate::getStatus, bo.getStatus()); + lqw.orderByDesc(MallOrderEvaluate::getCreateTime); + return lqw; + } + + /** + * 新增订单评价 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean insertByBo(MallOrderEvaluateBo bo) { + MallOrderEvaluate add = MapstructUtils.convert(bo, MallOrderEvaluate.class); + validEntityBeforeSave(add); + + // 设置用户ID(移动端用户) + if (add.getUserId() == null) { + add.setUserId(LoginHelper.getUserId()); + } + + // 设置默认状态为待审核 + if (add.getStatus() == null) { + add.setStatus(0); + } + + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改订单评价 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean updateByBo(MallOrderEvaluateBo bo) { + MallOrderEvaluate update = MapstructUtils.convert(bo, MallOrderEvaluate.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(MallOrderEvaluate entity) { + // 可以添加业务校验逻辑 + } + + /** + * 批量删除订单评价 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 可以添加业务校验逻辑 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 审核评价(通过/驳回) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean audit(Long id, Integer status) { + MallOrderEvaluate evaluate = baseMapper.selectById(id); + if (evaluate == null) { + throw new ServiceException("评价不存在"); + } + if (evaluate.getStatus() != 0) { + throw new ServiceException("评价已审核,不能重复审核"); + } + if (status == null || (status != 1 && status != 2)) { + throw new ServiceException("审核状态不正确"); + } + + evaluate.setStatus(status); + return baseMapper.updateById(evaluate) > 0; + } + + /** + * 回复评价(商家回复) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean reply(Long id, String replyContent) { + MallOrderEvaluate evaluate = baseMapper.selectById(id); + if (evaluate == null) { + throw new ServiceException("评价不存在"); + } + if (evaluate.getStatus() != 1) { + throw new ServiceException("只有已通过审核的评价才能回复"); + } + + evaluate.setReplyContent(replyContent); + evaluate.setReplyTime(new Date()); + return baseMapper.updateById(evaluate) > 0; + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/SeckillOrderServiceImpl.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/SeckillOrderServiceImpl.java new file mode 100644 index 0000000..bebcf2b --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/service/impl/SeckillOrderServiceImpl.java @@ -0,0 +1,175 @@ +package org.dromara.mall.trade.service.impl; + +import cn.hutool.core.lang.Dict; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.mall.promotion.domain.MallPromotion; +import org.dromara.mall.promotion.domain.vo.MallPromotionVo; +import org.dromara.mall.promotion.domain.vo.MallSeckillVo; +import org.dromara.mall.promotion.mapper.MallPromotionMapper; +import org.dromara.mall.promotion.service.MallSeckillService; +import org.dromara.mall.trade.service.SeckillOrderService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * 秒杀订单处理Service业务层实现 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class SeckillOrderServiceImpl implements SeckillOrderService { + + private final MallSeckillService mallSeckillService; + private final MallPromotionMapper promotionMapper; + + /** + * 活动类型:秒杀 + */ + private static final Integer PROMOTION_TYPE_SECKILL = 2; + + /** + * 默认限购数量 + */ + private static final Integer DEFAULT_PURCHASE_LIMIT = 1; + + /** + * 验证秒杀活动是否可用 + */ + @Override + public SeckillValidationResult validateSeckillActivity(Long seckillId, Integer quantity) { + // 查询秒杀活动 + MallSeckillVo seckillVo = mallSeckillService.queryById(seckillId); + if (seckillVo == null) { + return new SeckillValidationResult(false, "秒杀活动不存在"); + } + + // 验证秒杀活动状态 + if (seckillVo.getStatus() != 0) { + return new SeckillValidationResult(false, "秒杀活动已禁用"); + } + + // 验证秒杀活动时间 + Date now = new Date(); + if (seckillVo.getStartTime() != null && seckillVo.getStartTime().after(now)) { + return new SeckillValidationResult(false, "秒杀活动尚未开始"); + } + if (seckillVo.getEndTime() != null && seckillVo.getEndTime().before(now)) { + return new SeckillValidationResult(false, "秒杀活动已结束"); + } + + // 验证秒杀库存 + if (seckillVo.getSeckillStock() == null || seckillVo.getSeckillStock() < quantity) { + return new SeckillValidationResult(false, "秒杀库存不足"); + } + + // 验证关联的营销活动 + if (seckillVo.getPromotionId() != null) { + MallPromotionVo promotionVo = promotionMapper.selectVoById(seckillVo.getPromotionId()); + if (promotionVo == null) { + return new SeckillValidationResult(false, "关联的营销活动不存在"); + } + if (promotionVo.getStatus() != 0) { + return new SeckillValidationResult(false, "关联的营销活动已禁用"); + } + if (!PROMOTION_TYPE_SECKILL.equals(promotionVo.getType())) { + return new SeckillValidationResult(false, "关联的营销活动类型不正确"); + } + } + + // 返回验证通过的结果,包含秒杀信息 + Map data = new HashMap<>(); + data.put("seckillPrice", seckillVo.getSeckillPrice()); + data.put("promotionId", seckillVo.getPromotionId()); + data.put("skuId", seckillVo.getSkuId()); + + return new SeckillValidationResult(true, null, data); + } + + /** + * 验证秒杀限购 + */ + @Override + public Boolean validatePurchaseLimit(Long seckillId, Long userId, Long skuId) { + // 查询秒杀活动信息 + MallSeckillVo seckillVo = mallSeckillService.queryById(seckillId); + if (seckillVo == null) { + throw new ServiceException("秒杀活动不存在"); + } + + // 获取限购数量(从营销活动的rules JSON中读取,或使用默认值) + Integer purchaseLimit = DEFAULT_PURCHASE_LIMIT; + if (seckillVo.getPromotionId() != null) { + MallPromotion promotion = promotionMapper.selectById(seckillVo.getPromotionId()); + if (promotion != null && promotion.getRules() != null) { + try { + Dict rules = JsonUtils.parseMap(promotion.getRules()); + if (rules != null && rules.containsKey("purchaseLimit")) { + Object limitObj = rules.get("purchaseLimit"); + if (limitObj instanceof Number) { + purchaseLimit = ((Number) limitObj).intValue(); + } + } + } catch (Exception e) { + log.warn("解析营销活动规则JSON失败,使用默认限购数量: {}", e.getMessage()); + } + } + } + + // TODO: 查询用户已购买的秒杀订单数量 + // 注意:由于订单实体类和服务尚未实现,这里暂时返回true(允许购买) + // 后续需要在订单服务中实现此逻辑: + // 1. 查询用户已购买的秒杀订单数量(同一秒杀活动、同一SKU,仅统计已支付的订单) + // 2. 校验是否超过限购数量 + // 示例代码(需要等订单服务实现后): + // LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + // lqw.eq(MallOrder::getUserId, userId); + // lqw.eq(MallOrder::getSeckillId, seckillId); + // lqw.eq(MallOrder::getPayStatus, PAY_STATUS_PAID); + // lqw.ne(MallOrder::getOrderStatus, 4); + // Long purchasedCount = orderMapper.selectCount(lqw); + // return purchasedCount == null || purchasedCount < purchaseLimit; + + log.info("秒杀限购校验 - 秒杀ID: {}, 用户ID: {}, SKU ID: {}, 限购数量: {} (待订单服务实现后完善)", seckillId, userId, skuId, purchaseLimit); + return true; // 临时返回true,待订单服务实现后完善 + } + + /** + * 预扣秒杀库存 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean preDeductSeckillStock(Long seckillId, Integer quantity) { + return mallSeckillService.reduceStock(seckillId, quantity); + } + + /** + * 恢复秒杀库存(订单取消时调用) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean restoreSeckillStock(Long seckillId, Integer quantity) { + return mallSeckillService.restoreStock(seckillId, quantity); + } + + /** + * 获取秒杀价格 + */ + @Override + public Integer getSeckillPrice(Long seckillId) { + MallSeckillVo seckillVo = mallSeckillService.queryById(seckillId); + if (seckillVo == null) { + throw new ServiceException("秒杀活动不存在"); + } + return seckillVo.getSeckillPrice(); + } + +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/utils/CouponCalculator.java b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/utils/CouponCalculator.java new file mode 100644 index 0000000..10cdd21 --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/java/org/dromara/mall/trade/utils/CouponCalculator.java @@ -0,0 +1,139 @@ +package org.dromara.mall.trade.utils; + +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.mall.coupon.domain.vo.MallCouponVo; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 优惠券计算工具类 + * 用于计算优惠券的折扣金额 + * + * @author pengles + */ +@Slf4j +public class CouponCalculator { + + /** + * 计算优惠券的折扣金额(单位:分) + * + * @param coupon 优惠券信息 + * @param orderAmount 订单金额(单位:分) + * @return 折扣金额(单位:分),如果订单金额不满足使用门槛,返回0 + */ + public static Long calculateDiscountAmount(MallCouponVo coupon, Long orderAmount) { + if (coupon == null) { + throw new ServiceException("优惠券信息不能为空"); + } + if (orderAmount == null || orderAmount <= 0) { + throw new ServiceException("订单金额必须大于0"); + } + + // 检查使用门槛 + Integer minAmount = coupon.getMinAmount(); + if (minAmount != null && minAmount > 0 && orderAmount < minAmount) { + log.debug("订单金额{}不满足优惠券{}的使用门槛{}", orderAmount, coupon.getId(), minAmount); + return 0L; + } + + Integer type = coupon.getType(); + if (type == null) { + throw new ServiceException("优惠券类型不能为空"); + } + + Long discountAmount = 0L; + + // 0-满减,1-折扣,2-现金 + if (type == 0 || type == 2) { + // 满减券或现金券:直接使用 discountAmount + Integer couponDiscountAmount = coupon.getDiscountAmount(); + if (couponDiscountAmount == null || couponDiscountAmount <= 0) { + throw new ServiceException("优惠券优惠金额配置错误"); + } + discountAmount = couponDiscountAmount.longValue(); + // 确保折扣金额不超过订单金额 + if (discountAmount > orderAmount) { + discountAmount = orderAmount; + } + } else if (type == 1) { + // 折扣券:使用 discountRate(百分比) + BigDecimal discountRate = coupon.getDiscountRate(); + if (discountRate == null || discountRate.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("优惠券折扣率配置错误"); + } + if (discountRate.compareTo(new BigDecimal("100")) > 0) { + throw new ServiceException("折扣率不能大于100%"); + } + // 计算折扣金额:订单金额 * (折扣率 / 100) + BigDecimal orderAmountDecimal = new BigDecimal(orderAmount); + BigDecimal discountAmountDecimal = orderAmountDecimal + .multiply(discountRate) + .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + discountAmount = discountAmountDecimal.setScale(0, RoundingMode.HALF_UP).longValue(); + // 确保折扣金额不超过订单金额 + if (discountAmount > orderAmount) { + discountAmount = orderAmount; + } + } else { + throw new ServiceException("不支持的优惠券类型: " + type); + } + + log.debug("优惠券{}计算折扣金额: 订单金额={}, 折扣金额={}", coupon.getId(), orderAmount, discountAmount); + return discountAmount; + } + + /** + * 计算优惠券的折扣金额(单位:分) + * 使用优惠券ID和订单金额,需要传入优惠券查询方法 + * + * @param couponService 优惠券服务,用于查询优惠券信息 + * @param couponId 优惠券ID + * @param orderAmount 订单金额(单位:分) + * @return 折扣金额(单位:分),如果订单金额不满足使用门槛或优惠券不存在,返回0 + */ + public static Long calculateDiscountAmount( + org.dromara.mall.coupon.service.MallCouponService couponService, + Long couponId, + Long orderAmount) { + if (couponService == null) { + throw new ServiceException("优惠券服务不能为空"); + } + if (couponId == null) { + throw new ServiceException("优惠券ID不能为空"); + } + + org.dromara.mall.coupon.domain.vo.MallCouponVo coupon = couponService.queryById(couponId); + if (coupon == null) { + log.warn("优惠券{}不存在", couponId); + return 0L; + } + + return calculateDiscountAmount(coupon, orderAmount); + } + + /** + * 验证优惠券是否可用 + * + * @param coupon 优惠券信息 + * @param orderAmount 订单金额(单位:分) + * @return true表示可用,false表示不可用 + */ + public static boolean isValidCoupon(MallCouponVo coupon, Long orderAmount) { + if (coupon == null) { + return false; + } + // 检查状态 + if (coupon.getStatus() != null && coupon.getStatus() != 0) { + return false; + } + // 检查使用门槛 + Integer minAmount = coupon.getMinAmount(); + if (minAmount != null && minAmount > 0 && orderAmount < minAmount) { + return false; + } + // 检查有效期(如果订单服务需要,可以添加时间校验) + return true; + } +} diff --git a/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/resources/META-INF/spring.factories b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..e08a02f --- /dev/null +++ b/ruoyi-modules/ruoyi-mall/ruoyi-mall-trade/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.dromara.mall.trade.config.MallTradeAutoConfiguration diff --git a/ruoyi-modules/ruoyi-member/MODULE_STRUCTURE.md b/ruoyi-modules/ruoyi-member/MODULE_STRUCTURE.md new file mode 100644 index 0000000..cee8c20 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/MODULE_STRUCTURE.md @@ -0,0 +1,935 @@ +# 会员模块结构说明 + +## 模块概述 + +`ruoyi-member` 模块是系统会员中心模块,负责 App 端用户管理、认证授权、设备管理、消息推送等核心功能。 + +- **包名**: `org.dromara.member` +- **状态**: ✅ 核心功能已实现 + +## 已完成工作 + +### 1. 模块结构 + +- ✅ 创建了完整的模块目录结构 +- ✅ 创建了 `pom.xml` 配置文件 +- ✅ 配置了模块依赖关系 + +### 2. 配置类 + +- ✅ 创建了 JWT 过滤器配置 (`AppJwtFilter`) +- ✅ 创建了 JWT Web 配置 (`AppJwtWebConfig`) +- ✅ 配置了组件扫描和过滤器注册 + +### 3. 数据库 + +- ✅ 创建了所有业务表的实体类和 Mapper +- ✅ 实现了数据库表的 CRUD 操作 +- ✅ 配置了逻辑删除和字段自动填充 + +### 4. 核心功能 + +- ✅ 用户管理(注册、登录、信息管理) +- ✅ 角色管理(角色CRUD、用户角色关联) +- ✅ 设备管理(设备绑定、推送令牌管理) +- ✅ 文件管理(文件上传、下载、管理) +- ✅ 消息管理(消息推送、通知管理) +- ✅ 操作日志(日志记录、查询、统计) +- ✅ 登录日志(登录日志记录、查询) +- ✅ 版本管理(App版本管理、更新检查) +- ✅ 社交认证(第三方登录、绑定) +- ✅ JWT认证(令牌生成、刷新、验证) + +### 5. 异常处理 + +- ✅ 创建了统一的异常处理类 (`AppExceptionHandler`) +- ✅ 定义了业务异常类(`DeviceException`, `FileException`, `VersionException`) + +### 6. API接口 + +- ✅ 实现了管理端接口(用户、角色、配置管理) +- ✅ 实现了移动端接口(用户、角色、设备、文件、消息、日志、版本管理) +- ✅ 实现了认证接口(JWT登录、注册、刷新) + +## 核心功能模块 + +### 1. 用户管理 (AppUser) + +- **职责**: 用户注册、登录、信息管理、密码管理 +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_user`, `app_user_role` + +### 2. 角色管理 (AppRole) + +- **职责**: 角色管理、用户角色关联 +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_role`, `app_user_role` + +### 3. 设备管理 (AppDevice) + +- **职责**: 用户设备绑定、推送令牌管理、设备状态管理 +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_device` + +### 4. 文件管理 (AppFile) + +- **职责**: 文件上传、下载、管理(头像、文档、图片等) +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_file` + +### 5. 消息管理 (AppMessage) + +- **职责**: 消息推送、通知管理、用户消息关联 +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_message`, `app_user_message` + +### 6. 操作日志 (AppOperLog) + +- **职责**: 用户操作日志记录、查询、统计分析 +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_oper_log` + +### 7. 登录日志 (AppLoginLog) + +- **职责**: 用户登录日志记录、查询 +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_login_log` + +### 8. 版本管理 (AppVersion) + +- **职责**: App 版本管理、更新检查、强制更新 +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_version` + +### 9. 社交认证 (AppSocialAuth) + +- **职责**: 第三方登录(微信、QQ、微博等) +- **状态**: ✅ 核心功能已实现 +- **数据库表**: `app_social_auth` + +### 10. JWT认证 (JwtUtil) + +- **职责**: JWT 令牌生成、刷新、验证 +- **状态**: ✅ 核心功能已实现 +- **配置文件**: `AppJwtFilter`, `AppJwtWebConfig` + +## 数据库表结构说明 + +### 1. 用户表 (app_user) + +- **功能**: 存储 App 端用户基本信息 +- **关键字段**: `user_id`, `user_name`, `nick_name`, `phone`, `email`, `password`, `status`, `avatar`, `sex`, `login_ip`, `login_date`, `register_time` +- **索引**: `user_name` (唯一), `phone`, `email` +- **状态说明**: + - `status`: 0-正常, 1-停用 + - `sex`: 0-男, 1-女, 2-未知 + - `del_flag`: 0-存在, 1-删除 + +### 2. 角色表 (app_role) + +- **功能**: 存储 App 端角色信息 +- **关键字段**: `role_id`, `role_name`, `role_key`, `role_sort`, `status` +- **索引**: `role_key` (唯一), `status` +- **状态说明**: + - `status`: 0-正常, 1-停用 + - `del_flag`: 0-存在, 1-删除 + +### 3. 用户角色关联表 (app_user_role) + +- **功能**: 存储用户和角色的关联关系 +- **关键字段**: `user_id`, `role_id` +- **索引**: `user_id`, `role_id`, `uk_user_role` (唯一索引) +- **说明**: 多对多关系,一个用户可以有多个角色,一个角色可以分配给多个用户 + +### 4. 设备表 (app_device) + +- **功能**: 存储用户绑定的设备信息 +- **关键字段**: `device_id`, `user_id`, `device_type`, `device_name`, `device_model`, `device_brand`, `os_version`, `app_version`, `push_token`, `is_active`, `last_active_time` +- **索引**: `user_id`, `device_type`, `push_token` +- **状态说明**: + - `device_type`: ios, android, h5 + - `is_active`: 0-否, 1-是 + - `del_flag`: 0-存在, 2-删除 + +### 5. 文件表 (app_file) + +- **功能**: 存储用户上传的文件信息 +- **关键字段**: `file_id`, `user_id`, `file_name`, `original_name`, `file_path`, `file_url`, `file_size`, `file_type`, `file_suffix`, `file_category`, `storage_service`, `status`, `download_count`, `is_public`, `file_md5` +- **索引**: `user_id`, `file_category`, `file_md5`, `status` +- **状态说明**: + - `file_category`: 1-头像, 2-文档, 3-图片, 4-视频, 5-音频, 6-其他 + - `status`: 0-正常, 1-删除 + - `is_public`: 0-私有, 1-公开 + - `del_flag`: 0-存在, 2-删除 + +### 6. 消息表 (app_message) + +- **功能**: 存储系统消息和通知 +- **关键字段**: `message_id`, `title`, `content`, `message_type`, `target_type`, `target_ids`, `send_time`, `status` +- **索引**: `message_type`, `target_type`, `status`, `send_time` +- **状态说明**: + - `message_type`: system-系统消息, notice-通知, activity-活动 + - `target_type`: all-全部用户, user-指定用户, role-指定角色 + - `status`: 0-待发送, 1-已发送, 2-发送失败 + - `del_flag`: 0-存在, 2-删除 + +### 7. 用户消息关联表 (app_user_message) + +- **功能**: 存储用户消息的读取记录 +- **关键字段**: `id`, `user_id`, `message_id`, `is_read`, `read_time` +- **索引**: `user_id`, `message_id`, `is_read` +- **状态说明**: + - `is_read`: 0-未读, 1-已读 + - `del_flag`: 0-存在, 2-删除 + +### 8. 操作日志表 (app_oper_log) + +- **功能**: 存储用户操作日志 +- **关键字段**: `oper_id`, `title`, `business_type`, `method`, `request_method`, `oper_name`, `oper_url`, `oper_ip`, `oper_location`, `oper_param`, `json_result`, `status`, `error_msg`, `oper_time`, `cost_time` +- **索引**: `oper_name`, `status`, `oper_time`, `business_type` +- **状态说明**: + - `business_type`: 0-其它, 1-新增, 2-修改, 3-删除 + - `status`: 0-正常, 1-异常 + +### 9. 登录日志表 (app_login_log) + +- **功能**: 存储用户登录日志 +- **关键字段**: `info_id`, `user_name`, `status`, `ipaddr`, `login_location`, `browser`, `os`, `msg`, `login_time` +- **索引**: `user_name`, `status`, `login_time`, `ipaddr` +- **状态说明**: + - `status`: 0-成功, 1-失败 + +### 10. 版本表 (app_version) + +- **功能**: 存储 App 版本信息 +- **关键字段**: `version_id`, `version_code`, `version_name`, `update_content`, `download_url`, `is_force`, `platform` +- **索引**: `platform`, `version_code` +- **状态说明**: + - `platform`: android, ios + - `is_force`: 0-否, 1-是 + - `del_flag`: 0-存在, 2-删除 + +### 11. 社交认证表 (app_social_auth) + +- **功能**: 存储第三方登录授权信息 +- **关键字段**: `id`, `user_id`, `platform`, `platform_uid`, `open_id`, `union_id`, `access_token`, `expire_in`, `refresh_token`, `scope` +- **索引**: `user_id`, `platform`, `platform_uid`, `open_id`, `union_id` +- **状态说明**: + - `platform`: wechat_mp-微信小程序, wechat_open-微信开放平台, qq-QQ, weibo-微博 + - `del_flag`: 0-存在, 2-删除 + +## 功能清单 + +### 1. 用户管理 + +#### 已实现功能 ✅ + +##### 用户基础功能 + +- ✅ 用户注册 + - 账号注册(支持用户名、手机号、邮箱注册) + - 账号唯一性校验 + - 手机号唯一性校验 + - 邮箱唯一性校验 + - 注册时自动分配默认角色 +- ✅ 用户登录 + - 账号密码登录 + - 手机号验证码登录 ✅(在 ruoyi-server 模块实现) + - 邮箱验证码登录 ✅(在 ruoyi-server 模块实现) + - 验证码发送(短信/邮箱)✅(在 ruoyi-server 模块实现) + - JWT 令牌生成 + - 登录日志记录 +- ✅ 用户信息管理 + - 用户列表查询(分页、条件筛选) + - 用户详情查询 + - 用户新增/修改/删除 + - 用户状态管理(启用/停用) + - 用户信息导出 +- ✅ 用户资料管理 + - 用户资料查询 + - 用户资料修改 + - 头像管理(关联文件表) +- ✅ 密码管理 + - 修改密码(需验证旧密码) + - 重置密码(管理员操作) + - 密码加密(BCrypt) +- ✅ 第三方账号绑定 + - 第三方账号绑定/解绑 ✅(在 ruoyi-server 模块实现) + - 查询绑定列表 ✅ + +#### 待实现功能 ⏳ + +- [ ] 忘记密码(通过手机号/邮箱验证码找回密码) +- [ ] 独立的手机号/邮箱绑定功能(当前可通过更新用户资料方式修改) +- [ ] 用户等级体系 + - 用户等级定义 + - 用户等级升级规则 + - 用户积分/经验值管理 +- [ ] 用户标签 + - 用户标签管理 + - 用户标签关联 + - 标签筛选用户 + +### 2. 角色管理 + +#### 已实现功能 ✅ + +- ✅ 角色CRUD操作 + - 角色列表查询(分页、条件筛选) + - 角色详情查询 + - 角色新增/修改/删除 + - 角色导出功能 +- ✅ 角色状态管理 + - 启用/禁用角色 +- ✅ 用户角色关联 + - 查询用户已分配角色 + - 查询用户未分配角色 + - 分配角色给用户 + - 取消用户角色关联 + - 批量分配/取消角色 + +#### 待实现功能 ⏳ + +- [ ] 角色权限管理 + - 角色权限配置 + - 权限点管理 + - 权限验证(需集成权限框架) +- [ ] 角色数据权限 + - 数据权限范围配置 + - 数据权限过滤 + +### 3. 设备管理 + +#### 已实现功能 ✅ + +- ✅ 设备绑定 + - 设备信息绑定 + - 设备信息更新 +- ✅ 设备查询 + - 设备列表查询 + - 设备详情查询 + - 我的设备列表查询 + - 活跃设备查询 +- ✅ 设备状态管理 + - 设备活跃状态更新 + - 推送令牌管理 + - 设备心跳检测 +- ✅ 设备解绑 + - 设备解绑功能 + +#### 待实现功能 ⏳ + +- [ ] 设备推送管理 + - 消息推送(需集成推送服务) + - 推送记录查询 + - 推送统计 +- [ ] 设备安全 + - 设备指纹识别 + - 异常设备检测 + - 设备黑名单管理 + +### 4. 文件管理 + +#### 已实现功能 ✅ + +- ✅ 文件上传 + - 单文件上传 + - 批量文件上传 + - 头像上传 +- ✅ 文件查询 + - 文件列表查询(分页) + - 文件详情查询 + - 按分类查询文件 + - 公开文件查询 +- ✅ 文件下载 + - 文件下载 + - 下载次数统计 +- ✅ 文件管理 + - 文件信息修改 + - 文件状态管理 + - 文件公开/私有设置 + - 文件删除 +- ✅ 文件校验 + - 文件MD5校验(秒传功能) + - 文件统计 + +#### 待实现功能 ⏳ + +- [ ] 文件存储优化 + - 文件存储服务商集成(OSS、COS等) + - 文件CDN加速 + - 文件压缩处理 +- [ ] 文件预览 + - 图片预览 + - 文档预览(PDF、Office等) + - 视频播放 +- [ ] 文件分享 + - 文件分享链接生成 + - 分享链接访问控制 + - 分享统计 + +### 5. 消息管理 + +#### 已实现功能 ✅ + +- ✅ 消息管理 + - 消息列表查询(分页) + - 消息详情查询 + - 消息新增/修改/删除 + - 消息导出 +- ✅ 用户消息 + - 我的消息列表查询 + - 未读消息数量查询 + - 消息标记已读 + - 批量标记已读 + - 全部标记已读 +- ✅ 消息推送 + - 系统消息发送 + - 个人消息发送 + - 消息推送(需集成推送服务) + - 消息广播 + +#### 待实现功能 ⏳ + +- [ ] 消息推送集成 + - 推送服务集成(极光推送、个推等) + - 推送消息发送 + - 推送结果回调 + - 推送统计 +- [ ] 消息模板 + - 消息模板管理 + - 模板变量替换 +- [ ] 消息分类 + - 消息分类管理 + - 消息分类筛选 +- [ ] 消息设置 + - 用户消息推送设置 + - 消息免打扰时段 + - 消息接收方式设置 + +### 6. 操作日志 + +#### 已实现功能 ✅ + +- ✅ 日志记录 + - 操作日志记录 + - 批量操作日志记录 +- ✅ 日志查询 + - 日志列表查询(分页、条件筛选) + - 我的操作日志查询 + - 日志详情查询 + - 日志导出 +- ✅ 日志统计 + - 操作日志统计 + - 行为分析 + - 热门操作统计 + - 错误操作统计 +- ✅ 日志清理 + - 日志清理 + - 按时间清理日志 + +#### 待实现功能 ⏳ + +- [ ] 日志分析 + - 操作趋势分析 + - 用户行为分析 + - 异常操作分析 +- [ ] 日志告警 + - 异常操作告警 + - 告警规则配置 + +### 7. 登录日志 + +#### 已实现功能 ✅ + +- ✅ 登录日志记录 + - 登录成功/失败日志记录 + - 登录IP、地点、浏览器等信息记录 +- ✅ 登录日志查询 + - 登录日志列表查询(分页、条件筛选) + - 登录日志详情查询 + - 登录日志导出 + +#### 待实现功能 ⏳ + +- [ ] 登录安全 + - 异常登录检测 + - 异地登录提醒 + - 登录IP白名单/黑名单 +- [ ] 登录统计 + - 登录统计报表 + - 登录趋势分析 + - 登录设备分析 + +### 8. 版本管理 + +#### 已实现功能 ✅ + +- ✅ 版本管理 + - 版本列表查询(分页) + - 版本详情查询 + - 版本新增/修改/删除 + - 版本导出 +- ✅ 版本检查 + - 检查更新 + - 获取最新版本 + - 版本历史查询 + - 强制更新检查 + - 更新信息查询 + - 版本对比 +- ✅ 版本统计 + - 版本下载记录 + - 版本安装记录 + +#### 待实现功能 ⏳ + +- [ ] 版本发布流程 + - 版本发布审核 + - 版本发布通知 +- [ ] 版本回退 + - 版本回退功能 + - 回退记录 + +### 9. 社交认证 + +#### 已实现功能 ✅ + +- ✅ 第三方登录 + - 微信小程序登录 + - 微信开放平台登录 + - QQ登录 + - 微博登录 +- ✅ 授权管理 + - 授权信息存储 + - 授权信息查询 + - 授权绑定/解绑 ✅(在 ruoyi-server 模块实现) + +#### 待实现功能 ⏳ + +- [ ] 更多第三方登录 + - 支付宝登录 + - 抖音登录 + - 苹果登录(Apple ID) +- [ ] 授权管理增强 + - 授权过期处理 + - 授权刷新 + - 授权撤销 + +### 10. JWT认证 + +#### 已实现功能 ✅ + +- ✅ JWT令牌生成 + - Access Token生成 + - Refresh Token生成 +- ✅ JWT令牌验证 + - Token验证过滤器 + - Token有效性校验 +- ✅ JWT令牌刷新 + - Token刷新接口 +- ✅ 用户登录/登出 + - 登录接口(生成Token) + - 登出接口(Token失效) +- ✅ 验证码 + - 图形验证码生成 + +#### 待实现功能 ⏳ + +- [ ] Token安全增强 + - Token黑名单(Redis存储) + - Token刷新策略优化 + - Token过期时间配置 +- [ ] 多端登录管理 + - 多设备登录控制 + - 登录设备列表查询 + - 强制下线功能 + +## API接口详细说明 + +### 管理端接口 + +#### 用户管理 (`/admin/member/user`) + +- `GET /admin/member/user/list` - 查询用户列表(分页) +- `GET /admin/member/user/{userId}` - 获取用户详情 +- `POST /admin/member/user` - 新增用户 +- `PUT /admin/member/user` - 修改用户 +- `DELETE /admin/member/user/{userIds}` - 删除用户 +- `POST /admin/member/user/export` - 导出用户列表 +- `GET /admin/member/user/profile` - 获取当前用户资料 +- `PUT /admin/member/user/profile` - 修改当前用户资料 +- `PUT /admin/member/user/resetPwd` - 重置用户密码 +- `PUT /admin/member/user/changeStatus` - 修改用户状态 +- `GET /admin/member/user/authRole/{userId}` - 查询用户角色 +- `GET /admin/member/user/deptTree` - 查询部门树(预留) + +#### 角色管理 (`/admin/member/role`) + +- `GET /admin/member/role/list` - 查询角色列表(分页) +- `GET /admin/member/role/{roleId}` - 获取角色详情 +- `POST /admin/member/role` - 新增角色 +- `PUT /admin/member/role` - 修改角色 +- `DELETE /admin/member/role/{roleIds}` - 删除角色 +- `POST /admin/member/role/export` - 导出角色列表 +- `GET /admin/member/role/optionselect` - 查询角色选项列表 +- `GET /admin/member/role/authUser/allocatedList` - 查询已分配用户角色列表 +- `GET /admin/member/role/authUser/unallocatedList` - 查询未分配用户角色列表 +- `PUT /admin/member/role/authUser/cancelAll` - 取消授权用户所有角色 +- `PUT /admin/member/role/authUser/selectAll` - 批量选择授权用户角色 + +#### 配置管理 (`/admin/member/config`) + +- `GET /admin/member/config/list` - 查询配置列表 +- `GET /admin/member/config/{configId}` - 获取配置详情 +- `POST /admin/member/config` - 新增配置 +- `PUT /admin/member/config` - 修改配置 +- `DELETE /admin/member/config/{configIds}` - 删除配置 +- `POST /admin/member/config/export` - 导出配置列表 +- `DELETE /admin/member/config/refreshCache` - 刷新配置缓存 +- `GET /admin/member/config/configKey/{configKey}` - 根据配置键查询配置值 +- `GET /admin/member/config/systemInfo` - 获取系统信息 +- `GET /admin/member/config/statistics` - 获取统计数据 + +### 移动端接口 + +#### 用户管理 (`/app/member/user`) + +- `GET /app/member/user/list` - 查询用户列表 +- `GET /app/member/user/{userId}` - 获取用户详情 +- `POST /app/member/user` - 新增用户 +- `PUT /app/member/user` - 修改用户 +- `DELETE /app/member/user/{userIds}` - 删除用户 +- `POST /app/member/user/export` - 导出用户列表 +- `PUT /app/member/user/resetPwd` - 重置密码 +- `PUT /app/member/user/changeStatus` - 修改状态 +- `GET /app/member/user/profile/{userId}` - 获取用户资料 +- `GET /app/member/user/getInfo` - 获取当前用户信息 +- `GET /app/member/user/profile` - 获取当前用户资料 +- `PUT /app/member/user/profile` - 修改用户资料 +- `PUT /app/member/user/profile/updatePwd` - 修改密码 + +#### 角色管理 (`/app/member/role`) + +- `GET /app/member/role/list` - 查询角色列表 +- `GET /app/member/role/{roleId}` - 获取角色详情 +- `POST /app/member/role` - 新增角色 +- `PUT /app/member/role` - 修改角色 +- `DELETE /app/member/role/{roleIds}` - 删除角色 +- `POST /app/member/role/export` - 导出角色列表 +- `GET /app/member/role/optionselect` - 查询角色选项列表 +- `GET /app/member/role/authUser/allocatedList` - 查询已分配用户角色列表 +- `GET /app/member/role/authUser/unallocatedList` - 查询未分配用户角色列表 +- `PUT /app/member/role/authUser/cancel` - 取消授权用户角色 +- `PUT /app/member/role/authUser/cancelAll` - 取消授权用户所有角色 +- `PUT /app/member/role/authUser/selectAll` - 批量选择授权用户角色 + +#### 设备管理 (`/app/member/device`) + +- `GET /app/member/device/list` - 查询设备列表 +- `GET /app/member/device/{deviceId}` - 获取设备详情 +- `POST /app/member/device/bind` - 绑定设备 +- `PUT /app/member/device` - 修改设备信息 +- `DELETE /app/member/device/{deviceId}` - 解绑设备 +- `GET /app/member/device/my` - 查询我的设备列表 +- `GET /app/member/device/active` - 查询活跃设备列表 +- `PUT /app/member/device/{deviceId}/status` - 更新设备状态 +- `PUT /app/member/device/{deviceId}/push-token` - 更新推送令牌 +- `POST /app/member/device/{deviceId}/heartbeat` - 设备心跳 +- `GET /app/member/device/by-push-token` - 根据推送令牌查询设备 + +#### 文件管理 (`/app/member/file`) + +- `GET /app/member/file/list` - 查询文件列表 +- `GET /app/member/file/{fileId}` - 获取文件详情 +- `POST /app/member/file` - 新增文件记录 +- `PUT /app/member/file` - 修改文件信息 +- `DELETE /app/member/file/{fileIds}` - 删除文件 +- `POST /app/member/file/upload` - 上传文件 +- `POST /app/member/file/upload/avatar` - 上传头像 +- `POST /app/member/file/upload/batch` - 批量上传文件 +- `GET /app/member/file/category/{fileCategory}` - 按分类查询文件 +- `GET /app/member/file/download/{fileId}` - 下载文件 +- `PUT /app/member/file/status/{fileId}` - 更新文件状态 +- `PUT /app/member/file/public/{fileId}` - 更新文件公开状态 +- `GET /app/member/file/statistics` - 获取文件统计 +- `GET /app/member/file/check/{fileMd5}` - 检查文件是否存在 +- `GET /app/member/file/public/category/{fileCategory}` - 查询公开文件 + +#### 消息管理 (`/app/member/message`) + +- `GET /app/member/message/list` - 查询消息列表 +- `GET /app/member/message/myMessages` - 查询我的消息列表 +- `GET /app/member/message/unreadCount` - 查询未读消息数量 +- `GET /app/member/message/{messageId}` - 获取消息详情 +- `POST /app/member/message` - 新增消息 +- `PUT /app/member/message` - 修改消息 +- `DELETE /app/member/message/{messageIds}` - 删除消息 +- `POST /app/member/message/export` - 导出消息列表 +- `PUT /app/member/message/markRead/{messageId}` - 标记消息已读 +- `PUT /app/member/message/markReadBatch` - 批量标记消息已读 +- `PUT /app/member/message/markAllRead` - 全部标记已读 +- `POST /app/member/message/sendSystem` - 发送系统消息 +- `POST /app/member/message/sendPersonal` - 发送个人消息 +- `POST /app/member/message/push` - 推送消息 +- `POST /app/member/message/broadcast` - 广播消息 + +#### 操作日志 (`/app/member/operlog`) + +- `GET /app/member/operlog/list` - 查询操作日志列表 +- `GET /app/member/operlog/my` - 查询我的操作日志 +- `GET /app/member/operlog/{operId}` - 获取操作日志详情 +- `POST /app/member/operlog` - 新增操作日志 +- `PUT /app/member/operlog` - 修改操作日志 +- `DELETE /app/member/operlog/{operIds}` - 删除操作日志 +- `POST /app/member/operlog/export` - 导出操作日志列表 +- `DELETE /app/member/operlog/clean` - 清理操作日志 +- `DELETE /app/member/operlog/cleanByTime` - 按时间清理操作日志 +- `GET /app/member/operlog/statistics` - 获取操作日志统计 +- `GET /app/member/operlog/behavior` - 获取行为分析 +- `GET /app/member/operlog/popular` - 获取热门操作 +- `GET /app/member/operlog/errors` - 获取错误操作 +- `POST /app/member/operlog/record` - 记录操作 +- `POST /app/member/operlog/batchRecord` - 批量记录操作 + +#### 版本管理 (`/app/member/version`) + +- `GET /app/member/version/list` - 查询版本列表 +- `GET /app/member/version/{versionId}` - 获取版本详情 +- `GET /app/member/version/checkUpdate` - 检查更新 +- `GET /app/member/version/latest` - 获取最新版本 +- `GET /app/member/version/history` - 获取版本历史 +- `GET /app/member/version/checkForceUpdate` - 检查强制更新 +- `GET /app/member/version/updateInfo` - 获取更新信息 +- `GET /app/member/version/compare` - 版本对比 +- `POST /app/member/version/download/{versionId}` - 记录下载 +- `POST /app/member/version/install/{versionId}` - 记录安装 + +### 认证接口 + +#### JWT认证 (`/app/jwt`) + +- `GET /app/jwt/captcha` - 获取验证码 +- `POST /app/jwt/register` - 用户注册 +- `POST /app/jwt/login` - 用户登录 +- `POST /app/jwt/refresh` - 刷新Token +- `POST /app/jwt/logout` - 用户登出 + +#### 第三方账号绑定 (`/app/auth`) - 在 ruoyi-server 模块 + +- `POST /app/auth/bind/social` - 绑定第三方账号 +- `POST /app/auth/unbind/social` - 解绑第三方账号 +- `GET /app/auth/social/list` - 获取用户绑定的第三方账号列表 +- `GET /app/auth/binding/{source}` - 获取第三方登录跳转URL +- `POST /app/auth/social/binding` - 社交账号绑定回调 + +## 技术栈与开发规范 + +### 技术栈 + +- **框架**: Spring Boot 3.x +- **ORM**: MyBatis-Plus +- **数据库**: MySQL 8.0+ +- **认证**: Sa-Token + JWT +- **API文档**: Swagger/OpenAPI 3.0 +- **构建工具**: Maven +- **安全**: Spring Security Crypto (BCrypt) + +### 开发规范 + +#### 包结构规范 + +- `controller` - 控制器层(分为 `admin` 和 `app` 两个子包) +- `domain` - 实体类(包含 `bo` 业务对象和 `vo` 视图对象子包) +- `mapper` - MyBatis Mapper 接口 +- `service` - 服务接口(实现类放在 `impl` 子包) +- `config` - 配置类(JWT过滤器、Web配置等) +- `exception` - 异常处理类 +- `utils` - 工具类(JWT工具等) + +#### 代码规范 + +- 统一使用 Lombok 简化代码 +- 统一异常处理,使用 `ServiceException` 抛出业务异常 +- 统一返回格式,使用 `R` 封装返回结果 +- 统一分页查询,使用 `PageQuery` 和 `TableDataInfo` +- 统一日志记录,使用 `@Log` 注解记录操作日志 +- 统一权限控制,管理端接口使用 `@SaCheckPermission` 注解 + +#### 数据库设计规范 + +- 表名使用 `app_` 前缀 +- 字段命名使用下划线命名法(snake_case) +- 必须包含 `create_by`, `create_time`, `update_by`, `update_time` 字段 +- 删除标记使用逻辑删除(`del_flag` 字段) +- 状态字段统一使用 `status`,0表示启用/正常,1表示禁用/异常 + +#### API设计规范 + +- RESTful 风格设计 +- 管理端接口路径:`/admin/member/{module}/{resource}` +- 移动端接口路径:`/app/member/{module}/{resource}` +- 使用 HTTP 标准方法:GET(查询)、POST(新增)、PUT(修改)、DELETE(删除) +- 统一使用 JSON 格式进行数据交互 +- 使用 Swagger 注解完善接口文档 + +## 模块依赖关系 + +``` +ruoyi-member (会员中心模块) +├── 依赖: ruoyi-common-* (通用工具模块) +│ ├── ruoyi-common-core (核心工具) +│ ├── ruoyi-common-doc (API文档) +│ ├── ruoyi-common-log (日志) +│ ├── ruoyi-common-mybatis (MyBatis) +│ ├── ruoyi-common-security (安全) +│ ├── ruoyi-common-web (Web) +│ ├── ruoyi-common-excel (Excel) +│ ├── ruoyi-common-translation (翻译) +│ ├── ruoyi-common-social (社交登录) +│ ├── ruoyi-common-satoken (权限认证) +│ └── ruoyi-common-oss (对象存储) +├── 依赖: sa-token-spring-boot3-starter (Sa-Token) +├── 依赖: sa-token-jwt (JWT支持) +└── 依赖: spring-security-crypto (密码加密) +``` + +## 模块集成说明 + +### 与 ruoyi-server 模块的集成 + +- **登录功能集成**: 手机号登录、邮箱登录功能在 `ruoyi-server` 模块实现 + - `AppSmsAuthStrategy` - 手机号登录策略 + - `AppEmailAuthStrategy` - 邮箱登录策略 + - `CaptchaController` - 验证码发送接口 +- **第三方账号绑定集成**: 第三方账号绑定/解绑功能在 `ruoyi-server` 模块实现 + - `AppAuthController` - 提供绑定/解绑接口 +- **用户服务集成**: `ruoyi-server` 模块通过依赖 `ruoyi-member` 模块使用用户服务 + +### 与其他模块的集成 + +- **文件存储**: 依赖 `ruoyi-common-oss` 模块,支持对象存储服务集成(OSS、COS等) +- **社交登录**: 依赖 `ruoyi-common-social` 模块,支持第三方登录(微信、QQ、微博等) +- **权限认证**: 依赖 `ruoyi-common-satoken` 模块,使用 Sa-Token 进行权限管理 + +## 下一步工作 + +### 功能完善 + +1. **用户管理增强** + - [x] 手机号/邮箱登录功能 ✅(已在 ruoyi-server 模块实现) + - [x] 第三方账号绑定/解绑功能 ✅(已在 ruoyi-server 模块实现) + - [ ] 忘记密码功能(通过手机号/邮箱验证码找回密码) + - [ ] 独立的手机号/邮箱绑定功能(当前可通过更新用户资料方式修改) + - [ ] 用户等级体系 + - [ ] 用户标签管理 + +2. **角色权限系统** + - [ ] 权限点管理 + - [ ] 角色权限配置 + - [ ] 数据权限管理 + +3. **消息推送集成** + - [ ] 集成推送服务(极光推送、个推等) + - [ ] 消息推送功能完善 + - [ ] 推送统计功能 + +4. **文件管理增强** + - [ ] 文件存储服务集成(OSS、COS等) + - [ ] 文件预览功能 + - [ ] 文件分享功能 + +5. **安全增强** + - [ ] Token黑名单机制 + - [ ] 多端登录管理 + - [ ] 异常登录检测 + - [ ] 登录IP白名单/黑名单 + +6. **日志分析** + - [ ] 操作日志分析功能 + - [ ] 登录日志分析功能 + - [ ] 日志告警功能 + +### 性能优化 + +1. **缓存优化** + - [ ] 用户信息缓存(Redis) + - [ ] 角色权限缓存 + - [ ] 配置信息缓存 + +2. **数据库优化** + - [ ] 索引优化 + - [ ] 查询优化 + - [ ] 分表策略(日志表) + +### 测试验证 + +1. **单元测试** + - [ ] Service层单元测试 + - [ ] Controller层单元测试 + +2. **集成测试** + - [ ] API接口测试 + - [ ] 业务流程测试 + +## 开发指南 + +### 新增功能开发步骤 + +1. **数据库设计** + - 设计表结构(遵循数据库设计规范) + - 确保字段命名规范,包含必要的索引 + - 添加必要的状态字段和逻辑删除字段 + +2. **创建实体类** + - 在 `domain` 包下创建实体类 + - 创建对应的 `Bo`(业务对象)和 `Vo`(视图对象) + - 使用 Lombok 简化代码 + +3. **创建 Mapper** + - 在 `mapper` 包下创建 Mapper 接口 + - 继承 `BaseMapper` 获得基础 CRUD 功能 + - 如需复杂查询,在 `resources/mapper/member` 下创建 XML 文件 + +4. **创建 Service** + - 在 `service` 包下创建 Service 接口 + - 在 `service/impl` 包下创建 Service 实现类 + - 实现业务逻辑,使用事务注解保证数据一致性 + +5. **创建 Controller** + - 管理端接口:在 `controller/admin` 包下创建 + - 移动端接口:在 `controller/app` 包下创建 + - 添加 Swagger 注解完善接口文档 + - 添加权限控制注解(管理端使用 `@SaCheckPermission`) + +6. **测试验证** + - 编写单元测试(可选) + - 使用 Swagger UI 测试接口 + - 验证业务逻辑正确性 + +### 代码迁移指南 + +如果要将现有代码迁移到新的模块结构: + +1. **创建新模块结构**(如果尚未创建) +2. **复制代码到新模块** +3. **修改包名**(如需要) +4. **更新导入语句** +5. **更新 Mapper XML 文件中的命名空间** +6. **测试验证功能正常** + +## 注意事项 + +1. **登录功能**: 手机号登录、邮箱登录功能已在 `ruoyi-server` 模块实现(通过 `AppSmsAuthStrategy` 和 `AppEmailAuthStrategy`),验证码发送功能通过 `CaptchaController` 提供 +2. **密码安全**: 用户密码使用 BCrypt 加密,确保密码安全存储 +3. **Token安全**: JWT Token 需要设置合理的过期时间,建议使用 Refresh Token 机制 +4. **文件上传**: 文件上传需要限制文件大小和类型,防止恶意文件上传 +5. **消息推送**: 消息推送需要集成第三方推送服务,注意推送频率限制 +6. **日志管理**: 操作日志和登录日志会随着时间增长,建议定期清理或归档 +7. **设备管理**: 设备绑定需要考虑设备唯一性,避免重复绑定 +8. **社交登录**: 第三方登录需要配置相应的应用ID和密钥 +9. **数据一致性**: 用户删除需要考虑关联数据(设备、文件、消息等)的处理 +10. **并发安全**: 用户注册、登录等操作需要考虑并发安全性 +11. **异常处理**: 需要统一异常处理,避免敏感信息泄露 +12. **模块集成**: 注意与 `ruoyi-server` 模块的集成关系,部分功能(如登录、第三方绑定)在 server 模块实现 +13. **配置管理**: JWT 过滤器配置在 `AppJwtWebConfig` 中,需要正确配置过滤路径 +14. **权限控制**: 管理端接口需要添加权限控制,移动端接口需要添加登录验证 diff --git a/ruoyi-modules/ruoyi-member/pom.xml b/ruoyi-modules/ruoyi-member/pom.xml new file mode 100644 index 0000000..2b18c65 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/pom.xml @@ -0,0 +1,94 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + + ruoyi-member + + + ruoyi-member 会员中心模块 + + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-log + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-excel + + + + org.dromara + ruoyi-common-translation + + + + org.dromara + ruoyi-common-social + + + + org.dromara + ruoyi-common-satoken + + + + org.dromara + ruoyi-common-oss + + + + + cn.dev33 + sa-token-spring-boot3-starter + + + + + cn.dev33 + sa-token-jwt + + + org.springframework.security + spring-security-crypto + + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/config/AppJwtFilter.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/config/AppJwtFilter.java new file mode 100644 index 0000000..cf76a6e --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/config/AppJwtFilter.java @@ -0,0 +1,81 @@ +package org.dromara.member.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.member.utils.JwtUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * JWT过滤器 + * + * @author pengles + */ +@Component +@RequiredArgsConstructor +public class AppJwtFilter extends OncePerRequestFilter { + + @Value("${ruoyi.app.jwt.secret:ruoyi-secret}") + private String jwtSecret; + private static final String BLACKLIST_PREFIX = "APP:JWT:BLACKLIST:"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String path = request.getRequestURI(); + // 仅保护敏感接口 + if (requiresAuth(path)) { + String auth = request.getHeader("Authorization"); + if (auth == null || !auth.startsWith("Bearer ")) { + write401(response); + return; + } + String token = auth.substring("Bearer ".length()).trim(); + JwtUtil jwt = new JwtUtil(jwtSecret); + if (!jwt.verify(token)) { + write401(response); + return; + } + Map payload = jwt.parsePayload(token); + Object expObj = payload.get("exp"); + long now = java.time.Instant.now().getEpochSecond(); + long exp = expObj instanceof Number ? ((Number) expObj).longValue() : now; + if (exp < now) { + write401(response); + return; + } + String jti = String.valueOf(payload.get("jti")); + String bl = RedisUtils.getCacheObject(BLACKLIST_PREFIX + jti); + if (bl != null) { + write401(response); + return; + } + } + filterChain.doFilter(request, response); + } + + private boolean requiresAuth(String path) { + if (path == null) return false; + // 保护系统会话/消息/用户/文件等接口 + return path.startsWith("/system/session") + || path.startsWith("/system/message") + || path.startsWith("/app/user") + || path.startsWith("/app/file"); + } + + private void write401(HttpServletResponse response) throws IOException { + response.setStatus(401); + response.setContentType("application/json"); + response.getOutputStream().write("{\"code\":401,\"msg\":\"Unauthorized\",\"data\":null}".getBytes(StandardCharsets.UTF_8)); + } +} + diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/config/AppJwtWebConfig.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/config/AppJwtWebConfig.java new file mode 100644 index 0000000..54f78d9 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/config/AppJwtWebConfig.java @@ -0,0 +1,30 @@ +package org.dromara.member.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * JWT Web配置 + * + * @author pengles + */ +@Configuration +@RequiredArgsConstructor +public class AppJwtWebConfig { + private final AppJwtFilter appJwtFilter; + + @Bean + public FilterRegistrationBean appJwtFilterRegistration() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(appJwtFilter); + // 设置过滤路径 + bean.addUrlPatterns("/system/session/*", "/system/message/*", "/app/user/*", "/app/file/*"); + bean.setName("AppJwtFilter"); + // 设置优先级,越小越靠前 + bean.setOrder(10); + return bean; + } +} + diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/AppFileController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/AppFileController.java new file mode 100644 index 0000000..cf621b7 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/AppFileController.java @@ -0,0 +1,266 @@ +package org.dromara.member.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.annotation.SaIgnore; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppFileBo; +import org.dromara.member.domain.vo.AppFileVo; +import org.dromara.member.service.AppFileService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Arrays; +import java.util.List; + +/** + * App端文件信息Controller + * + * @author pengles + */ +@Tag(name = "App端文件管理", description = "App端文件信息管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/file") +public class AppFileController extends BaseController { + + private final AppFileService appFileService; + + /** + * 查询App端文件信息列表 + */ + @Operation(summary = "查询文件列表", description = "分页查询当前用户的文件信息列表") + @SaCheckLogin + @GetMapping("/list") + public TableDataInfo list( + @Parameter(description = "文件查询条件") @Valid AppFileBo bo, + @Parameter(description = "分页参数") @Valid PageQuery pageQuery) { + // 只能查询自己的文件 + bo.setUserId(LoginHelper.getUserId()); + return appFileService.queryPageList(bo, pageQuery); + } + + /** + * 获取App端文件信息详细信息 + */ + @Operation(summary = "获取文件详情", description = "根据文件ID获取文件详细信息") + @SaCheckLogin + @GetMapping("/{fileId}") + public R getInfo( + @Parameter(description = "文件ID", required = true) + @PathVariable @NotNull(message = "文件ID不能为空") @Positive(message = "文件ID必须为正数") Long fileId) { + AppFileVo fileVo = appFileService.queryById(fileId); + if (fileVo == null) { + return R.fail("文件不存在"); + } + // 检查权限 + if (!fileVo.getUserId().equals(LoginHelper.getUserId()) && !"1".equals(fileVo.getIsPublic())) { + return R.fail("无权限访问该文件"); + } + return R.ok(fileVo); + } + + /** + * 新增App端文件信息 + */ + @Operation(summary = "新增文件信息", description = "添加新的文件信息记录") + @SaCheckLogin + @Log(title = "App端文件信息", businessType = BusinessType.INSERT) + @PostMapping + public R add( + @Parameter(description = "文件信息", required = true) + @Validated(AddGroup.class) @RequestBody AppFileBo bo) { + bo.setUserId(LoginHelper.getUserId()); + return toAjax(appFileService.insertByBo(bo)); + } + + /** + * 修改App端文件信息 + */ + @Operation(summary = "修改文件信息", description = "更新文件信息记录") + @SaCheckLogin + @Log(title = "App端文件信息", businessType = BusinessType.UPDATE) + @PutMapping + public R edit( + @Parameter(description = "文件信息", required = true) + @Validated(EditGroup.class) @RequestBody AppFileBo bo) { + // 检查权限 + AppFileVo existFile = appFileService.queryById(bo.getFileId()); + if (existFile == null) { + return R.fail("文件不存在"); + } + if (!existFile.getUserId().equals(LoginHelper.getUserId())) { + return R.fail("无权限修改该文件"); + } + return toAjax(appFileService.updateByBo(bo)); + } + + /** + * 删除App端文件信息 + */ + @Operation(summary = "删除文件", description = "批量删除文件信息") + @SaCheckLogin + @Log(title = "App端文件信息", businessType = BusinessType.DELETE) + @DeleteMapping("/{fileIds}") + public R remove( + @Parameter(description = "文件ID列表", required = true) + @PathVariable @NotEmpty(message = "文件ID列表不能为空") Long[] fileIds) { + return toAjax(appFileService.batchDeleteUserFiles(Arrays.asList(fileIds), LoginHelper.getUserId())); + } + + /** + * 上传文件 + */ + @Operation(summary = "上传文件", description = "上传单个文件") + @SaCheckLogin + @Log(title = "文件上传", businessType = BusinessType.INSERT) + @PostMapping("/upload") + public R uploadFile( + @Parameter(description = "上传的文件", required = true) + @RequestParam("file") @NotNull(message = "上传文件不能为空") MultipartFile file, + @Parameter(description = "文件分类") + @RequestParam(value = "fileCategory", required = false) @Min(value = 0, message = "文件分类不能为负数") Integer fileCategory) { + AppFileVo result = appFileService.uploadFile(file, LoginHelper.getUserId(), fileCategory); + return R.ok(result); + } + + /** + * 上传头像 + */ + @Operation(summary = "上传头像", description = "上传用户头像文件") + @SaCheckLogin + @Log(title = "头像上传", businessType = BusinessType.INSERT) + @PostMapping("/upload/avatar") + public R uploadAvatar( + @Parameter(description = "头像文件", required = true) + @RequestParam("file") @NotNull(message = "头像文件不能为空") MultipartFile file) { + AppFileVo result = appFileService.uploadAvatar(file, LoginHelper.getUserId()); + return R.ok(result); + } + + /** + * 批量上传文件 + */ + @Operation(summary = "批量上传文件", description = "批量上传多个文件") + @SaCheckLogin + @Log(title = "批量文件上传", businessType = BusinessType.INSERT) + @PostMapping("/upload/batch") + public R> batchUploadFiles( + @Parameter(description = "上传的文件列表", required = true) + @RequestParam("files") @NotEmpty(message = "上传文件列表不能为空") MultipartFile[] files, + @Parameter(description = "文件分类") + @RequestParam(value = "fileCategory", required = false) @Min(value = 0, message = "文件分类不能为负数") Integer fileCategory) { + List result = appFileService.batchUploadFiles(files, LoginHelper.getUserId(), fileCategory); + return R.ok(result); + } + + /** + * 根据文件分类查询文件列表 + */ + @Operation(summary = "根据分类获取文件", description = "获取指定分类的文件列表") + @SaCheckLogin + @GetMapping("/category/{fileCategory}") + public R> getFilesByCategory( + @Parameter(description = "文件分类", required = true) + @PathVariable @NotNull(message = "文件分类不能为空") @Min(value = 0, message = "文件分类不能为负数") Integer fileCategory) { + List result = appFileService.queryByUserId(LoginHelper.getUserId(), fileCategory); + return R.ok(result); + } + + /** + * 下载文件 + */ + @Operation(summary = "下载文件", description = "根据文件ID下载文件") + @SaCheckLogin + @Log(title = "文件下载", businessType = BusinessType.OTHER) + @GetMapping("/download/{fileId}") + public R downloadFile( + @Parameter(description = "文件ID", required = true) + @PathVariable @NotNull(message = "文件ID不能为空") @Positive(message = "文件ID必须为正数") Long fileId) { + AppFileVo result = appFileService.downloadFile(fileId, LoginHelper.getUserId()); + return R.ok(result); + } + + /** + * 更新文件状态 + */ + @Operation(summary = "更新文件状态", description = "更新文件的状态信息") + @SaCheckLogin + @Log(title = "更新文件状态", businessType = BusinessType.UPDATE) + @PutMapping("/status/{fileId}") + public R updateFileStatus( + @Parameter(description = "文件ID", required = true) + @PathVariable @NotNull(message = "文件ID不能为空") @Positive(message = "文件ID必须为正数") Long fileId, + @Parameter(description = "文件状态(0-正常,1-禁用)", required = true) + @RequestParam @NotBlank(message = "文件状态不能为空") @Pattern(regexp = "^(0|1)$", message = "文件状态只能为0或1") String status) { + return toAjax(appFileService.updateFileStatus(fileId, status, LoginHelper.getUserId())); + } + + /** + * 更新文件公开状态 + */ + @Operation(summary = "更新文件公开状态", description = "设置文件是否公开") + @SaCheckLogin + @Log(title = "更新文件公开状态", businessType = BusinessType.UPDATE) + @PutMapping("/public/{fileId}") + public R updateFilePublicStatus( + @Parameter(description = "文件ID", required = true) + @PathVariable @NotNull(message = "文件ID不能为空") @Positive(message = "文件ID必须为正数") Long fileId, + @Parameter(description = "是否公开(0-私有,1-公开)", required = true) + @RequestParam @NotBlank(message = "公开状态不能为空") @Pattern(regexp = "^(0|1)$", message = "公开状态只能为0或1") String isPublic) { + return toAjax(appFileService.updateFilePublicStatus(fileId, isPublic, LoginHelper.getUserId())); + } + + /** + * 获取用户文件统计信息 + */ + @Operation(summary = "获取文件统计", description = "获取当前用户的文件统计信息") + @SaCheckLogin + @GetMapping("/statistics") + public R getUserFileStatistics() { + AppFileVo result = appFileService.getUserFileStatistics(LoginHelper.getUserId()); + return R.ok(result); + } + + /** + * 检查文件是否存在 + */ + @Operation(summary = "检查文件是否存在", description = "根据MD5值检查文件是否已存在") + @SaCheckLogin + @GetMapping("/check/{fileMd5}") + public R checkFileExists( + @Parameter(description = "文件MD5值", required = true) + @PathVariable @NotBlank(message = "文件MD5不能为空") @Size(min = 32, max = 32, message = "MD5长度必须为32位") String fileMd5) { + AppFileVo result = appFileService.checkFileExists(fileMd5, LoginHelper.getUserId()); + return R.ok(result); + } + + /** + * 查询公开文件列表 + */ + @Operation(summary = "查询公开文件列表", description = "获取指定分类的公开文件列表") + @SaIgnore + @GetMapping("/public/category/{fileCategory}") + public R> getPublicFilesByCategory( + @Parameter(description = "文件分类", required = true) + @PathVariable @NotNull(message = "文件分类不能为空") @Min(value = 0, message = "文件分类不能为负数") Integer fileCategory) { + List result = appFileService.queryByCategory(fileCategory); + return R.ok(result); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/AppJwtController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/AppJwtController.java new file mode 100644 index 0000000..9c0eb56 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/AppJwtController.java @@ -0,0 +1,180 @@ +package org.dromara.member.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.member.utils.JwtUtil; +import org.dromara.member.domain.AppUser; +import org.dromara.member.mapper.AppUserMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * JWT认证控制器 + * + * @author pengles + */ +@SaIgnore +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/auth/jwt") +public class AppJwtController { + + private final AppUserMapper appUserMapper; + + @Value("${ruoyi.app.jwt.secret:ruoyi-secret}") + private String jwtSecret; + @Value("${ruoyi.app.jwt.access-ttl-seconds:7200}") + private long accessTtlSeconds; + @Value("${ruoyi.app.jwt.refresh-ttl-seconds:604800}") + private long refreshTtlSeconds; + private static final String CAPTCHA_PREFIX = "APP:JWT:CAPTCHA:"; + private static final String BLACKLIST_PREFIX = "APP:JWT:BLACKLIST:"; + private static final String TOKEN_JTI_PREFIX = "APP:JWT:JTI:"; + private static final String USER_TOKENS_PREFIX = "APP:JWT:USER_TOKENS:"; + private static final String LOGIN_FAIL_PREFIX = "APP:JWT:LOGIN_FAIL:"; + + @Operation(summary = "生成图形验证码(4位字母数字)") + @GetMapping("/captcha") + public R> captcha() { + String code = randomCode(4); + String uuid = UUID.randomUUID().toString(); + RedisUtils.setCacheObject(CAPTCHA_PREFIX + uuid, code, Duration.ofMinutes(5)); + // 这里简化为直接返回文本验证码;实际可返回Base64图片 + return R.ok(Map.of("uuid", uuid, "code", code)); + } + + @Operation(summary = "注册") + @PostMapping("/register") + public R register( + @Parameter(description = "手机号(+86或11位)") @RequestParam @Pattern(regexp = "^(\\+86\\d{11}|\\d{11})$") String phone, + @Parameter(description = "密码") @RequestParam @Size(min = 6, max = 64) String password, + @Parameter(description = "验证码uuid") @RequestParam @NotBlank String captchaUuid, + @Parameter(description = "验证码") @RequestParam @NotBlank String captchaCode) { + if (!verifyCaptcha(captchaUuid, captchaCode)) { + return R.fail("验证码错误"); + } + AppUser exist = findByPhone(phone); + if (exist != null) { + return R.fail("手机号已注册"); + } + org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder enc = new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder(12); + AppUser u = new AppUser(); + u.setPhone(phone); + u.setPassword(enc.encode(password)); + boolean ok = appUserMapper.insert(u) > 0; + return ok ? R.ok() : R.fail("注册失败"); + } + + @Operation(summary = "登录") + @PostMapping("/login") + public R> login( + @Parameter(description = "手机号(+86或11位)") @RequestParam @Pattern(regexp = "^(\\+86\\d{11}|\\d{11})$") String phone, + @Parameter(description = "密码") @RequestParam @Size(min = 6, max = 64) String password, + @Parameter(description = "验证码uuid") @RequestParam @NotBlank String captchaUuid, + @Parameter(description = "验证码") @RequestParam @NotBlank String captchaCode) { + if (!verifyCaptcha(captchaUuid, captchaCode)) { + return R.fail("验证码错误"); + } + String failKey = LOGIN_FAIL_PREFIX + phone; + Integer cnt = RedisUtils.getCacheObject(failKey); + if (cnt != null && cnt >= 5) { + return R.fail("登录失败次数过多,请稍后再试"); + } + AppUser user = findByPhone(phone); + org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder enc = new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder(12); + if (user == null || !enc.matches(password, user.getPassword())) { + int n = (cnt == null ? 0 : cnt) + 1; + RedisUtils.setCacheObject(failKey, n, Duration.ofHours(1)); + return R.fail("手机号或密码错误"); + } + JwtUtil jwt = new JwtUtil(jwtSecret); + String jti = UUID.randomUUID().toString(); + String access = jwt.generateToken(String.valueOf(user.getUserId()), accessTtlSeconds, jti); + String refresh = jwt.generateToken("refresh:" + user.getUserId(), refreshTtlSeconds, UUID.randomUUID().toString()); + RedisUtils.setCacheObject(TOKEN_JTI_PREFIX + jti, "1", Duration.ofSeconds(accessTtlSeconds)); + limitUserDevices(user.getUserId(), jti); + Map data = new LinkedHashMap<>(); + data.put("accessToken", access); + data.put("refreshToken", refresh); + data.put("expiresIn", accessTtlSeconds); + return R.ok(data); + } + + @Operation(summary = "刷新Token") + @PostMapping("/refresh") + public R> refresh(@Parameter(description = "刷新令牌") @RequestParam String refreshToken) { + JwtUtil jwt = new JwtUtil(jwtSecret); + if (!jwt.verify(refreshToken)) return R.fail("刷新令牌无效"); + Map payload = jwt.parsePayload(refreshToken); + String sub = String.valueOf(payload.get("sub")); + if (sub == null || !sub.startsWith("refresh:")) return R.fail("刷新令牌错误"); + String userId = sub.substring("refresh:".length()); + String jti = UUID.randomUUID().toString(); + String access = jwt.generateToken(userId, accessTtlSeconds, jti); + RedisUtils.setCacheObject(TOKEN_JTI_PREFIX + jti, "1", Duration.ofSeconds(accessTtlSeconds)); + limitUserDevices(Long.parseLong(userId), jti); + return R.ok(Map.of("accessToken", access, "expiresIn", accessTtlSeconds)); + } + + @Operation(summary = "注销") + @PostMapping("/logout") + public R logout(@Parameter(description = "访问令牌") @RequestParam String accessToken) { + JwtUtil jwt = new JwtUtil(jwtSecret); + if (!jwt.verify(accessToken)) return R.fail("令牌无效"); + Map payload = jwt.parsePayload(accessToken); + String jti = String.valueOf(payload.get("jti")); + Object expObj = payload.get("exp"); + long now = java.time.Instant.now().getEpochSecond(); + long exp = expObj instanceof Number ? ((Number) expObj).longValue() : now; + long remain = Math.max(0, exp - now); + RedisUtils.setCacheObject(BLACKLIST_PREFIX + jti, "1", Duration.ofSeconds(remain)); + RedisUtils.deleteObject(TOKEN_JTI_PREFIX + jti); + return R.ok(); + } + + private boolean verifyCaptcha(String uuid, String code) { + String real = RedisUtils.getCacheObject(CAPTCHA_PREFIX + uuid); + return real != null && real.equalsIgnoreCase(code); + } + + private String randomCode(int n) { + String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + StringBuilder sb = new StringBuilder(); + java.util.Random r = new java.util.Random(); + for (int i = 0; i < n; i++) sb.append(chars.charAt(r.nextInt(chars.length()))); + return sb.toString(); + } + + private void limitUserDevices(Long userId, String jti) { + String listKey = USER_TOKENS_PREFIX + userId; + java.util.List list = RedisUtils.getCacheObject(listKey); + if (list == null) list = new java.util.ArrayList<>(); + list.add(0, jti); + while (list.size() > 3) { + String old = list.remove(list.size() - 1); + RedisUtils.setCacheObject(BLACKLIST_PREFIX + old, "1", Duration.ofHours(2)); + RedisUtils.deleteObject(TOKEN_JTI_PREFIX + old); + } + RedisUtils.setCacheObject(listKey, list, Duration.ofDays(8)); + } + + private AppUser findByPhone(String phone) { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper lqw = com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery(); + lqw.eq(AppUser::getPhone, phone); + return appUserMapper.selectOne(lqw); + } +} diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminConfigController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminConfigController.java new file mode 100644 index 0000000..c0983fe --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminConfigController.java @@ -0,0 +1,175 @@ +package org.dromara.member.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 管理端系统配置 + * + * @author pengles + */ +@Tag(name = "管理端系统配置", description = "系统配置管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/admin/config") +public class AdminConfigController extends BaseController { + + /** + * 查询系统配置列表 + */ + @Operation(summary = "查询配置列表", description = "分页查询系统配置列表") + @SaCheckPermission("admin:config:list") + @GetMapping("/list") + public TableDataInfo> list( + @Parameter(description = "分页参数") PageQuery pageQuery) { + // 模拟配置数据 + TableDataInfo> dataInfo = new TableDataInfo<>(); + dataInfo.setCode(200); + dataInfo.setMsg("查询成功"); + dataInfo.setTotal(0L); + dataInfo.setRows(List.of()); + return dataInfo; + } + + /** + * 导出系统配置列表 + */ + @Operation(summary = "导出配置列表", description = "导出系统配置到Excel") + @SaCheckPermission("admin:config:export") + @Log(title = "系统配置", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response) { + // 导出逻辑 + } + + /** + * 获取系统配置详细信息 + */ + @Operation(summary = "获取配置详情", description = "根据配置ID获取详细信息") + @SaCheckPermission("admin:config:query") + @GetMapping("/{configId}") + public R> getInfo( + @Parameter(description = "配置ID", required = true) + @NotNull(message = "主键不能为空") @PathVariable Long configId) { + Map config = new HashMap<>(); + config.put("configId", configId); + config.put("configName", "示例配置"); + config.put("configKey", "example.config"); + config.put("configValue", "示例值"); + return R.ok(config); + } + + /** + * 新增系统配置 + */ + @Operation(summary = "新增配置", description = "创建新的系统配置") + @SaCheckPermission("admin:config:add") + @Log(title = "系统配置", businessType = BusinessType.INSERT) + @PostMapping() + public R add( + @Parameter(description = "配置信息", required = true) + @Validated(AddGroup.class) @RequestBody Map config) { + return R.ok(); + } + + /** + * 修改系统配置 + */ + @Operation(summary = "修改配置", description = "更新系统配置信息") + @SaCheckPermission("admin:config:edit") + @Log(title = "系统配置", businessType = BusinessType.UPDATE) + @PutMapping() + public R edit( + @Parameter(description = "配置信息", required = true) + @Validated(EditGroup.class) @RequestBody Map config) { + return R.ok(); + } + + /** + * 删除系统配置 + */ + @Operation(summary = "删除配置", description = "批量删除系统配置") + @SaCheckPermission("admin:config:remove") + @Log(title = "系统配置", businessType = BusinessType.DELETE) + @DeleteMapping("/{configIds}") + public R remove( + @Parameter(description = "配置ID列表", required = true) + @NotEmpty(message = "主键不能为空") @PathVariable Long[] configIds) { + return R.ok(); + } + + /** + * 刷新参数缓存 + */ + @Operation(summary = "刷新缓存", description = "刷新系统配置缓存") + @SaCheckPermission("admin:config:remove") + @Log(title = "系统配置", businessType = BusinessType.CLEAN) + @DeleteMapping("/refreshCache") + public R refreshCache() { + return R.ok(); + } + + /** + * 根据参数键名查询参数值 + */ + @Operation(summary = "根据键名查询配置", description = "根据配置键名获取配置值") + @GetMapping("/configKey/{configKey}") + public R getConfigKey( + @Parameter(description = "配置键名", required = true) + @PathVariable String configKey) { + return R.ok("配置值"); + } + + /** + * 获取系统信息 + */ + @Operation(summary = "获取系统信息", description = "获取系统运行信息") + @SaCheckPermission("admin:config:query") + @GetMapping("/systemInfo") + public R> getSystemInfo() { + Map systemInfo = new HashMap<>(); + systemInfo.put("systemName", "RuoYi-Unity-AI管理系统"); + systemInfo.put("version", "5.X"); + systemInfo.put("author", "RuoYi"); + systemInfo.put("javaVersion", System.getProperty("java.version")); + systemInfo.put("osName", System.getProperty("os.name")); + return R.ok(systemInfo); + } + + /** + * 获取系统统计信息 + */ + @Operation(summary = "获取统计信息", description = "获取系统统计数据") + @SaCheckPermission("admin:config:query") + @GetMapping("/statistics") + public R> getStatistics() { + Map statistics = new HashMap<>(); + statistics.put("totalUsers", 0); + statistics.put("totalRoles", 0); + statistics.put("onlineUsers", 0); + statistics.put("todayVisits", 0); + return R.ok(statistics); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminRoleController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminRoleController.java new file mode 100644 index 0000000..822dfa6 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminRoleController.java @@ -0,0 +1,172 @@ +package org.dromara.member.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppRoleBo; +import org.dromara.member.domain.vo.AppRoleVo; +import org.dromara.member.service.AppRoleService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 管理端角色信息 + * + * @author pengles + */ +@Tag(name = "管理端角色管理", description = "管理端角色信息管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/admin/role") +public class AdminRoleController extends BaseController { + + private final AppRoleService appRoleService; + + /** + * 查询管理端角色信息列表 + */ + @Operation(summary = "查询角色列表", description = "分页查询管理端角色信息列表") + @SaCheckPermission("admin:role:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "角色查询条件") AppRoleBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return appRoleService.queryPageList(bo, pageQuery); + } + + /** + * 导出管理端角色信息列表 + */ + @Operation(summary = "导出角色列表", description = "导出管理端角色信息到Excel") + @SaCheckPermission("admin:role:export") + @Log(title = "管理端角色信息", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "角色查询条件") AppRoleBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = appRoleService.queryList(bo); + ExcelUtil.exportExcel(list, "管理端角色信息", AppRoleVo.class, response); + } + + /** + * 获取管理端角色信息详细信息 + */ + @Operation(summary = "获取角色详情", description = "根据角色ID获取详细信息") + @SaCheckPermission("admin:role:query") + @GetMapping("/{roleId}") + public R getInfo(@Parameter(description = "角色ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long roleId) { + return R.ok(appRoleService.queryById(roleId)); + } + + /** + * 新增管理端角色信息 + */ + @Operation(summary = "新增角色", description = "新增管理端角色信息") + @SaCheckPermission("admin:role:add") + @Log(title = "管理端角色信息", businessType = BusinessType.INSERT) + @PostMapping() + public R add(@Parameter(description = "角色信息", required = true) + @Validated(AddGroup.class) @RequestBody AppRoleBo bo) { + return toAjax(appRoleService.insertByBo(bo)); + } + + /** + * 修改管理端角色信息 + */ + @Operation(summary = "修改角色", description = "修改管理端角色信息") + @SaCheckPermission("admin:role:edit") + @Log(title = "管理端角色信息", businessType = BusinessType.UPDATE) + @PutMapping() + public R edit(@Parameter(description = "角色信息", required = true) + @Validated(EditGroup.class) @RequestBody AppRoleBo bo) { + return toAjax(appRoleService.updateByBo(bo)); + } + + /** + * 删除管理端角色信息 + */ + @Operation(summary = "删除角色", description = "批量删除管理端角色信息") + @SaCheckPermission("admin:role:remove") + @Log(title = "管理端角色信息", businessType = BusinessType.DELETE) + @DeleteMapping("/{roleIds}") + public R remove(@Parameter(description = "角色ID数组", required = true) + @NotEmpty(message = "主键不能为空") + @PathVariable Long[] roleIds) { + return toAjax(appRoleService.deleteWithValidByIds(List.of(roleIds), true)); + } + + /** + * 获取角色选择框列表 + */ + @Operation(summary = "获取角色选项", description = "获取角色选择框列表") + @SaCheckPermission("admin:role:query") + @GetMapping("/optionselect") + public R> optionselect() { + List roles = appRoleService.queryList(new AppRoleBo()); + return R.ok(roles); + } + + /** + * 查询已分配用户角色列表 + */ + @Operation(summary = "查询已分配用户", description = "查询已分配用户角色列表") + @SaCheckPermission("admin:role:list") + @GetMapping("/authUser/allocatedList") + public TableDataInfo allocatedList(@Parameter(description = "角色查询条件") AppRoleBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return appRoleService.queryPageList(bo, pageQuery); + } + + /** + * 查询未分配用户角色列表 + */ + @Operation(summary = "查询未分配用户", description = "查询未分配用户角色列表") + @SaCheckPermission("admin:role:list") + @GetMapping("/authUser/unallocatedList") + public TableDataInfo unallocatedList(@Parameter(description = "角色查询条件") AppRoleBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return appRoleService.queryPageList(bo, pageQuery); + } + + /** + * 取消授权用户 + */ + @Operation(summary = "取消授权用户", description = "批量取消用户角色授权") + @SaCheckPermission("admin:role:edit") + @Log(title = "管理端角色信息", businessType = BusinessType.GRANT) + @PutMapping("/authUser/cancelAll") + public R cancelAuthUserAll(@Parameter(description = "角色ID", required = true) Long roleId, + @Parameter(description = "用户ID数组", required = true) Long[] userIds) { + return toAjax(appRoleService.deleteAuthUsers(roleId, userIds)); + } + + /** + * 批量选择用户授权 + */ + @Operation(summary = "批量授权用户", description = "批量选择用户进行角色授权") + @SaCheckPermission("admin:role:edit") + @Log(title = "管理端角色信息", businessType = BusinessType.GRANT) + @PutMapping("/authUser/selectAll") + public R selectAuthUserAll(@Parameter(description = "角色ID", required = true) Long roleId, + @Parameter(description = "用户ID数组", required = true) Long[] userIds) { + return toAjax(appRoleService.insertAuthUsers(roleId, userIds)); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminUserController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminUserController.java new file mode 100644 index 0000000..a9a6d3e --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/admin/AdminUserController.java @@ -0,0 +1,191 @@ +package org.dromara.member.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppUserBo; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.member.service.AppUserService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 管理端用户信息 + * + * @author pengles + */ +@Tag(name = "管理端用户管理", description = "管理端用户信息管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/admin/user") +public class AdminUserController extends BaseController { + + private final AppUserService appUserService; + + /** + * 查询管理端用户信息列表 + */ + @Operation(summary = "查询用户列表", description = "分页查询管理端用户信息列表") + @SaCheckPermission("admin:user:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "用户查询条件") AppUserBo bo, + @Parameter(description = "分页参数") PageQuery pageQuery) { + return appUserService.queryPageList(bo, pageQuery); + } + + /** + * 导出管理端用户信息列表 + */ + @Operation(summary = "导出用户列表", description = "导出管理端用户信息到Excel") + @SaCheckPermission("admin:user:export") + @Log(title = "管理端用户信息", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "用户查询条件") AppUserBo bo, + @Parameter(description = "响应对象") HttpServletResponse response) { + List list = appUserService.queryList(bo); + ExcelUtil.exportExcel(list, "管理端用户信息", AppUserVo.class, response); + } + + /** + * 获取管理端用户信息详细信息 + */ + @Operation(summary = "获取用户详情", description = "根据用户ID获取详细信息") + @SaCheckPermission("admin:user:query") + @GetMapping("/{userId}") + public R getInfo(@Parameter(description = "用户ID", required = true) + @NotNull(message = "主键不能为空") + @PathVariable Long userId) { + return R.ok(appUserService.queryById(userId)); + } + + /** + * 新增管理端用户信息 + */ + @Operation(summary = "新增用户", description = "新增管理端用户信息") + @SaCheckPermission("admin:user:add") + @Log(title = "管理端用户信息", businessType = BusinessType.INSERT) + @PostMapping() + public R add(@Parameter(description = "用户信息", required = true) + @Validated(AddGroup.class) @RequestBody AppUserBo bo) { + return toAjax(appUserService.insertByBo(bo)); + } + + /** + * 修改管理端用户信息 + */ + @Operation(summary = "修改用户", description = "修改管理端用户信息") + @SaCheckPermission("admin:user:edit") + @Log(title = "管理端用户信息", businessType = BusinessType.UPDATE) + @PutMapping() + public R edit(@Parameter(description = "用户信息", required = true) + @Validated(EditGroup.class) @RequestBody AppUserBo bo) { + return toAjax(appUserService.updateByBo(bo)); + } + + /** + * 删除管理端用户信息 + */ + @Operation(summary = "删除用户", description = "批量删除管理端用户信息") + @SaCheckPermission("admin:user:remove") + @Log(title = "管理端用户信息", businessType = BusinessType.DELETE) + @DeleteMapping("/{userIds}") + public R remove(@Parameter(description = "用户ID数组", required = true) + @NotEmpty(message = "主键不能为空") + @PathVariable Long[] userIds) { + return toAjax(appUserService.deleteWithValidByIds(List.of(userIds), true)); + } + + /** + * 获取个人信息 + */ + @Operation(summary = "获取个人信息", description = "获取当前登录用户的个人信息") + @GetMapping("/profile") + public R profile() { + LoginUser loginUser = LoginHelper.getLoginUser(); + AppUserVo user = appUserService.queryById(loginUser.getUserId()); + return R.ok(user); + } + + /** + * 修改个人信息 + */ + @Operation(summary = "修改个人信息", description = "修改当前登录用户的个人信息") + @Log(title = "个人信息", businessType = BusinessType.UPDATE) + @PutMapping("/profile") + public R updateProfile(@Parameter(description = "用户信息", required = true) + @RequestBody AppUserBo bo) { + LoginUser loginUser = LoginHelper.getLoginUser(); + bo.setUserId(loginUser.getUserId()); + return toAjax(appUserService.updateByBo(bo)); + } + + /** + * 重置密码 + */ + @Operation(summary = "重置密码", description = "重置用户密码") + @SaCheckPermission("admin:user:resetPwd") + @Log(title = "管理端用户信息", businessType = BusinessType.UPDATE) + @PutMapping("/resetPwd") + public R resetPwd(@Parameter(description = "用户信息", required = true) + @RequestBody AppUserBo bo) { + AppUserBo userBo = new AppUserBo(); + userBo.setUserId(bo.getUserId()); + userBo.setPassword(bo.getPassword()); + return toAjax(appUserService.updateByBo(userBo)); + } + + /** + * 状态修改 + */ + @Operation(summary = "修改用户状态", description = "修改用户启用/禁用状态") + @SaCheckPermission("admin:user:edit") + @Log(title = "管理端用户信息", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus") + public R changeStatus(@Parameter(description = "用户信息", required = true) + @RequestBody AppUserBo bo) { + return toAjax(appUserService.updateUserStatus(bo.getUserId(), bo.getStatus())); + } + + /** + * 根据用户编号获取授权角色 + */ + @Operation(summary = "获取用户角色", description = "根据用户ID获取授权角色信息") + @SaCheckPermission("admin:user:query") + @GetMapping("/authRole/{userId}") + public R authRole(@Parameter(description = "用户ID", required = true) + @PathVariable Long userId) { + AppUserVo user = appUserService.queryById(userId); + return R.ok(user); + } + + /** + * 获取部门树列表 + */ + @Operation(summary = "获取部门树", description = "获取部门树形结构列表") + @SaCheckPermission("admin:user:list") + @GetMapping("/deptTree") + public R> deptTree(@Parameter(description = "用户查询条件") AppUserBo bo) { + // 这里可以根据实际需求实现部门树逻辑 + return R.ok(); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppDeviceController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppDeviceController.java new file mode 100644 index 0000000..4ed73a5 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppDeviceController.java @@ -0,0 +1,247 @@ +package org.dromara.member.controller.app; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.stp.StpUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppDeviceBo; +import org.dromara.member.domain.vo.AppDeviceVo; +import org.dromara.member.service.AppDeviceService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * App端设备管理 + * + * @author pengles + */ +@Tag(name = "App端设备管理", description = "App端设备信息管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/device") +public class AppDeviceController extends BaseController { + + private final AppDeviceService appDeviceService; + + /** + * 查询App端设备信息列表 + */ + @Operation(summary = "查询设备列表", description = "分页查询当前用户的设备信息列表") + @SaCheckLogin + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "设备查询条件") @Valid AppDeviceBo bo, + @Parameter(description = "分页参数") @Valid PageQuery pageQuery) { + // 只能查询当前用户的设备 + bo.setUserId(StpUtil.getLoginIdAsLong()); + return appDeviceService.queryPageList(bo, pageQuery); + } + + /** + * 获取App端设备信息详细信息 + */ + @Operation(summary = "获取设备详情", description = "根据设备ID获取详细信息") + @SaCheckLogin + @GetMapping("/{deviceId}") + public R getInfo(@Parameter(description = "设备ID", required = true) + @PathVariable @NotNull(message = "设备ID不能为空") @Positive(message = "设备ID必须为正数") Long deviceId) { + AppDeviceVo deviceVo = appDeviceService.queryById(deviceId); + // 验证设备是否属于当前用户 + if (deviceVo != null && !deviceVo.getUserId().equals(StpUtil.getLoginIdAsLong())) { + return R.fail("无权限访问该设备信息"); + } + return R.ok(deviceVo); + } + + /** + * 绑定设备 + */ + @Operation(summary = "绑定设备", description = "绑定新设备到当前用户") + @SaCheckLogin + @Log(title = "App端设备信息", businessType = BusinessType.INSERT) + @PostMapping("/bind") + public R bind(@Parameter(description = "设备信息", required = true) + @Validated(AddGroup.class) @RequestBody @NotNull(message = "设备信息不能为空") AppDeviceBo bo) { + // 设置当前用户ID + bo.setUserId(StpUtil.getLoginIdAsLong()); + + // 验证必要字段 + if (StringUtils.isBlank(bo.getDeviceType())) { + return R.fail("设备类型不能为空"); + } + if (StringUtils.isBlank(bo.getDeviceName())) { + return R.fail("设备名称不能为空"); + } + + return toAjax(appDeviceService.bindDevice(bo)); + } + + /** + * 修改设备信息 + */ + @Operation(summary = "修改设备信息", description = "修改设备信息") + @SaCheckLogin + @Log(title = "App端设备信息", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Parameter(description = "设备信息", required = true) + @Validated(EditGroup.class) @RequestBody @NotNull(message = "设备信息不能为空") AppDeviceBo bo) { + // 验证设备是否属于当前用户 + AppDeviceVo deviceVo = appDeviceService.queryById(bo.getDeviceId()); + if (deviceVo == null) { + return R.fail("设备不存在"); + } + if (!deviceVo.getUserId().equals(StpUtil.getLoginIdAsLong())) { + return R.fail("无权限修改该设备信息"); + } + + // 设置当前用户ID + bo.setUserId(StpUtil.getLoginIdAsLong()); + return toAjax(appDeviceService.updateByBo(bo)); + } + + /** + * 解绑设备 + */ + @Operation(summary = "解绑设备", description = "解绑指定设备") + @SaCheckLogin + @Log(title = "App端设备信息", businessType = BusinessType.DELETE) + @DeleteMapping("/{deviceId}") + public R unbind(@Parameter(description = "设备ID", required = true) + @PathVariable @NotNull(message = "设备ID不能为空") @Positive(message = "设备ID必须为正数") Long deviceId) { + return toAjax(appDeviceService.unbindDevice(deviceId, StpUtil.getLoginIdAsLong())); + } + + /** + * 获取我的设备列表 + */ + @Operation(summary = "获取我的设备", description = "获取当前用户的所有设备列表") + @SaCheckLogin + @GetMapping("/my") + public R> getMyDevices() { + List devices = appDeviceService.queryByUserId(StpUtil.getLoginIdAsLong()); + return R.ok(devices); + } + + /** + * 获取活跃设备列表 + */ + @Operation(summary = "获取活跃设备", description = "获取当前用户的活跃设备列表") + @SaCheckLogin + @GetMapping("/active") + public R> getActiveDevices() { + List devices = appDeviceService.getActiveDevices(StpUtil.getLoginIdAsLong()); + return R.ok(devices); + } + + /** + * 更新设备状态 + */ + @Operation(summary = "更新设备状态", description = "更新设备的激活状态") + @SaCheckLogin + @Log(title = "App端设备信息", businessType = BusinessType.UPDATE) + @PutMapping("/{deviceId}/status") + public R updateStatus(@Parameter(description = "设备ID", required = true) + @PathVariable @NotNull(message = "设备ID不能为空") @Positive(message = "设备ID必须为正数") Long deviceId, + @Parameter(description = "设备状态(0-禁用,1-启用)", required = true) + @RequestParam @NotBlank(message = "设备状态不能为空") @Pattern(regexp = "^[01]$", message = "设备状态只能为0或1") String isActive) { + // 验证设备是否属于当前用户 + AppDeviceVo deviceVo = appDeviceService.queryById(deviceId); + if (deviceVo == null) { + return R.fail("设备不存在"); + } + if (!deviceVo.getUserId().equals(StpUtil.getLoginIdAsLong())) { + return R.fail("无权限修改该设备状态"); + } + + // 验证状态值 + if (!"0".equals(isActive) && !"1".equals(isActive)) { + return R.fail("状态值无效"); + } + + return toAjax(appDeviceService.updateActiveStatus(deviceId, isActive)); + } + + /** + * 更新设备推送令牌 + */ + @Operation(summary = "更新推送令牌", description = "更新设备的推送令牌") + @SaCheckLogin + @Log(title = "App端设备信息", businessType = BusinessType.UPDATE) + @PutMapping("/{deviceId}/push-token") + public R updatePushToken(@Parameter(description = "设备ID", required = true) + @PathVariable @NotNull(message = "设备ID不能为空") @Positive(message = "设备ID必须为正数") Long deviceId, + @Parameter(description = "推送令牌", required = true) + @RequestParam @NotBlank(message = "推送令牌不能为空") @Size(max = 255, message = "推送令牌长度不能超过255个字符") String pushToken) { + // 验证设备是否属于当前用户 + AppDeviceVo deviceVo = appDeviceService.queryById(deviceId); + if (deviceVo == null) { + return R.fail("设备不存在"); + } + if (!deviceVo.getUserId().equals(StpUtil.getLoginIdAsLong())) { + return R.fail("无权限修改该设备推送令牌"); + } + + if (StringUtils.isBlank(pushToken)) { + return R.fail("推送令牌不能为空"); + } + + return toAjax(appDeviceService.updatePushToken(deviceId, pushToken)); + } + + /** + * 设备心跳 + */ + @Operation(summary = "设备心跳", description = "更新设备心跳时间") + @SaCheckLogin + @PostMapping("/{deviceId}/heartbeat") + public R heartbeat(@Parameter(description = "设备ID", required = true) + @PathVariable @NotNull(message = "设备ID不能为空") @Positive(message = "设备ID必须为正数") Long deviceId) { + // 验证设备是否属于当前用户 + AppDeviceVo deviceVo = appDeviceService.queryById(deviceId); + if (deviceVo == null) { + return R.fail("设备不存在"); + } + if (!deviceVo.getUserId().equals(StpUtil.getLoginIdAsLong())) { + return R.fail("无权限操作该设备"); + } + + return toAjax(appDeviceService.heartbeat(deviceId)); + } + + /** + * 根据推送令牌获取设备信息 + */ + @Operation(summary = "根据推送令牌获取设备", description = "根据推送令牌获取设备信息") + @SaCheckLogin + @GetMapping("/by-push-token") + public R getDeviceByPushToken(@Parameter(description = "推送令牌", required = true) + @RequestParam @NotBlank(message = "推送令牌不能为空") @Size(max = 255, message = "推送令牌长度不能超过255个字符") String pushToken) { + if (StringUtils.isBlank(pushToken)) { + return R.fail("推送令牌不能为空"); + } + + AppDeviceVo deviceVo = appDeviceService.queryByPushToken(pushToken); + // 验证设备是否属于当前用户 + if (deviceVo != null && !deviceVo.getUserId().equals(StpUtil.getLoginIdAsLong())) { + return R.fail("无权限访问该设备信息"); + } + + return R.ok(deviceVo); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppMessageController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppMessageController.java new file mode 100644 index 0000000..38139be --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppMessageController.java @@ -0,0 +1,222 @@ +package org.dromara.member.controller.app; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppMessageBo; +import org.dromara.member.domain.vo.AppMessageVo; +import org.dromara.member.service.AppMessageService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * App端消息管理 + * + * @author pengles + */ +@Tag(name = "App端消息管理", description = "App端消息管理相关接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/message") +public class AppMessageController extends BaseController { + + private final AppMessageService appMessageService; + + /** + * 查询App端消息列表 + */ + @Operation(summary = "查询消息列表", description = "查询App端消息列表") + @SaCheckPermission("app:message:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "查询条件") @Valid AppMessageBo bo, + @Parameter(description = "分页参数") @Valid PageQuery pageQuery) { + return appMessageService.queryPageList(bo, pageQuery); + } + + /** + * 获取当前用户的消息列表 + */ + @Operation(summary = "获取我的消息", description = "获取当前用户的消息列表") + @GetMapping("/myMessages") + public TableDataInfo getMyMessages(@Parameter(description = "分页参数") @Valid PageQuery pageQuery) { + LoginUser loginUser = LoginHelper.getLoginUser(); + AppMessageBo bo = new AppMessageBo(); + bo.setReceiverId(loginUser.getUserId()); + return appMessageService.queryPageList(bo, pageQuery); + } + + /** + * 获取当前用户未读消息数量 + */ + @Operation(summary = "获取未读消息数量", description = "获取当前用户未读消息数量") + @GetMapping("/unreadCount") + public R getUnreadCount() { + LoginUser loginUser = LoginHelper.getLoginUser(); + Long count = appMessageService.getUnreadCount(loginUser.getUserId()); + return R.ok(count); + } + + /** + * 导出App端消息列表 + */ + @Operation(summary = "导出消息列表", description = "导出App端消息列表") + @SaCheckPermission("app:message:export") + @Log(title = "App端消息", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "查询条件") @Valid AppMessageBo bo, HttpServletResponse response) { + List list = appMessageService.queryList(bo); + ExcelUtil.exportExcel(list, "App端消息", AppMessageVo.class, response); + } + + /** + * 获取App端消息详细信息 + */ + @Operation(summary = "获取消息详情", description = "获取App端消息详细信息") + @SaCheckPermission("app:message:query") + @GetMapping("/{messageId}") + public R getInfo(@Parameter(description = "消息ID", required = true) + @PathVariable @NotNull(message = "消息ID不能为空") @Positive(message = "消息ID必须为正数") Long messageId) { + return R.ok(appMessageService.queryById(messageId)); + } + + /** + * 新增App端消息 + */ + @Operation(summary = "新增消息", description = "新增App端消息") + @SaCheckPermission("app:message:add") + @Log(title = "App端消息", businessType = BusinessType.INSERT) + @PostMapping() + public R add(@Parameter(description = "消息信息", required = true) + @Validated(AddGroup.class) @RequestBody @NotNull(message = "消息信息不能为空") AppMessageBo bo) { + return toAjax(appMessageService.insertByBo(bo)); + } + + /** + * 修改App端消息 + */ + @Operation(summary = "修改消息", description = "修改App端消息") + @SaCheckPermission("app:message:edit") + @Log(title = "App端消息", businessType = BusinessType.UPDATE) + @PutMapping() + public R edit(@Parameter(description = "消息信息", required = true) + @Validated(EditGroup.class) @RequestBody @NotNull(message = "消息信息不能为空") AppMessageBo bo) { + return toAjax(appMessageService.updateByBo(bo)); + } + + /** + * 删除App端消息 + */ + @Operation(summary = "删除消息", description = "删除App端消息") + @SaCheckPermission("app:message:remove") + @Log(title = "App端消息", businessType = BusinessType.DELETE) + @DeleteMapping("/{messageIds}") + public R remove(@Parameter(description = "消息ID数组", required = true) + @PathVariable @NotEmpty(message = "消息ID不能为空") Long[] messageIds) { + return toAjax(appMessageService.deleteWithValidByIds(List.of(messageIds), true)); + } + + /** + * 标记消息为已读 + */ + @Operation(summary = "标记消息已读", description = "标记指定消息为已读") + @Log(title = "标记消息已读", businessType = BusinessType.UPDATE) + @PutMapping("/markRead/{messageId}") + public R markRead(@Parameter(description = "消息ID", required = true) + @PathVariable @NotNull(message = "消息ID不能为空") @Positive(message = "消息ID必须为正数") Long messageId) { + LoginUser loginUser = LoginHelper.getLoginUser(); + return toAjax(appMessageService.markAsRead(messageId, loginUser.getUserId())); + } + + /** + * 批量标记消息为已读 + */ + @Operation(summary = "批量标记已读", description = "批量标记消息为已读") + @Log(title = "批量标记消息已读", businessType = BusinessType.UPDATE) + @PutMapping("/markReadBatch") + public R markReadBatch(@Parameter(description = "消息ID数组", required = true) + @RequestBody @NotEmpty(message = "消息ID列表不能为空") Long[] messageIds) { + LoginUser loginUser = LoginHelper.getLoginUser(); + return toAjax(appMessageService.markAsReadBatch(List.of(messageIds), loginUser.getUserId())); + } + + /** + * 标记所有消息为已读 + */ + @Operation(summary = "标记全部已读", description = "标记所有消息为已读") + @Log(title = "标记所有消息已读", businessType = BusinessType.UPDATE) + @PutMapping("/markAllRead") + public R markAllRead() { + LoginUser loginUser = LoginHelper.getLoginUser(); + return toAjax(appMessageService.markAllAsRead(loginUser.getUserId())); + } + + /** + * 发送系统消息 + */ + @Operation(summary = "发送系统消息", description = "发送系统消息") + @SaCheckPermission("app:message:send") + @Log(title = "发送系统消息", businessType = BusinessType.INSERT) + @PostMapping("/sendSystem") + public R sendSystemMessage(@Parameter(description = "消息信息", required = true) + @RequestBody @NotNull(message = "消息信息不能为空") @Valid AppMessageBo bo) { + return toAjax(appMessageService.sendSystemMessage(bo)); + } + + /** + * 发送个人消息 + */ + @Operation(summary = "发送个人消息", description = "发送个人消息") + @Log(title = "发送个人消息", businessType = BusinessType.INSERT) + @PostMapping("/sendPersonal") + public R sendPersonalMessage(@Parameter(description = "消息信息", required = true) + @RequestBody @NotNull(message = "消息信息不能为空") @Valid AppMessageBo bo) { + LoginUser loginUser = LoginHelper.getLoginUser(); + bo.setSenderId(loginUser.getUserId()); + return toAjax(appMessageService.sendPersonalMessage(bo)); + } + + /** + * 推送消息 + */ + @Operation(summary = "推送消息", description = "推送消息") + @SaCheckPermission("app:message:push") + @Log(title = "推送消息", businessType = BusinessType.OTHER) + @PostMapping("/push") + public R pushMessage(@Parameter(description = "消息信息", required = true) + @Validated @RequestBody @NotNull(message = "消息信息不能为空") AppMessageBo bo) { + return toAjax(appMessageService.pushMessage(bo)); + } + + /** + * 群发消息 + */ + @Operation(summary = "群发消息", description = "群发消息") + @SaCheckPermission("app:message:broadcast") + @Log(title = "群发消息", businessType = BusinessType.INSERT) + @PostMapping("/broadcast") + public R broadcastMessage(@Parameter(description = "消息信息", required = true) + @Validated @RequestBody @NotNull(message = "消息信息不能为空") AppMessageBo bo) { + return toAjax(appMessageService.broadcastMessage(bo)); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppOperLogController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppOperLogController.java new file mode 100644 index 0000000..906068d --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppOperLogController.java @@ -0,0 +1,257 @@ +package org.dromara.member.controller.app; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.stp.StpUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppOperLogBo; +import org.dromara.member.domain.vo.AppOperLogVo; +import org.dromara.member.service.AppOperLogService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * App端操作日志 + * + * @author pengles + */ +@Tag(name = "App端操作日志管理", description = "App端操作日志管理相关接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/operLog") +public class AppOperLogController extends BaseController { + + private final AppOperLogService appOperLogService; + + /** + * 查询App端操作日志列表 + */ + @Operation(summary = "查询操作日志列表", description = "查询App端操作日志列表") + @SaCheckPermission("app:operLog:list") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "查询条件") @Valid AppOperLogBo bo, + @Parameter(description = "分页参数") @Valid PageQuery pageQuery) { + return appOperLogService.queryPageList(bo, pageQuery); + } + + /** + * 获取当前用户的操作日志列表 + */ + @Operation(summary = "获取我的操作日志", description = "获取当前用户的操作日志列表") + @SaCheckLogin + @GetMapping("/my") + public TableDataInfo getMyOperLogs(@Parameter(description = "查询条件") @Valid AppOperLogBo bo, + @Parameter(description = "分页参数") @Valid PageQuery pageQuery) { + // 只查询当前用户的操作日志 + bo.setOperName(StpUtil.getLoginIdAsString()); + return appOperLogService.queryPageList(bo, pageQuery); + } + + /** + * 导出App端操作日志列表 + */ + @Operation(summary = "导出操作日志", description = "导出App端操作日志列表") + @SaCheckPermission("app:operLog:export") + @Log(title = "App端操作日志", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(@Parameter(description = "查询条件") @Valid AppOperLogBo bo, HttpServletResponse response) { + List list = appOperLogService.queryList(bo); + ExcelUtil.exportExcel(list, "App端操作日志", AppOperLogVo.class, response); + } + + /** + * 获取App端操作日志详细信息 + */ + @Operation(summary = "获取操作日志详情", description = "获取App端操作日志详细信息") + @SaCheckPermission("app:operLog:query") + @GetMapping("/{operId}") + public R getInfo(@Parameter(description = "操作日志ID", required = true) + @PathVariable @NotNull(message = "操作日志ID不能为空") @Positive(message = "操作日志ID必须为正数") Long operId) { + return R.ok(appOperLogService.queryById(operId)); + } + + /** + * 新增App端操作日志 + */ + @Operation(summary = "新增操作日志", description = "新增App端操作日志") + @SaCheckPermission("app:operLog:add") + @Log(title = "App端操作日志", businessType = BusinessType.INSERT) + @PostMapping() + public R add(@Parameter(description = "操作日志信息", required = true) + @Validated(AddGroup.class) @RequestBody @NotNull(message = "操作日志信息不能为空") AppOperLogBo bo) { + return toAjax(appOperLogService.insertByBo(bo)); + } + + /** + * 修改App端操作日志 + */ + @Operation(summary = "修改操作日志", description = "修改App端操作日志") + @SaCheckPermission("app:operLog:edit") + @Log(title = "App端操作日志", businessType = BusinessType.UPDATE) + @PutMapping() + public R edit(@Parameter(description = "操作日志信息", required = true) + @Validated(EditGroup.class) @RequestBody @NotNull(message = "操作日志信息不能为空") AppOperLogBo bo) { + return toAjax(appOperLogService.updateByBo(bo)); + } + + /** + * 删除App端操作日志 + */ + @Operation(summary = "删除操作日志", description = "删除App端操作日志") + @SaCheckPermission("app:operLog:remove") + @Log(title = "App端操作日志", businessType = BusinessType.DELETE) + @DeleteMapping("/{operIds}") + public R remove(@Parameter(description = "操作日志ID数组", required = true) + @PathVariable @NotEmpty(message = "操作日志ID不能为空") Long[] operIds) { + return toAjax(appOperLogService.deleteWithValidByIds(List.of(operIds), true)); + } + + /** + * 清空App端操作日志 + */ + @Operation(summary = "清空操作日志", description = "清空App端操作日志") + @SaCheckPermission("app:operLog:remove") + @Log(title = "App端操作日志", businessType = BusinessType.CLEAN) + @DeleteMapping("/clean") + public R clean() { + appOperLogService.cleanOperLog(); + return R.ok(); + } + + /** + * 根据时间范围删除操作日志 + */ + @Operation(summary = "按时间删除操作日志", description = "根据时间范围删除App端操作日志") + @SaCheckPermission("app:operLog:remove") + @Log(title = "App端操作日志", businessType = BusinessType.DELETE) + @DeleteMapping("/cleanByTime") + public R cleanByTime(@Parameter(description = "开始时间") + @RequestParam(required = false) LocalDateTime beginTime, + @Parameter(description = "结束时间") + @RequestParam(required = false) LocalDateTime endTime) { + appOperLogService.deleteOperLogByTime(beginTime, endTime); + return R.ok(); + } + + /** + * 获取操作统计信息 + */ + @Operation(summary = "获取操作统计信息", description = "获取操作日志统计信息") + @SaCheckLogin + @GetMapping("/statistics") + public R getStatistics(@Parameter(description = "操作人员名称") + @RequestParam(required = false) @Size(max = 50, message = "操作人员名称长度不能超过50个字符") String operName, + @Parameter(description = "开始时间") + @RequestParam(required = false) LocalDateTime beginTime, + @Parameter(description = "结束时间") + @RequestParam(required = false) LocalDateTime endTime) { + // 如果没有指定操作人,则查询当前用户的统计 + if (operName == null) { + operName = StpUtil.getLoginIdAsString(); + } + Long count = appOperLogService.getOperLogCount(operName, beginTime, endTime); + return R.ok(count); + } + + /** + * 获取用户行为分析数据 + */ + @Operation(summary = "获取用户行为分析", description = "获取用户行为分析数据") + @SaCheckLogin + @GetMapping("/behavior") + public R> getBehaviorAnalysis(@Parameter(description = "操作人员名称") + @RequestParam(required = false) @Size(max = 50, message = "操作人员名称长度不能超过50个字符") String operName, + @Parameter(description = "分析天数") + @RequestParam(defaultValue = "7") @Min(value = 1, message = "天数必须大于0") @Max(value = 365, message = "天数不能超过365天") Integer days) { + // 如果没有指定操作人,则查询当前用户的行为分析 + if (operName == null) { + operName = StpUtil.getLoginIdAsString(); + } + List result = appOperLogService.getUserBehaviorAnalysis(operName, days); + return R.ok(result); + } + + /** + * 获取热门操作统计 + */ + @Operation(summary = "获取热门操作统计", description = "获取热门操作统计") + @SaCheckPermission("app:operLog:list") + @GetMapping("/popular") + public R> getPopularOperations(@Parameter(description = "统计天数") + @RequestParam(defaultValue = "7") @Min(value = 1, message = "天数必须大于0") @Max(value = 365, message = "天数不能超过365天") Integer days, + @Parameter(description = "返回数量限制") + @RequestParam(defaultValue = "10") @Min(value = 1, message = "限制数量必须大于0") @Max(value = 100, message = "限制数量不能超过100") Integer limit) { + List result = appOperLogService.getPopularOperations(days, limit); + return R.ok(result); + } + + /** + * 获取错误操作统计 + */ + @Operation(summary = "获取错误操作统计", description = "获取错误操作统计") + @SaCheckPermission("app:operLog:list") + @GetMapping("/errors") + public R> getErrorOperations(@Parameter(description = "统计天数") + @RequestParam(defaultValue = "7") @Min(value = 1, message = "天数必须大于0") @Max(value = 365, message = "天数不能超过365天") Integer days) { + List result = appOperLogService.getErrorOperations(days); + return R.ok(result); + } + + /** + * 记录用户操作日志(供App端调用) + */ + @Operation(summary = "记录操作日志", description = "记录用户操作日志(供App端调用)") + @SaCheckLogin + @Log(title = "App端操作日志", businessType = BusinessType.INSERT) + @PostMapping("/record") + public R recordOperation(@Parameter(description = "操作日志信息", required = true) + @RequestBody @NotNull(message = "操作日志信息不能为空") @Valid AppOperLogBo bo) { + // 设置操作人为当前登录用户 + bo.setOperName(StpUtil.getLoginIdAsString()); + bo.setOperTime(LocalDateTime.now()); + + // 异步记录操作日志 + appOperLogService.asyncRecordOperLog(bo); + return R.ok(); + } + + /** + * 批量记录操作日志 + */ + @Operation(summary = "批量记录操作日志", description = "批量记录操作日志") + @SaCheckLogin + @Log(title = "App端操作日志", businessType = BusinessType.INSERT) + @PostMapping("/batchRecord") + public R batchRecordOperations(@Parameter(description = "操作日志列表", required = true) + @RequestBody @NotEmpty(message = "操作日志列表不能为空") @Valid List operLogs) { + // 设置操作人为当前登录用户 + String currentUser = StpUtil.getLoginIdAsString(); + operLogs.forEach(bo -> { + bo.setOperName(currentUser); + bo.setOperTime(LocalDateTime.now()); + }); + + appOperLogService.batchInsertOperLog(operLogs); + return R.ok(); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppRoleController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppRoleController.java new file mode 100644 index 0000000..2e56d50 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppRoleController.java @@ -0,0 +1,189 @@ +package org.dromara.member.controller.app; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppRoleBo; +import org.dromara.member.domain.vo.AppRoleVo; +import org.dromara.member.service.AppRoleService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * App端角色信息 + * + * @author pengles + */ +@Tag(name = "App端角色管理", description = "App端角色信息的增删改查操作") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/role") +public class AppRoleController extends BaseController { + + private final AppRoleService appRoleService; + + /** + * 查询App端角色信息列表 + */ + @Operation(summary = "查询App端角色信息列表", description = "分页查询App端角色信息列表") + @SaCheckPermission("app:role:list") + @GetMapping("/list") + public TableDataInfo list( + @Parameter(description = "角色查询条件") @Valid AppRoleBo bo, + @Parameter(description = "分页查询参数") @Valid PageQuery pageQuery) { + return appRoleService.queryPageList(bo, pageQuery); + } + + /** + * 导出App端角色信息列表 + */ + @Operation(summary = "导出App端角色信息列表", description = "导出App端角色信息到Excel文件") + @SaCheckPermission("app:role:export") + @Log(title = "App端角色信息", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export( + @Parameter(description = "角色查询条件") @Valid AppRoleBo bo, + HttpServletResponse response) { + List list = appRoleService.queryList(bo); + ExcelUtil.exportExcel(list, "App端角色信息", AppRoleVo.class, response); + } + + /** + * 获取App端角色信息详细信息 + */ + @Operation(summary = "获取App端角色信息详细信息", description = "根据角色ID获取App端角色详细信息") + @SaCheckPermission("app:role:query") + @GetMapping("/{roleId}") + public R getInfo( + @Parameter(description = "角色ID", required = true) @PathVariable @NotNull(message = "角色ID不能为空") @Positive(message = "角色ID必须为正数") Long roleId) { + return R.ok(appRoleService.queryById(roleId)); + } + + /** + * 新增App端角色信息 + */ + @Operation(summary = "新增App端角色信息", description = "新增App端角色信息") + @SaCheckPermission("app:role:add") + @Log(title = "App端角色信息", businessType = BusinessType.INSERT) + @PostMapping() + public R add( + @Parameter(description = "角色信息", required = true) @Validated(AddGroup.class) @RequestBody @NotNull(message = "角色信息不能为空") AppRoleBo bo) { + return toAjax(appRoleService.insertByBo(bo)); + } + + /** + * 修改App端角色信息 + */ + @Operation(summary = "修改App端角色信息", description = "修改App端角色信息") + @SaCheckPermission("app:role:edit") + @Log(title = "App端角色信息", businessType = BusinessType.UPDATE) + @PutMapping() + public R edit( + @Parameter(description = "角色信息", required = true) @Validated(EditGroup.class) @RequestBody @NotNull(message = "角色信息不能为空") AppRoleBo bo) { + return toAjax(appRoleService.updateByBo(bo)); + } + + /** + * 删除App端角色信息 + */ + @Operation(summary = "删除App端角色信息", description = "批量删除App端角色信息") + @SaCheckPermission("app:role:remove") + @Log(title = "App端角色信息", businessType = BusinessType.DELETE) + @DeleteMapping("/{roleIds}") + public R remove( + @Parameter(description = "角色ID数组", required = true) @PathVariable @NotEmpty(message = "角色ID不能为空") Long[] roleIds) { + return toAjax(appRoleService.deleteWithValidByIds(List.of(roleIds), true)); + } + + /** + * 获取角色选择框列表 + */ + @Operation(summary = "获取角色选择框列表", description = "获取所有角色的选择框列表") + @SaCheckPermission("app:role:query") + @GetMapping("/optionselect") + public R> optionselect() { + return R.ok(appRoleService.selectRoleAll()); + } + + /** + * 查询已分配用户角色列表 + */ + @Operation(summary = "查询已分配用户角色列表", description = "分页查询已分配用户的角色列表") + @SaCheckPermission("app:role:list") + @GetMapping("/authUser/allocatedList") + public TableDataInfo allocatedList( + @Parameter(description = "角色查询条件") @Valid AppRoleBo bo, + @Parameter(description = "分页查询参数") @Valid PageQuery pageQuery) { + return appRoleService.queryPageList(bo, pageQuery); + } + + /** + * 查询未分配用户角色列表 + */ + @Operation(summary = "查询未分配用户角色列表", description = "分页查询未分配用户的角色列表") + @SaCheckPermission("app:role:list") + @GetMapping("/authUser/unallocatedList") + public TableDataInfo unallocatedList( + @Parameter(description = "角色查询条件") @Valid AppRoleBo bo, + @Parameter(description = "分页查询参数") @Valid PageQuery pageQuery) { + return appRoleService.queryPageList(bo, pageQuery); + } + + /** + * 取消授权用户 + */ + @Operation(summary = "取消授权用户", description = "取消用户的角色授权") + @SaCheckPermission("app:role:edit") + @Log(title = "App端角色信息", businessType = BusinessType.GRANT) + @PutMapping("/authUser/cancel") + public R cancelAuthUser( + @Parameter(description = "角色信息", required = true) @RequestBody @NotNull(message = "角色信息不能为空") @Valid AppRoleBo bo) { + return toAjax(appRoleService.deleteAuthUser(bo.getUserId(), bo.getRoleId())); + } + + /** + * 批量取消授权用户 + */ + @Operation(summary = "批量取消授权用户", description = "批量取消用户的角色授权") + @SaCheckPermission("app:role:edit") + @Log(title = "App端角色信息", businessType = BusinessType.GRANT) + @PutMapping("/authUser/cancelAll") + public R cancelAuthUserAll( + @Parameter(description = "角色ID", required = true) @NotNull(message = "角色ID不能为空") @Positive(message = "角色ID必须为正数") Long roleId, + @Parameter(description = "用户ID数组", required = true) @NotEmpty(message = "用户ID不能为空") Long[] userIds) { + return toAjax(appRoleService.deleteAuthUsers(roleId, userIds)); + } + + /** + * 批量选择用户授权 + */ + @Operation(summary = "批量选择用户授权", description = "批量为用户分配角色授权") + @SaCheckPermission("app:role:edit") + @Log(title = "App端角色信息", businessType = BusinessType.GRANT) + @PutMapping("/authUser/selectAll") + public R selectAuthUserAll( + @Parameter(description = "角色ID", required = true) @NotNull(message = "角色ID不能为空") @Positive(message = "角色ID必须为正数") Long roleId, + @Parameter(description = "用户ID数组", required = true) @NotEmpty(message = "用户ID不能为空") Long[] userIds) { + return toAjax(appRoleService.insertAuthUsers(roleId, userIds)); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppUserController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppUserController.java new file mode 100644 index 0000000..b936189 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppUserController.java @@ -0,0 +1,220 @@ +package org.dromara.member.controller.app; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.stp.StpUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppUserBo; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.member.service.AppUserService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * App端用户信息 + * + * @author pengles + */ +@Tag(name = "App用户管理", description = "App端用户信息管理接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/user") +public class AppUserController extends BaseController { + + private final AppUserService appUserService; + + /** + * 查询App端用户信息列表 + */ + @Operation(summary = "查询用户列表", description = "分页查询App端用户信息列表") + @SaCheckPermission("app:user:list") + @GetMapping("/list") + public TableDataInfo list( + @Parameter(description = "查询条件") @Valid AppUserBo bo, + @Parameter(description = "分页参数") @Valid PageQuery pageQuery) { + return appUserService.queryPageList(bo, pageQuery); + } + + /** + * 导出App端用户信息列表 + */ + @Operation(summary = "导出用户列表", description = "导出App端用户信息到Excel") + @SaCheckPermission("app:user:export") + @Log(title = "App端用户信息", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export( + @Parameter(description = "查询条件") @Valid AppUserBo bo, + HttpServletResponse response) { + List list = appUserService.queryList(bo); + ExcelUtil.exportExcel(list, "App端用户信息", AppUserVo.class, response); + } + + /** + * 获取App端用户信息详细信息 + */ + @Operation(summary = "获取用户详情", description = "根据用户ID获取详细信息") + @SaCheckPermission("app:user:query") + @GetMapping("/{userId}") + public R getInfo( + @Parameter(description = "用户ID", required = true) + @PathVariable @NotNull(message = "用户ID不能为空") @Positive(message = "用户ID必须为正数") Long userId) { + return R.ok(appUserService.queryById(userId)); + } + + /** + * 新增App端用户信息 + */ + @Operation(summary = "新增用户", description = "创建新的App端用户") + @SaCheckPermission("app:user:add") + @Log(title = "App端用户信息", businessType = BusinessType.INSERT) + @PostMapping() + public R add( + @Parameter(description = "用户信息", required = true) + @Validated(AddGroup.class) @RequestBody AppUserBo bo) { + return toAjax(appUserService.insertByBo(bo)); + } + + /** + * 修改App端用户信息 + */ + @Operation(summary = "修改用户", description = "更新App端用户信息") + @SaCheckPermission("app:user:edit") + @Log(title = "App端用户信息", businessType = BusinessType.UPDATE) + @PutMapping() + public R edit( + @Parameter(description = "用户信息", required = true) + @Validated(EditGroup.class) @RequestBody AppUserBo bo) { + return toAjax(appUserService.updateByBo(bo)); + } + + /** + * 删除App端用户信息 + */ + @Operation(summary = "删除用户", description = "批量删除App端用户") + @SaCheckPermission("app:user:remove") + @Log(title = "App端用户信息", businessType = BusinessType.DELETE) + @DeleteMapping("/{userIds}") + public R remove( + @Parameter(description = "用户ID列表", required = true) + @PathVariable @NotEmpty(message = "用户ID列表不能为空") Long[] userIds) { + return toAjax(appUserService.deleteWithValidByIds(List.of(userIds), true)); + } + + /** + * 重置密码 + */ + @Operation(summary = "重置密码", description = "重置用户密码") + @SaCheckPermission("app:user:resetPwd") + @Log(title = "App端用户信息", businessType = BusinessType.UPDATE) + @PutMapping("/resetPwd") + public R resetPwd( + @Parameter(description = "用户信息", required = true) + @Validated(EditGroup.class) @RequestBody AppUserBo bo) { + return toAjax(appUserService.resetPassword(bo.getUserId(), bo.getPassword())); + } + + /** + * 状态修改 + */ + @Operation(summary = "修改用户状态", description = "启用或禁用用户") + @SaCheckPermission("app:user:edit") + @Log(title = "App端用户信息", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus") + public R changeStatus( + @Parameter(description = "用户信息", required = true) + @Validated(EditGroup.class) @RequestBody AppUserBo bo) { + // 如果用户状态变为禁用,则冻结用户的所有token + if ("1".equals(bo.getStatus())) { + StpUtil.disable(bo.getUserId(), -1); // -1表示永久冻结,直到手动解冻 + } else if ("0".equals(bo.getStatus())) { + // 如果用户状态变为正常,则解冻用户 + StpUtil.untieDisable(bo.getUserId()); + } + return toAjax(appUserService.updateUserStatus(bo.getUserId(), bo.getStatus())); + } + + /** + * 根据用户编号获取详细信息 + */ + @Operation(summary = "获取用户档案", description = "根据用户ID获取用户档案信息") + @SaCheckPermission("app:user:query") + @GetMapping("/profile/{userId}") + public R profile( + @Parameter(description = "用户ID", required = true) + @PathVariable @NotNull(message = "用户ID不能为空") @Positive(message = "用户ID必须为正数") Long userId) { + AppUserVo user = appUserService.queryById(userId); + return R.ok(user); + } + + /** + * 获取当前用户信息 + */ + @Operation(summary = "获取当前用户信息", description = "获取当前登录用户的详细信息") + @GetMapping("/getInfo") + public R getInfo() { + LoginUser loginUser = LoginHelper.getLoginUser(); + AppUserVo user = appUserService.queryById(loginUser.getUserId()); + return R.ok(user); + } + + /** + * 获取用户个人信息 + */ + @Operation(summary = "获取个人信息", description = "获取当前用户的个人信息") + @GetMapping("/profile") + public R getUserProfile() { + LoginUser loginUser = LoginHelper.getLoginUser(); + AppUserVo user = appUserService.queryById(loginUser.getUserId()); + return R.ok(user); + } + + /** + * 修改用户个人信息 + */ + @Operation(summary = "修改个人信息", description = "更新当前用户的个人信息") + @Log(title = "个人信息", businessType = BusinessType.UPDATE) + @PutMapping("/profile") + public R updateUserProfile( + @Parameter(description = "用户信息", required = true) + @Validated(EditGroup.class) @RequestBody AppUserBo bo) { + LoginUser loginUser = LoginHelper.getLoginUser(); + bo.setUserId(loginUser.getUserId()); + return toAjax(appUserService.updateByBo(bo)); + } + + + /** + * 重置密码 + */ + @Operation(summary = "修改密码", description = "修改当前用户的登录密码") + @Log(title = "个人信息", businessType = BusinessType.UPDATE) + @PutMapping("/profile/updatePwd") + public R updateUserPwd( + @Parameter(description = "原密码", required = true) + @RequestParam @NotBlank(message = "原密码不能为空") @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间") String oldPassword, + @Parameter(description = "新密码", required = true) + @RequestParam @NotBlank(message = "新密码不能为空") @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间") String newPassword) { + LoginUser loginUser = LoginHelper.getLoginUser(); + return toAjax(appUserService.updatePassword(loginUser.getUserId(), oldPassword, newPassword)); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppVersionController.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppVersionController.java new file mode 100644 index 0000000..2d4967b --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/controller/app/AppVersionController.java @@ -0,0 +1,255 @@ +package org.dromara.member.controller.app; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.member.domain.bo.AppVersionBo; +import org.dromara.member.domain.vo.AppVersionVo; +import org.dromara.member.service.AppVersionService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * App端版本管理 + * + * @author pengles + */ +@Tag(name = "App端版本管理", description = "App端版本管理相关接口") +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/version") +public class AppVersionController extends BaseController { + + private final AppVersionService appVersionService; + + /** + * 查询App版本信息列表 + */ + @Operation(summary = "查询版本列表", description = "查询App版本信息列表") + @GetMapping("/list") + public TableDataInfo list(@Parameter(description = "查询条件") @Valid AppVersionBo bo, + @Parameter(description = "分页参数") @Valid PageQuery pageQuery) { + return appVersionService.queryPageList(bo, pageQuery); + } + + /** + * 获取App版本信息详细信息 + */ + @Operation(summary = "获取版本详情", description = "获取App版本信息详细信息") + @GetMapping("/{versionId}") + public R getInfo(@Parameter(description = "版本ID", required = true) + @PathVariable @NotNull(message = "版本ID不能为空") @Positive(message = "版本ID必须为正数") Long versionId) { + return R.ok(appVersionService.queryById(versionId)); + } + + /** + * 检查版本更新 + */ + @Operation(summary = "检查版本更新", description = "检查是否有新版本可用") + @GetMapping("/checkUpdate") + public R> checkUpdate(@Parameter(description = "平台类型(android/ios/web)", required = true) + @RequestParam @NotBlank(message = "平台类型不能为空") @Pattern(regexp = "^(android|ios|web)$", message = "平台类型只能为android、ios或web") String platform, + @Parameter(description = "当前版本号", required = true) + @RequestParam @NotNull(message = "当前版本号不能为空") @Positive(message = "当前版本号必须为正数") Integer currentVersionCode) { + if (StringUtils.isBlank(platform)) { + return R.fail("平台类型不能为空"); + } + if (currentVersionCode == null || currentVersionCode <= 0) { + return R.fail("当前版本号无效"); + } + + AppVersionVo updateInfo = appVersionService.getUpdateInfo(platform, currentVersionCode); + Map result = new HashMap<>(); + + if (updateInfo != null) { + result.put("hasUpdate", true); + result.put("versionInfo", updateInfo); + result.put("isForce", "1".equals(updateInfo.getIsForce())); + result.put("needUpdate", true); + } else { + result.put("hasUpdate", false); + result.put("needUpdate", false); + result.put("isForce", false); + result.put("message", "当前已是最新版本"); + } + + return R.ok(result); + } + + /** + * 获取最新版本信息 + */ + @Operation(summary = "获取最新版本", description = "获取指定平台的最新版本信息") + @GetMapping("/latest") + public R getLatestVersion(@Parameter(description = "平台类型(android/ios/web)", required = true) + @RequestParam @NotBlank(message = "平台类型不能为空") @Pattern(regexp = "^(android|ios|web)$", message = "平台类型只能为android、ios或web") String platform) { + if (StringUtils.isBlank(platform)) { + return R.fail("平台类型不能为空"); + } + + AppVersionVo latestVersion = appVersionService.getLatestVersion(platform); + if (latestVersion == null) { + return R.fail("未找到该平台的版本信息"); + } + + return R.ok(latestVersion); + } + + /** + * 获取版本历史记录 + */ + @Operation(summary = "获取版本历史", description = "获取指定平台的版本历史记录") + @GetMapping("/history") + public R> getVersionHistory(@Parameter(description = "平台类型(android/ios/web)", required = true) + @RequestParam @NotBlank(message = "平台类型不能为空") @Pattern(regexp = "^(android|ios|web)$", message = "平台类型只能为android、ios或web") String platform, + @Parameter(description = "返回数量限制") + @RequestParam(defaultValue = "10") @Min(value = 1, message = "限制数量必须大于0") @Max(value = 100, message = "限制数量不能超过100") Integer limit) { + if (StringUtils.isBlank(platform)) { + return R.fail("平台类型不能为空"); + } + + List versionHistory = appVersionService.getVersionHistory(platform, limit); + return R.ok(versionHistory); + } + + /** + * 检查是否强制更新 + */ + @Operation(summary = "检查强制更新", description = "检查当前版本是否需要强制更新") + @GetMapping("/checkForceUpdate") + public R> checkForceUpdate(@Parameter(description = "平台类型(android/ios/web)", required = true) + @RequestParam @NotBlank(message = "平台类型不能为空") @Pattern(regexp = "^(android|ios|web)$", message = "平台类型只能为android、ios或web") String platform, + @Parameter(description = "当前版本号", required = true) + @RequestParam @NotNull(message = "当前版本号不能为空") @Positive(message = "当前版本号必须为正数") Integer currentVersionCode) { + if (StringUtils.isBlank(platform)) { + return R.fail("平台类型不能为空"); + } + if (currentVersionCode == null || currentVersionCode <= 0) { + return R.fail("当前版本号无效"); + } + + Boolean forceUpdate = appVersionService.checkForceUpdate(platform, currentVersionCode); + Map result = new HashMap<>(); + result.put("forceUpdate", forceUpdate); + + if (forceUpdate) { + AppVersionVo updateInfo = appVersionService.getUpdateInfo(platform, currentVersionCode); + result.put("versionInfo", updateInfo); + } + + return R.ok(result); + } + + /** + * 获取更新信息 + */ + @Operation(summary = "获取更新信息", description = "获取详细的版本更新信息") + @GetMapping("/updateInfo") + public R> getUpdateInfo(@Parameter(description = "平台类型(android/ios/web)", required = true) + @RequestParam @NotBlank(message = "平台类型不能为空") @Pattern(regexp = "^(android|ios|web)$", message = "平台类型只能为android、ios或web") String platform, + @Parameter(description = "当前版本号", required = true) + @RequestParam @NotNull(message = "当前版本号不能为空") @Positive(message = "当前版本号必须为正数") Integer currentVersionCode) { + if (StringUtils.isBlank(platform)) { + return R.fail("平台类型不能为空"); + } + if (currentVersionCode == null || currentVersionCode <= 0) { + return R.fail("当前版本号无效"); + } + + AppVersionVo updateInfo = appVersionService.getUpdateInfo(platform, currentVersionCode); + Map result = new HashMap<>(); + + if (updateInfo != null) { + result.put("hasUpdate", true); + result.put("versionCode", updateInfo.getVersionCode()); + result.put("versionName", updateInfo.getVersionName()); + result.put("updateContent", updateInfo.getUpdateContent()); + result.put("downloadUrl", updateInfo.getDownloadUrl()); + result.put("isForce", "1".equals(updateInfo.getIsForce())); + result.put("platform", updateInfo.getPlatform()); + result.put("createTime", updateInfo.getCreateTime()); + } else { + result.put("hasUpdate", false); + result.put("message", "当前已是最新版本"); + } + + return R.ok(result); + } + + /** + * 版本比较 + */ + @Operation(summary = "版本比较", description = "比较两个版本之间的差异") + @GetMapping("/compare") + public R> compareVersion(@Parameter(description = "平台类型(android/ios/web)", required = true) + @RequestParam @NotBlank(message = "平台类型不能为空") @Pattern(regexp = "^(android|ios|web)$", message = "平台类型只能为android、ios或web") String platform, + @Parameter(description = "当前版本号", required = true) + @RequestParam @NotNull(message = "当前版本号不能为空") @Positive(message = "当前版本号必须为正数") Integer currentVersionCode, + @Parameter(description = "目标版本号", required = true) + @RequestParam @NotNull(message = "目标版本号不能为空") @Positive(message = "目标版本号必须为正数") Integer targetVersionCode) { + if (StringUtils.isBlank(platform)) { + return R.fail("平台类型不能为空"); + } + if (currentVersionCode == null || targetVersionCode == null) { + return R.fail("版本号不能为空"); + } + + Map result = new HashMap<>(); + result.put("platform", platform); + result.put("currentVersionCode", currentVersionCode); + result.put("targetVersionCode", targetVersionCode); + + if (targetVersionCode > currentVersionCode) { + result.put("needUpdate", true); + result.put("updateType", "upgrade"); + } else if (targetVersionCode < currentVersionCode) { + result.put("needUpdate", false); + result.put("updateType", "downgrade"); + } else { + result.put("needUpdate", false); + result.put("updateType", "same"); + } + + return R.ok(result); + } + + /** + * 记录版本下载 + */ + @Operation(summary = "记录版本下载", description = "记录用户下载版本的行为") + @Log(title = "App版本下载", businessType = BusinessType.OTHER) + @PostMapping("/download/{versionId}") + public R recordDownload(@Parameter(description = "版本ID", required = true) + @PathVariable @NotNull(message = "版本ID不能为空") @Positive(message = "版本ID必须为正数") Long versionId) { + // 这里可以记录下载统计等信息 + return R.ok(); + } + + /** + * 记录版本安装 + */ + @Operation(summary = "记录版本安装", description = "记录用户安装版本的行为") + @Log(title = "App版本安装", businessType = BusinessType.OTHER) + @PostMapping("/install/{versionId}") + public R recordInstall(@Parameter(description = "版本ID", required = true) + @PathVariable @NotNull(message = "版本ID不能为空") @Positive(message = "版本ID必须为正数") Long versionId) { + // 这里可以记录安装统计等信息 + return R.ok(); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppDevice.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppDevice.java new file mode 100644 index 0000000..ff88c11 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppDevice.java @@ -0,0 +1,109 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端设备信息对象 app_device + * + * @author pengles + */ +@Data +@TableName("app_device") +public class AppDevice implements Serializable { + + /** + * 设备ID + */ + @TableId(value = "device_id") + private Long deviceId; + + /** + * App用户ID + */ + private Long userId; + + /** + * 设备类型(ios android h5) + */ + private String deviceType; + + /** + * 设备名称 + */ + private String deviceName; + + /** + * 设备型号 + */ + private String deviceModel; + + /** + * 设备品牌 + */ + private String deviceBrand; + + /** + * 操作系统版本 + */ + private String osVersion; + + /** + * App版本 + */ + private String appVersion; + + /** + * 推送令牌 + */ + private String pushToken; + + /** + * 是否活跃(0否 1是) + */ + private String isActive; + + /** + * 最后活跃时间 + */ + private LocalDateTime lastActiveTime; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志(0代表存在 2代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppFile.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppFile.java new file mode 100644 index 0000000..71cf679 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppFile.java @@ -0,0 +1,107 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +/** + * App端文件信息对象 app_file + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("app_file") +public class AppFile extends BaseEntity { + + /** + * 文件ID + */ + @TableId(value = "file_id") + private Long fileId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 文件名 + */ + private String fileName; + + /** + * 原始文件名 + */ + private String originalName; + + /** + * 文件路径 + */ + private String filePath; + + /** + * 文件URL + */ + private String fileUrl; + + /** + * 文件大小(字节) + */ + private Long fileSize; + + /** + * 文件类型(MIME类型) + */ + private String fileType; + + /** + * 文件后缀 + */ + private String fileSuffix; + + /** + * 文件分类(1头像 2文档 3图片 4视频 5音频 6其他) + */ + private Integer fileCategory; + + /** + * 存储服务商 + */ + private String storageService; + + /** + * 文件状态(0正常 1删除) + */ + private String status; + + /** + * 下载次数 + */ + private Long downloadCount; + + /** + * 是否公开(0私有 1公开) + */ + private String isPublic; + + /** + * 文件MD5值 + */ + private String fileMd5; + + /** + * 备注 + */ + private String remark; + + /** + * 删除标志(0代表存在 2代表删除) + */ + @TableLogic + private String delFlag; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppLoginLog.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppLoginLog.java new file mode 100644 index 0000000..a6f2d0b --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppLoginLog.java @@ -0,0 +1,100 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端登录日志对象 app_login_log + * + * @author pengles + */ +@Data +@TableName("app_login_log") +public class AppLoginLog implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 访问ID + */ + @TableId(value = "info_id") + private Long infoId; + + /** + * 用户账号 + */ + private String userName; + + /** + * 登录状态(0成功 1失败) + */ + private String status; + + /** + * 登录IP地址 + */ + private String ipaddr; + + /** + * 登录地点 + */ + private String loginLocation; + + /** + * 浏览器类型 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; + + /** + * 提示消息 + */ + private String msg; + + /** + * 登录时间 + */ + private LocalDateTime loginTime; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppMessage.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppMessage.java new file mode 100644 index 0000000..2d431d4 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppMessage.java @@ -0,0 +1,98 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端消息推送对象 app_message + * + * @author pengles + */ +@Data +@TableName("app_message") +public class AppMessage implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 消息ID + */ + @TableId(value = "message_id") + private Long messageId; + + /** + * 消息标题 + */ + private String title; + + /** + * 消息内容 + */ + private String content; + + /** + * 消息类型(system:系统消息 notice:通知 activity:活动) + */ + private String messageType; + + /** + * 推送目标(all:全部用户 user:指定用户 role:指定角色) + */ + private String targetType; + + /** + * 目标ID列表(用户ID或角色ID,逗号分隔) + */ + private String targetIds; + + /** + * 发送时间 + */ + private LocalDateTime sendTime; + + /** + * 状态(0待发送 1已发送 2发送失败) + */ + private String status; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志(0代表存在 2代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppOperLog.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppOperLog.java new file mode 100644 index 0000000..deb7284 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppOperLog.java @@ -0,0 +1,130 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端操作日志对象 app_oper_log + * + * @author pengles + */ +@Data +@TableName("app_oper_log") +public class AppOperLog implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 日志主键 + */ + @TableId(value = "oper_id") + private Long operId; + + /** + * 模块标题 + */ + private String title; + + /** + * 业务类型(0其它 1新增 2修改 3删除) + */ + private Integer businessType; + + /** + * 方法名称 + */ + private String method; + + /** + * 请求方式 + */ + private String requestMethod; + + /** + * 操作人员 + */ + private String operName; + + /** + * 请求URL + */ + private String operUrl; + + /** + * 主机地址 + */ + private String operIp; + + /** + * 操作地点 + */ + private String operLocation; + + /** + * 请求参数 + */ + private String operParam; + + /** + * 返回参数 + */ + private String jsonResult; + + /** + * 操作状态(0正常 1异常) + */ + private Integer status; + + /** + * 错误消息 + */ + private String errorMsg; + + /** + * 操作时间 + */ + private LocalDateTime operTime; + + /** + * 消耗时间 + */ + private Long costTime; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppRole.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppRole.java new file mode 100644 index 0000000..6030c93 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppRole.java @@ -0,0 +1,83 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端角色信息对象 app_role + * + * @author pengles + */ +@Data +@TableName("app_role") +public class AppRole implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * App角色ID + */ + @TableId(value = "role_id") + private Long roleId; + + /** + * 角色名称 + */ + private String roleName; + + /** + * 角色权限字符串 + */ + private String roleKey; + + /** + * 显示顺序 + */ + private Integer roleSort; + + /** + * 角色状态(0正常 1停用) + */ + private String status; + + /** + * 删除标志(0代表存在 1代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppSocialAuth.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppSocialAuth.java new file mode 100644 index 0000000..e207f7e --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppSocialAuth.java @@ -0,0 +1,114 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * App端第三方登录授权对象 app_social_auth + * + * @author pengles + */ +@Data +@TableName("app_social_auth") +public class AppSocialAuth implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * App用户ID + */ + private Long userId; + + /** + * 用户来源平台(wechat_mp:微信小程序 wechat_open:微信开放平台 qq:QQ weibo:微博) + */ + private String platform; + + /** + * 表示平台唯一用户标识 + */ + private String platformUid; + + /** + * 微信openid + */ + private String openId; + + /** + * 微信unionid + */ + private String unionId; + + /** + * 用户的授权令牌 + */ + private String accessToken; + + /** + * 用户的授权令牌的有效期 + */ + private Integer expireIn; + + /** + * 刷新令牌 + */ + private String refreshToken; + + /** + * 授予的权限 + */ + private String scope; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** + * 删除标志(0代表存在 2代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 备注 + */ + private String remark; + + /** + * 创建部门 + */ + @TableField(exist = false) + private Long createDept; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUser.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUser.java new file mode 100644 index 0000000..74a41e3 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUser.java @@ -0,0 +1,118 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端用户信息对象 app_user + * + * @author pengles + */ +@Data +@TableName("app_user") +public class AppUser implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * App用户ID + */ + @TableId(value = "user_id") + private Long userId; + + /** + * 用户账号 + */ + private String userName; + + /** + * 用户昵称 + */ + private String nickName; + + /** + * 手机号码 + */ + private String phone; + + /** + * 用户邮箱 + */ + private String email; + + /** + * 用户性别(0男 1女 2未知) + */ + private String sex; + + /** + * 头像地址 + */ + private Long avatar; + + /** + * 密码 + */ + private String password; + + /** + * 帐号状态(0正常 1停用) + */ + private String status; + + /** + * 删除标志(0代表存在 1代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 最后登录IP + */ + private String loginIp; + + /** + * 最后登录时间 + */ + private LocalDateTime loginDate; + + /** + * 注册时间 + */ + private LocalDateTime registerTime; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUserMessage.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUserMessage.java new file mode 100644 index 0000000..7d788af --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUserMessage.java @@ -0,0 +1,83 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端用户消息读取记录对象 app_user_message + * + * @author pengles + */ +@Data +@TableName("app_user_message") +public class AppUserMessage implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * App用户ID + */ + private Long userId; + + /** + * 消息ID + */ + private Long messageId; + + /** + * 是否已读(0未读 1已读) + */ + private String isRead; + + /** + * 读取时间 + */ + private LocalDateTime readTime; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志(0代表存在 2代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUserRole.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUserRole.java new file mode 100644 index 0000000..d2056bf --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppUserRole.java @@ -0,0 +1,70 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端用户和角色关联对象 app_user_role + * + * @author pengles + */ +@Data +@TableName("app_user_role") +public class AppUserRole implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * App用户ID + */ + private Long userId; + + /** + * App角色ID + */ + private Long roleId; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志(0代表存在 2代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppVersion.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppVersion.java new file mode 100644 index 0000000..bed34b9 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/AppVersion.java @@ -0,0 +1,93 @@ +package org.dromara.member.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App版本信息对象 app_version + * + * @author pengles + */ +@Data +@TableName("app_version") +public class AppVersion implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 版本ID + */ + @TableId(value = "version_id") + private Long versionId; + + /** + * 版本号(数字) + */ + private Integer versionCode; + + /** + * 版本名(如1.0.0) + */ + private String versionName; + + /** + * 更新内容 + */ + private String updateContent; + + /** + * 下载地址 + */ + private String downloadUrl; + + /** + * 是否强制更新(0否 1是) + */ + private String isForce; + + /** + * 平台类型(android ios) + */ + private String platform; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志(0代表存在 2代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppDeviceBo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppDeviceBo.java new file mode 100644 index 0000000..c3815cd --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppDeviceBo.java @@ -0,0 +1,87 @@ +package org.dromara.member.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.time.LocalDateTime; + +/** + * App端设备信息业务对象 app_device + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = org.dromara.member.domain.AppDevice.class, reverseConvertGenerate = false) +public class AppDeviceBo extends BaseEntity { + + /** + * 设备ID + */ + @NotNull(message = "设备ID不能为空", groups = {EditGroup.class}) + private Long deviceId; + + /** + * App用户ID + */ + @NotNull(message = "用户ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long userId; + + /** + * 设备类型(ios android h5) + */ + @NotBlank(message = "设备类型不能为空", groups = {AddGroup.class, EditGroup.class}) + private String deviceType; + + /** + * 设备名称 + */ + private String deviceName; + + /** + * 设备型号 + */ + private String deviceModel; + + /** + * 设备品牌 + */ + private String deviceBrand; + + /** + * 操作系统版本 + */ + private String osVersion; + + /** + * App版本 + */ + private String appVersion; + + /** + * 推送令牌 + */ + private String pushToken; + + /** + * 是否活跃(0否 1是) + */ + private String isActive; + + /** + * 最后活跃时间 + */ + private LocalDateTime lastActiveTime; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppFileBo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppFileBo.java new file mode 100644 index 0000000..a0cb553 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppFileBo.java @@ -0,0 +1,108 @@ +package org.dromara.member.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.member.domain.AppFile; + +/** + * App端文件信息业务对象 app_file + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = AppFile.class, reverseConvertGenerate = false) +public class AppFileBo extends BaseEntity { + + /** + * 文件ID + */ + @NotNull(message = "文件ID不能为空", groups = {EditGroup.class}) + private Long fileId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 文件名 + */ + @NotBlank(message = "文件名不能为空", groups = {AddGroup.class, EditGroup.class}) + private String fileName; + + /** + * 原始文件名 + */ + @NotBlank(message = "原始文件名不能为空", groups = {AddGroup.class, EditGroup.class}) + private String originalName; + + /** + * 文件路径 + */ + private String filePath; + + /** + * 文件URL + */ + @NotBlank(message = "文件URL不能为空", groups = {AddGroup.class, EditGroup.class}) + private String fileUrl; + + /** + * 文件大小(字节) + */ + @NotNull(message = "文件大小不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long fileSize; + + /** + * 文件类型(MIME类型) + */ + private String fileType; + + /** + * 文件后缀 + */ + private String fileSuffix; + + /** + * 文件分类(1头像 2文档 3图片 4视频 5音频 6其他) + */ + private Integer fileCategory; + + /** + * 存储服务商 + */ + private String storageService; + + /** + * 文件状态(0正常 1删除) + */ + private String status; + + /** + * 下载次数 + */ + private Long downloadCount; + + /** + * 是否公开(0私有 1公开) + */ + private String isPublic; + + /** + * 文件MD5值 + */ + private String fileMd5; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppMessageBo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppMessageBo.java new file mode 100644 index 0000000..5afecfd --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppMessageBo.java @@ -0,0 +1,82 @@ +package org.dromara.member.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.time.LocalDateTime; + +/** + * App端消息业务对象 app_message + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = org.dromara.member.domain.AppMessage.class, reverseConvertGenerate = false) +public class AppMessageBo extends BaseEntity { + + /** + * 消息ID + */ + @NotNull(message = "消息ID不能为空", groups = {EditGroup.class}) + private Long messageId; + + /** + * 消息标题 + */ + @NotBlank(message = "消息标题不能为空", groups = {AddGroup.class, EditGroup.class}) + private String title; + + /** + * 消息内容 + */ + @NotBlank(message = "消息内容不能为空", groups = {AddGroup.class, EditGroup.class}) + private String content; + + /** + * 消息类型(system:系统消息 notice:通知 activity:活动) + */ + private String messageType; + + /** + * 推送目标(all:全部用户 user:指定用户 role:指定角色) + */ + private String targetType; + + /** + * 目标ID列表(用户ID或角色ID,逗号分隔) + */ + private String targetIds; + + /** + * 发送时间 + */ + private LocalDateTime sendTime; + + /** + * 状态(0待发送 1已发送 2发送失败) + */ + private String status; + + /** + * 备注 + */ + private String remark; + + /** + * 接收者ID(用于查询当前用户的消息) + */ + private Long receiverId; + + /** + * 发送者ID + */ + private Long senderId; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppOperLogBo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppOperLogBo.java new file mode 100644 index 0000000..07a805f --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppOperLogBo.java @@ -0,0 +1,107 @@ +package org.dromara.member.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.member.domain.AppOperLog; + +import java.time.LocalDateTime; + +/** + * App端操作日志业务对象 app_oper_log + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = AppOperLog.class, reverseConvertGenerate = false) +public class AppOperLogBo extends BaseEntity { + + /** + * 日志主键 + */ + @NotNull(message = "日志主键不能为空", groups = {EditGroup.class}) + private Long operId; + + /** + * 模块标题 + */ + @NotBlank(message = "模块标题不能为空", groups = {AddGroup.class, EditGroup.class}) + private String title; + + /** + * 业务类型(0其它 1新增 2修改 3删除) + */ + private Integer businessType; + + /** + * 方法名称 + */ + private String method; + + /** + * 请求方式 + */ + private String requestMethod; + + /** + * 操作人员 + */ + private String operName; + + /** + * 请求URL + */ + private String operUrl; + + /** + * 主机地址 + */ + private String operIp; + + /** + * 操作地点 + */ + private String operLocation; + + /** + * 请求参数 + */ + private String operParam; + + /** + * 返回参数 + */ + private String jsonResult; + + /** + * 操作状态(0正常 1异常) + */ + private Integer status; + + /** + * 错误消息 + */ + private String errorMsg; + + /** + * 操作时间 + */ + private LocalDateTime operTime; + + /** + * 消耗时间 + */ + private Long costTime; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppRoleBo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppRoleBo.java new file mode 100644 index 0000000..464c962 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppRoleBo.java @@ -0,0 +1,61 @@ +package org.dromara.member.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.member.domain.AppRole; + +/** + * App端角色信息业务对象 app_role + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = AppRole.class, reverseConvertGenerate = false) +public class AppRoleBo extends BaseEntity { + + /** + * App角色ID + */ + @NotNull(message = "App角色ID不能为空", groups = {EditGroup.class}) + private Long roleId; + + /** + * 角色名称 + */ + @NotBlank(message = "角色名称不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 30, message = "角色名称长度不能超过30个字符") + private String roleName; + + /** + * 角色权限字符串 + */ + @NotBlank(message = "角色权限字符串不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 100, message = "角色权限字符串长度不能超过100个字符") + private String roleKey; + + /** + * 显示顺序 + */ + @NotNull(message = "显示顺序不能为空", groups = {AddGroup.class, EditGroup.class}) + private Integer roleSort; + + /** + * 角色状态(0正常 1停用) + */ + @NotBlank(message = "角色状态不能为空", groups = {AddGroup.class, EditGroup.class}) + private String status; + + /** + * 用户ID(用于用户角色授权操作) + */ + private Long userId; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppUserBo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppUserBo.java new file mode 100644 index 0000000..ca3528b --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppUserBo.java @@ -0,0 +1,96 @@ +package org.dromara.member.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.member.domain.AppUser; + +import java.time.LocalDateTime; + +/** + * App端用户信息业务对象 app_user + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = AppUser.class, reverseConvertGenerate = false) +public class AppUserBo extends BaseEntity { + + /** + * App用户ID + */ + @NotNull(message = "App用户ID不能为空", groups = {EditGroup.class}) + private Long userId; + + /** + * 用户账号 + */ + @NotBlank(message = "用户账号不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 30, message = "用户账号长度不能超过30个字符") + private String userName; + + /** + * 用户昵称 + */ + @NotBlank(message = "用户昵称不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(max = 30, message = "用户昵称长度不能超过30个字符") + private String nickName; + + /** + * 手机号码 + */ + @Size(max = 11, message = "手机号码长度不能超过11个字符") + private String phone; + + /** + * 用户邮箱 + */ + @Email(message = "邮箱格式不正确") + @Size(max = 50, message = "用户邮箱长度不能超过50个字符") + private String email; + + /** + * 用户性别(0男 1女 2未知) + */ + private String sex; + + /** + * 头像地址 + */ + private Long avatar; + + /** + * 密码 + */ + @Size(max = 100, message = "密码长度不能超过100个字符") + private String password; + + /** + * 帐号状态(0正常 1停用) + */ + private String status; + + /** + * 最后登录IP + */ + private String loginIp; + + /** + * 最后登录时间 + */ + private LocalDateTime loginDate; + + /** + * 注册时间 + */ + private LocalDateTime registerTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppVersionBo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppVersionBo.java new file mode 100644 index 0000000..a10b2b4 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/bo/AppVersionBo.java @@ -0,0 +1,67 @@ +package org.dromara.member.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.member.domain.AppVersion; + +/** + * App版本信息业务对象 app_version + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = AppVersion.class, reverseConvertGenerate = false) +public class AppVersionBo extends BaseEntity { + + /** + * 版本ID + */ + @NotNull(message = "版本ID不能为空", groups = {EditGroup.class}) + private Long versionId; + + /** + * 版本号(数字) + */ + @NotNull(message = "版本号不能为空", groups = {AddGroup.class, EditGroup.class}) + private Integer versionCode; + + /** + * 版本名(如1.0.0) + */ + @NotBlank(message = "版本名不能为空", groups = {AddGroup.class, EditGroup.class}) + private String versionName; + + /** + * 更新内容 + */ + private String updateContent; + + /** + * 下载地址 + */ + private String downloadUrl; + + /** + * 是否强制更新(0否 1是) + */ + private String isForce; + + /** + * 平台类型(android ios) + */ + @NotBlank(message = "平台类型不能为空", groups = {AddGroup.class, EditGroup.class}) + private String platform; + + /** + * 备注 + */ + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppDeviceVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppDeviceVo.java new file mode 100644 index 0000000..c1f0782 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppDeviceVo.java @@ -0,0 +1,108 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.member.domain.AppDevice; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端设备信息视图对象 app_device + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppDevice.class) +public class AppDeviceVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 设备ID + */ + @ExcelProperty(value = "设备ID") + private Long deviceId; + + /** + * App用户ID + */ + @ExcelProperty(value = "App用户ID") + private Long userId; + + /** + * 设备类型(ios android h5) + */ + @ExcelProperty(value = "设备类型", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "app_device_type") + private String deviceType; + + /** + * 设备名称 + */ + @ExcelProperty(value = "设备名称") + private String deviceName; + + /** + * 设备型号 + */ + @ExcelProperty(value = "设备型号") + private String deviceModel; + + /** + * 设备品牌 + */ + @ExcelProperty(value = "设备品牌") + private String deviceBrand; + + /** + * 操作系统版本 + */ + @ExcelProperty(value = "操作系统版本") + private String osVersion; + + /** + * App版本 + */ + @ExcelProperty(value = "App版本") + private String appVersion; + + /** + * 推送令牌 + */ + @ExcelProperty(value = "推送令牌") + private String pushToken; + + /** + * 是否活跃(0否 1是) + */ + @ExcelProperty(value = "是否活跃", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_yes_no") + private String isActive; + + /** + * 最后活跃时间 + */ + @ExcelProperty(value = "最后活跃时间") + private LocalDateTime lastActiveTime; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppFileVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppFileVo.java new file mode 100644 index 0000000..75f5f79 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppFileVo.java @@ -0,0 +1,123 @@ +package org.dromara.member.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.member.domain.AppFile; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端文件信息视图对象 app_file + * + * @author pengles + */ +@Data +@AutoMapper(target = AppFile.class) +public class AppFileVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 文件ID + */ + private Long fileId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 文件名 + */ + private String fileName; + + /** + * 原始文件名 + */ + private String originalName; + + /** + * 文件路径 + */ + private String filePath; + + /** + * 文件URL + */ + private String fileUrl; + + /** + * 文件大小(字节) + */ + private Long fileSize; + + /** + * 文件类型(MIME类型) + */ + private String fileType; + + /** + * 文件后缀 + */ + private String fileSuffix; + + /** + * 文件分类(1头像 2文档 3图片 4视频 5音频 6其他) + */ + private Integer fileCategory; + + /** + * 存储服务商 + */ + private String storageService; + + /** + * 文件状态(0正常 1删除) + */ + private String status; + + /** + * 下载次数 + */ + private Long downloadCount; + + /** + * 是否公开(0私有 1公开) + */ + private String isPublic; + + /** + * 文件MD5值 + */ + private String fileMd5; + + /** + * 备注 + */ + private String remark; + + /** + * 创建者 + */ + private String createBy; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新者 + */ + private String updateBy; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppLoginLogVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppLoginLogVo.java new file mode 100644 index 0000000..5eadb05 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppLoginLogVo.java @@ -0,0 +1,83 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.member.domain.AppLoginLog; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端登录日志视图对象 app_login_log + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppLoginLog.class) +public class AppLoginLogVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 访问ID + */ + @ExcelProperty(value = "访问ID") + private Long infoId; + + /** + * 用户账号 + */ + @ExcelProperty(value = "用户账号") + private String userName; + + /** + * 登录状态(0成功 1失败) + */ + @ExcelProperty(value = "登录状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_common_status") + private String status; + + /** + * 登录IP地址 + */ + @ExcelProperty(value = "登录IP地址") + private String ipaddr; + + /** + * 登录地点 + */ + @ExcelProperty(value = "登录地点") + private String loginLocation; + + /** + * 浏览器类型 + */ + @ExcelProperty(value = "浏览器类型") + private String browser; + + /** + * 操作系统 + */ + @ExcelProperty(value = "操作系统") + private String os; + + /** + * 提示消息 + */ + @ExcelProperty(value = "提示消息") + private String msg; + + /** + * 访问时间 + */ + @ExcelProperty(value = "访问时间") + private LocalDateTime loginTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppMessageVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppMessageVo.java new file mode 100644 index 0000000..c64ede5 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppMessageVo.java @@ -0,0 +1,91 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.member.domain.AppMessage; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端消息推送视图对象 app_message + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppMessage.class) +public class AppMessageVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 消息ID + */ + @ExcelProperty(value = "消息ID") + private Long messageId; + + /** + * 消息标题 + */ + @ExcelProperty(value = "消息标题") + private String title; + + /** + * 消息内容 + */ + @ExcelProperty(value = "消息内容") + private String content; + + /** + * 消息类型(system:系统消息 notice:通知 activity:活动) + */ + @ExcelProperty(value = "消息类型", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "app_message_type") + private String messageType; + + /** + * 推送目标(all:全部用户 user:指定用户 role:指定角色) + */ + @ExcelProperty(value = "推送目标", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "app_target_type") + private String targetType; + + /** + * 目标ID列表(用户ID或角色ID,逗号分隔) + */ + @ExcelProperty(value = "目标ID列表") + private String targetIds; + + /** + * 发送时间 + */ + @ExcelProperty(value = "发送时间") + private LocalDateTime sendTime; + + /** + * 状态(0待发送 1已发送 2发送失败) + */ + @ExcelProperty(value = "状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "app_message_status") + private String status; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppOperLogVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppOperLogVo.java new file mode 100644 index 0000000..1f0b5e6 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppOperLogVo.java @@ -0,0 +1,120 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.member.domain.AppOperLog; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端操作日志视图对象 app_oper_log + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppOperLog.class) +public class AppOperLogVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 日志主键 + */ + @ExcelProperty(value = "日志主键") + private Long operId; + + /** + * 模块标题 + */ + @ExcelProperty(value = "模块标题") + private String title; + + /** + * 业务类型(0其它 1新增 2修改 3删除) + */ + @ExcelProperty(value = "业务类型", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_oper_type") + private Integer businessType; + + /** + * 方法名称 + */ + @ExcelProperty(value = "方法名称") + private String method; + + /** + * 请求方式 + */ + @ExcelProperty(value = "请求方式") + private String requestMethod; + + /** + * 操作人员 + */ + @ExcelProperty(value = "操作人员") + private String operName; + + /** + * 请求URL + */ + @ExcelProperty(value = "请求URL") + private String operUrl; + + /** + * 主机地址 + */ + @ExcelProperty(value = "主机地址") + private String operIp; + + /** + * 操作地点 + */ + @ExcelProperty(value = "操作地点") + private String operLocation; + + /** + * 请求参数 + */ + @ExcelProperty(value = "请求参数") + private String operParam; + + /** + * 返回参数 + */ + @ExcelProperty(value = "返回参数") + private String jsonResult; + + /** + * 操作状态(0正常 1异常) + */ + @ExcelProperty(value = "操作状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_common_status") + private Integer status; + + /** + * 错误消息 + */ + @ExcelProperty(value = "错误消息") + private String errorMsg; + + /** + * 操作时间 + */ + @ExcelProperty(value = "操作时间") + private LocalDateTime operTime; + + /** + * 消耗时间 + */ + @ExcelProperty(value = "消耗时间") + private Long costTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppRoleVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppRoleVo.java new file mode 100644 index 0000000..26f39b1 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppRoleVo.java @@ -0,0 +1,71 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.member.domain.AppRole; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端角色信息视图对象 app_role + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppRole.class) +public class AppRoleVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * App角色ID + */ + @ExcelProperty(value = "App角色ID") + private Long roleId; + + /** + * 角色名称 + */ + @ExcelProperty(value = "角色名称") + private String roleName; + + /** + * 角色权限字符串 + */ + @ExcelProperty(value = "角色权限字符串") + private String roleKey; + + /** + * 显示顺序 + */ + @ExcelProperty(value = "显示顺序") + private Integer roleSort; + + /** + * 角色状态(0正常 1停用) + */ + @ExcelProperty(value = "角色状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private String status; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppSocialAuthVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppSocialAuthVo.java new file mode 100644 index 0000000..aa2670b --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppSocialAuthVo.java @@ -0,0 +1,98 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.member.domain.AppSocialAuth; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端第三方登录授权视图对象 app_social_auth + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppSocialAuth.class) +public class AppSocialAuthVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "主键") + private Long id; + + /** + * App用户ID + */ + @ExcelProperty(value = "App用户ID") + private Long userId; + + /** + * 用户来源平台(wechat_mp:微信小程序 wechat_open:微信开放平台 qq:QQ weibo:微博) + */ + @ExcelProperty(value = "用户来源平台") + private String platform; + + /** + * 表示平台唯一用户标识 + */ + @ExcelProperty(value = "平台唯一用户标识") + private String platformUid; + + /** + * 微信openid + */ + @ExcelProperty(value = "微信openid") + private String openId; + + /** + * 微信unionid + */ + @ExcelProperty(value = "微信unionid") + private String unionId; + + /** + * 用户的授权令牌 + */ + @ExcelProperty(value = "授权令牌") + private String accessToken; + + /** + * 用户的授权令牌的有效期 + */ + @ExcelProperty(value = "令牌有效期") + private Integer expireIn; + + /** + * 刷新令牌 + */ + @ExcelProperty(value = "刷新令牌") + private String refreshToken; + + /** + * 授予的权限 + */ + @ExcelProperty(value = "授予权限") + private String scope; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserMessageVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserMessageVo.java new file mode 100644 index 0000000..cfe143d --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserMessageVo.java @@ -0,0 +1,65 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.member.domain.AppUserMessage; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端用户消息读取记录视图对象 app_user_message + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppUserMessage.class) +public class AppUserMessageVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "主键") + private Long id; + + /** + * App用户ID + */ + @ExcelProperty(value = "App用户ID") + private Long userId; + + /** + * 消息ID + */ + @ExcelProperty(value = "消息ID") + private Long messageId; + + /** + * 是否已读(0未读 1已读) + */ + @ExcelProperty(value = "是否已读", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_yes_no") + private String isRead; + + /** + * 读取时间 + */ + @ExcelProperty(value = "读取时间") + private LocalDateTime readTime; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserRoleVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserRoleVo.java new file mode 100644 index 0000000..2b524a4 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserRoleVo.java @@ -0,0 +1,68 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.member.domain.AppUserRole; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端用户和角色关联视图对象 app_user_role + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppUserRole.class) +public class AppUserRoleVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * App用户ID + */ + @ExcelProperty(value = "App用户ID") + private Long userId; + + /** + * App角色ID + */ + @ExcelProperty(value = "App角色ID") + private Long roleId; + + /** + * 创建者 + */ + @ExcelProperty(value = "创建者") + private Long createBy; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 更新者 + */ + @ExcelProperty(value = "更新者") + private Long updateBy; + + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserVo.java new file mode 100644 index 0000000..3c92b4b --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppUserVo.java @@ -0,0 +1,114 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.member.domain.AppUser; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App端用户信息视图对象 app_user + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppUser.class) +public class AppUserVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * App用户ID + */ + @ExcelProperty(value = "App用户ID") + private Long userId; + + /** + * 用户账号 + */ + @ExcelProperty(value = "用户账号") + private String userName; + + /** + * 密码 + */ + @ExcelProperty(value = "密码") + private String password; + + /** + * 用户昵称 + */ + @ExcelProperty(value = "用户昵称") + private String nickName; + + /** + * 手机号码 + */ + @ExcelProperty(value = "手机号码") + private String phone; + + /** + * 用户邮箱 + */ + @ExcelProperty(value = "用户邮箱") + private String email; + + /** + * 用户性别(0男 1女 2未知) + */ + @ExcelProperty(value = "用户性别", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_user_sex") + private String sex; + + /** + * 头像地址 + */ + @ExcelProperty(value = "头像地址") + private Long avatar; + + /** + * 帐号状态(0正常 1停用) + */ + @ExcelProperty(value = "帐号状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private String status; + + /** + * 最后登录IP + */ + @ExcelProperty(value = "最后登录IP") + private String loginIp; + + /** + * 最后登录时间 + */ + @ExcelProperty(value = "最后登录时间") + private LocalDateTime loginDate; + + /** + * 注册时间 + */ + @ExcelProperty(value = "注册时间") + private LocalDateTime registerTime; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppVersionVo.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppVersionVo.java new file mode 100644 index 0000000..3da5401 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/domain/vo/AppVersionVo.java @@ -0,0 +1,77 @@ +package org.dromara.member.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.member.domain.AppVersion; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * App版本信息视图对象 app_version + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AppVersion.class) +public class AppVersionVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 版本ID + */ + @ExcelProperty(value = "版本ID") + private Long versionId; + + /** + * 版本号(数字) + */ + @ExcelProperty(value = "版本号") + private Integer versionCode; + + /** + * 版本名(如1.0.0) + */ + @ExcelProperty(value = "版本名") + private String versionName; + + /** + * 更新内容 + */ + @ExcelProperty(value = "更新内容") + private String updateContent; + + /** + * 下载地址 + */ + @ExcelProperty(value = "下载地址") + private String downloadUrl; + + /** + * 是否强制更新(0否 1是) + */ + @ExcelProperty(value = "是否强制更新", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_yes_no") + private String isForce; + + /** + * 平台(android、ios) + */ + @ExcelProperty(value = "平台") + private String platform; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/AppExceptionHandler.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/AppExceptionHandler.java new file mode 100644 index 0000000..52e973c --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/AppExceptionHandler.java @@ -0,0 +1,159 @@ +package org.dromara.member.exception; + +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.exception.NotPermissionException; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +import java.io.IOException; + +/** + * App模块专用异常处理器 + * 处理App端特有的业务异常和错误场景 + * + * @author pengles + */ +@Slf4j +@Order(1) // 优先级高于全局异常处理器 +@RestControllerAdvice(basePackages = "org.dromara.app") +public class AppExceptionHandler { + + /** + * App端登录异常处理 + */ + @ExceptionHandler(NotLoginException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public R handleNotLoginException(NotLoginException e) { + log.warn("App端用户未登录: {}", e.getMessage()); + return R.fail(401, "请先登录后再操作"); + } + + /** + * App端权限异常处理 + */ + @ExceptionHandler(NotPermissionException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public R handleNotPermissionException(NotPermissionException e) { + log.warn("App端用户权限不足: {}", e.getMessage()); + return R.fail(403, "权限不足,无法访问该资源"); + } + + /** + * 文件上传大小超限异常 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { + log.error("App端文件上传大小超限: {}", e.getMessage()); + return R.fail("上传文件大小超出限制,请选择较小的文件"); + } + + /** + * IO异常处理(主要用于文件操作) + */ + @ExceptionHandler(IOException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public R handleIOException(IOException e) { + log.error("App端文件操作异常: {}", e.getMessage(), e); + return R.fail("文件操作失败,请稍后重试"); + } + + /** + * App端业务异常处理 + */ + @ExceptionHandler(ServiceException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleServiceException(ServiceException e) { + log.error("App端业务异常: {}", e.getMessage()); + Integer code = e.getCode(); + if (code != null) { + return R.fail(code, e.getMessage()); + } + return R.fail(e.getMessage()); + } + + /** + * 参数验证异常处理 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldError().getDefaultMessage(); + log.warn("App端参数验证失败: {}", message); + return R.fail("参数验证失败: " + message); + } + + /** + * 参数绑定异常处理 + */ + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleBindException(BindException e) { + String message = e.getBindingResult().getFieldError().getDefaultMessage(); + log.warn("App端参数绑定失败: {}", message); + return R.fail("参数绑定失败: " + message); + } + + /** + * 约束违反异常处理 + */ + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleConstraintViolationException(ConstraintViolationException e) { + String message = e.getMessage(); + log.warn("App端约束验证失败: {}", message); + return R.fail("参数验证失败: " + message); + } + + + /** + * 文件相关异常处理 + */ + @ExceptionHandler(FileException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleFileException(FileException e) { + log.error("App端文件异常: {}", e.getMessage()); + return R.fail(e.getCode(), e.getMessage()); + } + + /** + * 设备相关异常处理 + */ + @ExceptionHandler(DeviceException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleDeviceException(DeviceException e) { + log.error("App端设备异常: {}", e.getMessage()); + return R.fail(e.getCode(), e.getMessage()); + } + + /** + * 版本相关异常处理 + */ + @ExceptionHandler(VersionException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R handleVersionException(VersionException e) { + log.error("App端版本异常: {}", e.getMessage()); + return R.fail(e.getCode(), e.getMessage()); + } + + /** + * 通用异常处理 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public R handleException(Exception e) { + log.error("App端系统异常: ", e); + return R.fail("系统异常,请联系管理员"); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/DeviceException.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/DeviceException.java new file mode 100644 index 0000000..c7bb0f0 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/DeviceException.java @@ -0,0 +1,96 @@ +package org.dromara.member.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; + +/** + * 设备相关异常 + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class DeviceException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private Integer code; + + /** + * 错误信息 + */ + private String message; + + public DeviceException(String message) { + super(message); + this.message = message; + this.code = 400; + } + + public DeviceException(Integer code, String message) { + super(message); + this.code = code; + this.message = message; + } + + public DeviceException(String message, Throwable cause) { + super(message, cause); + this.message = message; + this.code = 400; + } + + /** + * 设备已绑定异常 + */ + public static DeviceException deviceAlreadyBound(String deviceInfo) { + return new DeviceException(4001, "设备已绑定: " + deviceInfo); + } + + /** + * 设备不存在异常 + */ + public static DeviceException deviceNotFound(Long deviceId) { + return new DeviceException(4002, "设备不存在: " + deviceId); + } + + /** + * 设备绑定失败异常 + */ + public static DeviceException bindFailed(String reason) { + return new DeviceException(4003, "设备绑定失败: " + reason); + } + + /** + * 设备解绑失败异常 + */ + public static DeviceException unbindFailed(String reason) { + return new DeviceException(4004, "设备解绑失败: " + reason); + } + + /** + * 设备状态异常 + */ + public static DeviceException invalidStatus(String status) { + return new DeviceException(4005, "设备状态异常: " + status); + } + + /** + * 推送令牌无效异常 + */ + public static DeviceException invalidPushToken(String token) { + return new DeviceException(4006, "推送令牌无效: " + token); + } + + /** + * 设备数量超限异常 + */ + public static DeviceException deviceLimitExceeded(int limit) { + return new DeviceException(4007, "设备数量超出限制,最多允许绑定 " + limit + " 台设备"); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/FileException.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/FileException.java new file mode 100644 index 0000000..2de3701 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/FileException.java @@ -0,0 +1,127 @@ +package org.dromara.member.exception; + +/** + * 文件相关异常 + * + * @author pengles + */ +public class FileException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private Integer code; + + private String message; + + private String detailMessage; + + public FileException() { + } + + public FileException(String message) { + this.message = message; + } + + public FileException(String message, Integer code) { + this.message = message; + this.code = code; + } + + public FileException(String message, Throwable e) { + super(message, e); + this.message = message; + } + + public FileException(String message, Integer code, Throwable e) { + super(message, e); + this.message = message; + this.code = code; + } + + public FileException(String message, Integer code, String detailMessage) { + this.message = message; + this.code = code; + this.detailMessage = detailMessage; + } + + public FileException(String message, Integer code, String detailMessage, Throwable e) { + super(message, e); + this.message = message; + this.code = code; + this.detailMessage = detailMessage; + } + + public Integer getCode() { + return code; + } + + public void setCode(Integer code) { + this.code = code; + } + + @Override + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getDetailMessage() { + return detailMessage; + } + + public void setDetailMessage(String detailMessage) { + this.detailMessage = detailMessage; + } + + // 静态工厂方法 + public static FileException fileEmpty() { + return new FileException("上传文件不能为空", 40001); + } + + public static FileException fileSizeExceeded(long maxSize) { + return new FileException("文件大小超过限制", 40002, "最大允许大小: " + maxSize + "MB"); + } + + public static FileException unsupportedFileType(String fileType) { + return new FileException("不支持的文件类型", 40003, "文件类型: " + fileType); + } + + public static FileException uploadFailed(String reason) { + return new FileException("文件上传失败", 40004, reason); + } + + public static FileException fileNotFound(Long fileId) { + return new FileException("文件不存在", 40005, "文件ID: " + fileId); + } + + public static FileException downloadFailed(String reason) { + return new FileException("文件下载失败", 40006, reason); + } + + public static FileException deleteFailed(String reason) { + return new FileException("文件删除失败", 40007, reason); + } + + public static FileException accessDenied(Long fileId) { + return new FileException("无权限访问文件", 40008, "文件ID: " + fileId); + } + + public static FileException storageServiceError(String service) { + return new FileException("存储服务异常", 40009, "服务: " + service); + } + + public static FileException md5CalculationFailed() { + return new FileException("文件MD5计算失败", 40010); + } + + public static FileException invalidFileName(String fileName) { + return new FileException("无效的文件名", 40011, "文件名: " + fileName); + } + + public static FileException fileAlreadyExists(String fileName) { + return new FileException("文件已存在", 40012, "文件名: " + fileName); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/VersionException.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/VersionException.java new file mode 100644 index 0000000..d397d6b --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/exception/VersionException.java @@ -0,0 +1,110 @@ +package org.dromara.member.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; + +/** + * 版本相关异常 + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class VersionException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private Integer code; + + /** + * 错误信息 + */ + private String message; + + public VersionException(String message) { + super(message); + this.message = message; + this.code = 400; + } + + public VersionException(Integer code, String message) { + super(message); + this.code = code; + this.message = message; + } + + public VersionException(String message, Throwable cause) { + super(message, cause); + this.message = message; + this.code = 400; + } + + /** + * 版本不存在异常 + */ + public static VersionException versionNotFound(String version) { + return new VersionException(5001, "版本不存在: " + version); + } + + /** + * 版本号重复异常 + */ + public static VersionException versionCodeDuplicate(Integer versionCode) { + return new VersionException(5002, "版本号已存在: " + versionCode); + } + + /** + * 版本号无效异常 + */ + public static VersionException invalidVersionCode(Integer versionCode) { + return new VersionException(5003, "版本号无效: " + versionCode); + } + + /** + * 平台不支持异常 + */ + public static VersionException unsupportedPlatform(String platform) { + return new VersionException(5004, "不支持的平台: " + platform); + } + + /** + * 版本发布失败异常 + */ + public static VersionException publishFailed(String reason) { + return new VersionException(5005, "版本发布失败: " + reason); + } + + /** + * 版本撤回失败异常 + */ + public static VersionException withdrawFailed(String reason) { + return new VersionException(5006, "版本撤回失败: " + reason); + } + + /** + * 强制更新版本冲突异常 + */ + public static VersionException forceUpdateConflict(String version) { + return new VersionException(5007, "强制更新版本冲突: " + version); + } + + /** + * 版本比较异常 + */ + public static VersionException versionCompareError(String currentVersion, String targetVersion) { + return new VersionException(5008, "版本比较失败: " + currentVersion + " vs " + targetVersion); + } + + /** + * 下载链接无效异常 + */ + public static VersionException invalidDownloadUrl(String url) { + return new VersionException(5009, "下载链接无效: " + url); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppDeviceMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppDeviceMapper.java new file mode 100644 index 0000000..4cd6faf --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppDeviceMapper.java @@ -0,0 +1,14 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppDevice; +import org.dromara.member.domain.vo.AppDeviceVo; + +/** + * App端设备信息Mapper接口 + * + * @author pengles + */ +public interface AppDeviceMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppFileMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppFileMapper.java new file mode 100644 index 0000000..38aa5a8 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppFileMapper.java @@ -0,0 +1,14 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppFile; +import org.dromara.member.domain.vo.AppFileVo; + +/** + * App端文件信息Mapper接口 + * + * @author pengles + */ +public interface AppFileMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppLoginLogMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppLoginLogMapper.java new file mode 100644 index 0000000..569202c --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppLoginLogMapper.java @@ -0,0 +1,14 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppLoginLog; +import org.dromara.member.domain.vo.AppLoginLogVo; + +/** + * App端登录日志Mapper接口 + * + * @author pengles + */ +public interface AppLoginLogMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppMessageMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppMessageMapper.java new file mode 100644 index 0000000..dc9818b --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppMessageMapper.java @@ -0,0 +1,39 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppMessage; +import org.dromara.member.domain.vo.AppMessageVo; + +import java.util.List; + +/** + * App端消息推送Mapper接口 + * + * @author pengles + */ +public interface AppMessageMapper extends BaseMapperPlus { + + /** + * 根据用户ID查询消息列表 + * + * @param userId 用户ID + * @return 消息列表 + */ + List selectMessagesByUserId(Long userId); + + /** + * 根据角色ID查询消息列表 + * + * @param roleId 角色ID + * @return 消息列表 + */ + List selectMessagesByRoleId(Long roleId); + + /** + * 查询全部用户消息列表 + * + * @return 消息列表 + */ + List selectMessagesForAll(); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppOperLogMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppOperLogMapper.java new file mode 100644 index 0000000..25d4a42 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppOperLogMapper.java @@ -0,0 +1,14 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppOperLog; +import org.dromara.member.domain.vo.AppOperLogVo; + +/** + * App端操作日志Mapper接口 + * + * @author pengles + */ +public interface AppOperLogMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppRoleMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppRoleMapper.java new file mode 100644 index 0000000..f32c3fe --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppRoleMapper.java @@ -0,0 +1,64 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppRole; +import org.dromara.member.domain.vo.AppRoleVo; + +import java.util.List; +import java.util.Set; + +/** + * App端角色信息Mapper接口 + * + * @author pengles + */ +public interface AppRoleMapper extends BaseMapperPlus { + + /** + * 根据用户ID查询角色 + * + * @param userId 用户ID + * @return 角色列表 + */ + List selectRolesByUserId(Long userId); + + /** + * 根据用户ID获取角色权限 + * + * @param userId 用户ID + * @return 权限列表 + */ + Set selectRolePermissionByUserId(Long userId); + + /** + * 查询所有角色 + * + * @return 角色列表 + */ + List selectRoleAll(); + + /** + * 根据用户ID获取角色选择框列表 + * + * @param userId 用户ID + * @return 角色ID列表 + */ + List selectRoleListByUserId(Long userId); + + /** + * 校验角色名称是否唯一 + * + * @param roleName 角色名称 + * @return 结果 + */ + int checkRoleNameUnique(String roleName); + + /** + * 校验角色权限是否唯一 + * + * @param roleKey 角色权限 + * @return 结果 + */ + int checkRoleKeyUnique(String roleKey); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppSocialAuthMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppSocialAuthMapper.java new file mode 100644 index 0000000..adfebb4 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppSocialAuthMapper.java @@ -0,0 +1,14 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppSocialAuth; +import org.dromara.member.domain.vo.AppSocialAuthVo; + +/** + * App端第三方登录授权Mapper接口 + * + * @author pengles + */ +public interface AppSocialAuthMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserMapper.java new file mode 100644 index 0000000..0d84f7c --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserMapper.java @@ -0,0 +1,62 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppUser; +import org.dromara.member.domain.vo.AppUserVo; + +/** + * App端用户信息Mapper接口 + * + * @author pengles + */ +public interface AppUserMapper extends BaseMapperPlus { + + /** + * 根据用户账号查询用户 + * + * @param userName 用户账号 + * @return 用户信息 + */ + AppUserVo selectUserByUserName(String userName); + + /** + * 根据手机号查询用户信息 + * + * @param phone 手机号 + * @return 用户信息 + */ + AppUserVo selectUserByPhone(String phone); + + /** + * 根据邮箱查询用户信息 + * + * @param email 邮箱 + * @return 用户信息 + */ + AppUserVo selectUserByEmail(String email); + + /** + * 校验用户账号是否唯一 + * + * @param userName 用户账号 + * @return 结果 + */ + int checkUserNameUnique(String userName); + + /** + * 校验手机号码是否唯一 + * + * @param phone 手机号码 + * @return 结果 + */ + int checkPhoneUnique(String phone); + + /** + * 校验email是否唯一 + * + * @param email 用户邮箱 + * @return 结果 + */ + int checkEmailUnique(String email); + +} diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserMessageMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserMessageMapper.java new file mode 100644 index 0000000..7a3ba27 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserMessageMapper.java @@ -0,0 +1,14 @@ +package org.dromara.member.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppUserMessage; +import org.dromara.member.domain.vo.AppUserMessageVo; + +/** + * App端用户消息读取记录Mapper接口 + * + * @author pengles + */ +public interface AppUserMessageMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserRoleMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserRoleMapper.java new file mode 100644 index 0000000..dd9079c --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppUserRoleMapper.java @@ -0,0 +1,51 @@ +package org.dromara.member.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppUserRole; +import org.dromara.member.domain.vo.AppUserRoleVo; + +import java.util.List; + +/** + * App端用户和角色关联Mapper接口 + * + * @author pengles + */ +@Mapper +public interface AppUserRoleMapper extends BaseMapperPlus { + + /** + * 通过用户ID删除用户和角色关联 + * + * @param userId 用户ID + * @return 结果 + */ + int deleteUserRoleByUserId(Long userId); + + /** + * 批量删除用户和角色关联 + * + * @param ids 需要删除的数据ID + * @return 结果 + */ + int deleteUserRole(Long[] ids); + + /** + * 通过角色ID查询角色使用数量 + * + * @param roleId 角色ID + * @return 结果 + */ + int countUserRoleByRoleId(Long roleId); + + /** + * 批量新增用户角色信息 + * + * @param userRoleList 用户角色列表 + * @return 结果 + */ + int batchUserRole(@Param("list") List userRoleList); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppVersionMapper.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppVersionMapper.java new file mode 100644 index 0000000..964e43d --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/mapper/AppVersionMapper.java @@ -0,0 +1,16 @@ +package org.dromara.member.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.member.domain.AppVersion; +import org.dromara.member.domain.vo.AppVersionVo; + +/** + * App版本信息Mapper接口 + * + * @author pengles + */ +@Mapper +public interface AppVersionMapper extends BaseMapperPlus { + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppDeviceService.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppDeviceService.java new file mode 100644 index 0000000..05ef9f2 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppDeviceService.java @@ -0,0 +1,98 @@ +package org.dromara.member.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.bo.AppDeviceBo; +import org.dromara.member.domain.vo.AppDeviceVo; + +import java.util.Collection; +import java.util.List; + +/** + * App端设备信息Service接口 + * + * @author pengles + */ +public interface AppDeviceService { + + /** + * 查询App端设备信息 + */ + AppDeviceVo queryById(Long deviceId); + + /** + * 查询App端设备信息列表 + */ + TableDataInfo queryPageList(AppDeviceBo bo, PageQuery pageQuery); + + /** + * 查询App端设备信息列表 + */ + List queryList(AppDeviceBo bo); + + /** + * 新增App端设备信息 + */ + Boolean insertByBo(AppDeviceBo bo); + + /** + * 修改App端设备信息 + */ + Boolean updateByBo(AppDeviceBo bo); + + /** + * 校验并批量删除App端设备信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据用户ID查询设备列表 + */ + List queryByUserId(Long userId); + + /** + * 绑定设备 + */ + Boolean bindDevice(AppDeviceBo bo); + + /** + * 解绑设备 + */ + Boolean unbindDevice(Long deviceId, Long userId); + + /** + * 更新设备活跃状态 + */ + Boolean updateActiveStatus(Long deviceId, String isActive); + + /** + * 更新推送令牌 + */ + Boolean updatePushToken(Long deviceId, String pushToken); + + /** + * 获取用户活跃设备 + */ + List getActiveDevices(Long userId); + + /** + * 清理非活跃设备 + */ + Integer cleanInactiveDevices(Integer days); + + /** + * 根据推送令牌查询设备 + */ + AppDeviceVo queryByPushToken(String pushToken); + + /** + * 设备心跳更新 + */ + Boolean heartbeat(Long deviceId); + + /** + * 批量更新设备状态 + */ + Boolean batchUpdateStatus(List deviceIds, String isActive); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppFileService.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppFileService.java new file mode 100644 index 0000000..32a3fbb --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppFileService.java @@ -0,0 +1,167 @@ +package org.dromara.member.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.bo.AppFileBo; +import org.dromara.member.domain.vo.AppFileVo; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collection; +import java.util.List; + +/** + * App端文件信息Service接口 + * + * @author pengles + */ +public interface AppFileService { + + /** + * 查询App端文件信息 + */ + AppFileVo queryById(Long fileId); + + /** + * 查询App端文件信息列表 + */ + TableDataInfo queryPageList(AppFileBo bo, PageQuery pageQuery); + + /** + * 查询App端文件信息列表 + */ + List queryList(AppFileBo bo); + + /** + * 新增App端文件信息 + */ + Boolean insertByBo(AppFileBo bo); + + /** + * 修改App端文件信息 + */ + Boolean updateByBo(AppFileBo bo); + + /** + * 校验并批量删除App端文件信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 上传文件 + * + * @param file 上传的文件 + * @param userId 用户ID + * @param fileCategory 文件分类 + * @return 文件信息 + */ + AppFileVo uploadFile(MultipartFile file, Long userId, Integer fileCategory); + + /** + * 上传头像 + * + * @param file 头像文件 + * @param userId 用户ID + * @return 文件信息 + */ + AppFileVo uploadAvatar(MultipartFile file, Long userId); + + /** + * 批量上传文件 + * + * @param files 文件数组 + * @param userId 用户ID + * @param fileCategory 文件分类 + * @return 文件信息列表 + */ + List batchUploadFiles(MultipartFile[] files, Long userId, Integer fileCategory); + + /** + * 根据用户ID查询文件列表 + * + * @param userId 用户ID + * @param fileCategory 文件分类(可选) + * @return 文件列表 + */ + List queryByUserId(Long userId, Integer fileCategory); + + /** + * 根据文件分类查询文件列表 + * + * @param fileCategory 文件分类 + * @return 文件列表 + */ + List queryByCategory(Integer fileCategory); + + /** + * 下载文件 + * + * @param fileId 文件ID + * @param userId 用户ID + * @return 文件信息 + */ + AppFileVo downloadFile(Long fileId, Long userId); + + /** + * 删除用户文件 + * + * @param fileId 文件ID + * @param userId 用户ID + * @return 删除结果 + */ + Boolean deleteUserFile(Long fileId, Long userId); + + /** + * 批量删除用户文件 + * + * @param fileIds 文件ID列表 + * @param userId 用户ID + * @return 删除结果 + */ + Boolean batchDeleteUserFiles(Collection fileIds, Long userId); + + /** + * 更新文件状态 + * + * @param fileId 文件ID + * @param status 状态 + * @param userId 用户ID + * @return 更新结果 + */ + Boolean updateFileStatus(Long fileId, String status, Long userId); + + /** + * 获取用户文件统计信息 + * + * @param userId 用户ID + * @return 统计信息 + */ + AppFileVo getUserFileStatistics(Long userId); + + /** + * 清理过期文件 + * + * @param days 过期天数 + * @return 清理数量 + */ + Integer cleanExpiredFiles(Integer days); + + /** + * 检查文件是否存在 + * + * @param fileMd5 文件MD5 + * @param userId 用户ID + * @return 文件信息 + */ + AppFileVo checkFileExists(String fileMd5, Long userId); + + /** + * 更新文件公开状态 + * + * @param fileId 文件ID + * @param isPublic 是否公开 + * @param userId 用户ID + * @return 更新结果 + */ + Boolean updateFilePublicStatus(Long fileId, String isPublic, Long userId); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppMessageService.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppMessageService.java new file mode 100644 index 0000000..0796fc7 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppMessageService.java @@ -0,0 +1,162 @@ +package org.dromara.member.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppMessage; +import org.dromara.member.domain.bo.AppMessageBo; +import org.dromara.member.domain.vo.AppMessageVo; + +import java.util.Collection; +import java.util.List; + +/** + * App端消息Service接口 + * + * @author pengles + */ +public interface AppMessageService extends IService { + + /** + * 查询App端消息 + * + * @param messageId 消息ID + * @return App端消息 + */ + AppMessageVo queryById(Long messageId); + + /** + * 查询App端消息列表 + * + * @param bo App端消息 + * @param pageQuery 分页查询条件 + * @return App端消息集合 + */ + TableDataInfo queryPageList(AppMessageBo bo, PageQuery pageQuery); + + /** + * 查询App端消息列表 + * + * @param bo App端消息 + * @return App端消息集合 + */ + List queryList(AppMessageBo bo); + + /** + * 新增App端消息 + * + * @param bo App端消息 + * @return 结果 + */ + Boolean insertByBo(AppMessageBo bo); + + /** + * 修改App端消息 + * + * @param bo App端消息 + * @return 结果 + */ + Boolean updateByBo(AppMessageBo bo); + + /** + * 校验并批量删除App端消息 + * + * @param ids 主键集合 + * @param isValid 是否校验,true-删除前校验,false-不校验 + * @return 结果 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 获取用户未读消息数量 + * + * @param userId 用户ID + * @return 未读消息数量 + */ + Long getUnreadCount(Long userId); + + /** + * 标记消息为已读 + * + * @param messageId 消息ID + * @param userId 用户ID + * @return 结果 + */ + Boolean markAsRead(Long messageId, Long userId); + + /** + * 批量标记消息为已读 + * + * @param messageIds 消息ID列表 + * @param userId 用户ID + * @return 结果 + */ + Boolean markAsReadBatch(List messageIds, Long userId); + + /** + * 标记所有消息为已读 + * + * @param userId 用户ID + * @return 结果 + */ + Boolean markAllAsRead(Long userId); + + /** + * 发送系统消息 + * + * @param bo 消息信息 + * @return 结果 + */ + Boolean sendSystemMessage(AppMessageBo bo); + + /** + * 发送个人消息 + * + * @param bo 消息信息 + * @return 结果 + */ + Boolean sendPersonalMessage(AppMessageBo bo); + + /** + * 推送消息到指定用户 + * + * @param bo 消息信息 + * @return 结果 + */ + Boolean pushMessage(AppMessageBo bo); + + /** + * 群发消息 + * + * @param bo 消息信息 + * @return 结果 + */ + Boolean broadcastMessage(AppMessageBo bo); + + /** + * 发送推送通知 + * + * @param userId 用户ID + * @param title 标题 + * @param content 内容 + * @return 结果 + */ + Boolean sendPushNotification(Long userId, String title, String content); + + /** + * 批量发送推送通知 + * + * @param userIds 用户ID列表 + * @param title 标题 + * @param content 内容 + * @return 结果 + */ + Boolean sendBatchPushNotification(List userIds, String title, String content); + + /** + * 清理过期消息 + * + * @return 清理数量 + */ + Integer cleanExpiredMessages(); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppOperLogService.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppOperLogService.java new file mode 100644 index 0000000..98135c8 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppOperLogService.java @@ -0,0 +1,141 @@ +package org.dromara.member.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.bo.AppOperLogBo; +import org.dromara.member.domain.vo.AppOperLogVo; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * App端操作日志Service接口 + * + * @author pengles + */ +public interface AppOperLogService { + + /** + * 查询App端操作日志 + */ + AppOperLogVo queryById(Long operId); + + /** + * 查询App端操作日志列表 + */ + TableDataInfo queryPageList(AppOperLogBo bo, PageQuery pageQuery); + + /** + * 查询App端操作日志列表 + */ + List queryList(AppOperLogBo bo); + + /** + * 新增App端操作日志 + */ + Boolean insertByBo(AppOperLogBo bo); + + /** + * 修改App端操作日志 + */ + Boolean updateByBo(AppOperLogBo bo); + + /** + * 校验并批量删除App端操作日志信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 记录操作日志 + * + * @param title 模块标题 + * @param businessType 业务类型 + * @param method 方法名称 + * @param requestMethod 请求方式 + * @param operName 操作人员 + * @param operUrl 请求URL + * @param operIp 主机地址 + * @param operLocation 操作地点 + * @param operParam 请求参数 + * @param jsonResult 返回参数 + * @param status 操作状态 + * @param errorMsg 错误消息 + * @param costTime 消耗时间 + */ + void recordOperLog(String title, Integer businessType, String method, String requestMethod, + String operName, String operUrl, String operIp, String operLocation, + String operParam, String jsonResult, Integer status, String errorMsg, Long costTime); + + /** + * 根据操作人查询操作日志 + * + * @param operName 操作人员 + * @return 操作日志列表 + */ + List queryByOperName(String operName); + + /** + * 清空操作日志 + */ + void cleanOperLog(); + + /** + * 根据时间范围删除操作日志 + * + * @param beginTime 开始时间 + * @param endTime 结束时间 + */ + void deleteOperLogByTime(LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取操作统计信息 + * + * @param operName 操作人员 + * @param beginTime 开始时间 + * @param endTime 结束时间 + * @return 统计信息 + */ + Long getOperLogCount(String operName, LocalDateTime beginTime, LocalDateTime endTime); + + /** + * 获取用户行为分析数据 + * + * @param operName 操作人员 + * @param days 统计天数 + * @return 行为分析数据 + */ + List getUserBehaviorAnalysis(String operName, Integer days); + + /** + * 获取热门操作统计 + * + * @param days 统计天数 + * @param limit 返回数量限制 + * @return 热门操作列表 + */ + List getPopularOperations(Integer days, Integer limit); + + /** + * 获取错误操作统计 + * + * @param days 统计天数 + * @return 错误操作列表 + */ + List getErrorOperations(Integer days); + + /** + * 批量记录操作日志 + * + * @param operLogs 操作日志列表 + */ + void batchInsertOperLog(List operLogs); + + /** + * 异步记录操作日志 + * + * @param bo 操作日志业务对象 + */ + void asyncRecordOperLog(AppOperLogBo bo); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppRoleService.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppRoleService.java new file mode 100644 index 0000000..b83d9e2 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppRoleService.java @@ -0,0 +1,169 @@ +package org.dromara.member.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppRole; +import org.dromara.member.domain.bo.AppRoleBo; +import org.dromara.member.domain.vo.AppRoleVo; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +/** + * App端角色信息Service接口 + * + * @author pengles + */ +public interface AppRoleService extends IService { + + /** + * 查询App端角色信息 + * + * @param roleId App角色ID + * @return App端角色信息 + */ + AppRoleVo queryById(Long roleId); + + /** + * 查询App端角色信息列表 + * + * @param bo App端角色信息 + * @param pageQuery 分页查询条件 + * @return App端角色信息集合 + */ + TableDataInfo queryPageList(AppRoleBo bo, PageQuery pageQuery); + + /** + * 查询App端角色信息列表 + * + * @param bo App端角色信息 + * @return App端角色信息集合 + */ + List queryList(AppRoleBo bo); + + /** + * 新增App端角色信息 + * + * @param bo App端角色信息 + * @return 结果 + */ + Boolean insertByBo(AppRoleBo bo); + + /** + * 修改App端角色信息 + * + * @param bo App端角色信息 + * @return 结果 + */ + Boolean updateByBo(AppRoleBo bo); + + /** + * 校验并批量删除App端角色信息 + * + * @param ids 主键集合 + * @param isValid 是否校验,true-删除前校验,false-不校验 + * @return 结果 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据用户ID查询角色 + * + * @param userId 用户ID + * @return 角色列表 + */ + List selectRolesByUserId(Long userId); + + /** + * 根据用户ID获取角色权限 + * + * @param userId 用户ID + * @return 权限列表 + */ + Set selectRolePermissionByUserId(Long userId); + + /** + * 查询所有角色 + * + * @return 角色列表 + */ + List selectRoleAll(); + + /** + * 根据用户ID获取角色选择框列表 + * + * @param userId 用户ID + * @return 选中角色ID列表 + */ + List selectRoleListByUserId(Long userId); + + /** + * 校验角色名称是否唯一 + * + * @param roleName 角色名称 + * @return 结果 + */ + boolean checkRoleNameUnique(String roleName); + + /** + * 校验角色权限是否唯一 + * + * @param roleKey 角色权限 + * @return 结果 + */ + boolean checkRoleKeyUnique(String roleKey); + + /** + * 校验角色是否允许操作 + * + * @param roleId 角色ID + * @return 结果 + */ + boolean checkRoleAllowed(Long roleId); + + /** + * 校验角色是否有数据权限 + * + * @param roleId 角色ID + * @return 结果 + */ + boolean checkRoleDataScope(Long roleId); + + /** + * 通过角色ID查询角色使用数量 + * + * @param roleId 角色ID + * @return 结果 + */ + long countUserRoleByRoleId(Long roleId); + + /** + * 取消授权用户角色 + * + * @param userId 用户ID + * @param roleId 角色ID + * @return 结果 + */ + boolean deleteAuthUser(Long userId, Long roleId); + + /** + * 批量取消授权用户角色 + * + * @param roleId 角色ID + * @param userIds 需要取消授权的用户数据ID + * @return 结果 + */ + boolean deleteAuthUsers(Long roleId, Long[] userIds); + + /** + * 批量选择授权用户角色 + * + * @param roleId 角色ID + * @param userIds 需要授权的用户数据ID + * @return 结果 + */ + boolean insertAuthUsers(Long roleId, Long[] userIds); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppSocialAuthService.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppSocialAuthService.java new file mode 100644 index 0000000..bae3045 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppSocialAuthService.java @@ -0,0 +1,129 @@ +package org.dromara.member.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import me.zhyd.oauth.model.AuthUser; +import org.dromara.member.domain.AppSocialAuth; +import org.dromara.member.domain.vo.AppSocialAuthVo; +import org.dromara.member.domain.vo.AppUserVo; + +import java.util.List; + +/** + * App端第三方登录授权Service接口 + * + * @author pengles + */ +public interface AppSocialAuthService extends IService { + + /** + * 查询App端第三方登录授权 + * + * @param id 主键 + * @return App端第三方登录授权 + */ + AppSocialAuthVo queryById(Long id); + + /** + * 根据用户ID查询社交认证列表 + * + * @param userId 用户ID + * @return 社交认证列表 + */ + List queryByUserId(Long userId); + + /** + * 根据平台和openId查询社交认证 + * + * @param platform 平台 + * @param openId openId + * @return 社交认证信息 + */ + AppSocialAuth queryByPlatformAndOpenId(String platform, String openId); + + /** + * 根据平台和unionId查询社交认证 + * + * @param platform 平台 + * @param unionId unionId + * @return 社交认证信息 + */ + AppSocialAuth queryByPlatformAndUnionId(String platform, String unionId); + + /** + * 新增App端第三方登录授权 + * + * @param appSocialAuth App端第三方登录授权 + * @return 结果 + */ + Boolean insertAppSocialAuth(AppSocialAuth appSocialAuth); + + /** + * 修改App端第三方登录授权 + * + * @param appSocialAuth App端第三方登录授权 + * @return 结果 + */ + Boolean updateAppSocialAuth(AppSocialAuth appSocialAuth); + + /** + * 删除App端第三方登录授权 + * + * @param id 主键 + * @return 结果 + */ + Boolean deleteAppSocialAuthById(Long id); + + /** + * 根据用户ID删除社交认证 + * + * @param userId 用户ID + * @return 结果 + */ + Boolean deleteByUserId(Long userId); + + /** + * 更新访问令牌 + * + * @param id 主键 + * @param accessToken 访问令牌 + * @param expireIn 过期时间 + * @param refreshToken 刷新令牌 + * @return 结果 + */ + Boolean updateAccessToken(Long id, String accessToken, Integer expireIn, String refreshToken); + + /** + * 根据用户ID和平台删除绑定关系 + * + * @param userId 用户ID + * @param platform 第三方平台 + * @return 是否成功 + */ + Boolean deleteByUserIdAndPlatform(Long userId, String platform); + + /** + * 根据平台和openId查询绑定的用户信息 + * + * @param platform 平台 + * @param openId openId + * @return 用户信息 + */ + AppUserVo selectUserByPlatformAndOpenId(String platform, String openId); + + /** + * 根据平台和unionId查询绑定的用户信息 + * + * @param platform 平台 + * @param unionId unionId + * @return 用户信息 + */ + AppUserVo selectUserByPlatformAndUnionId(String platform, String unionId); + + /** + * 绑定第三方用户 + * + * @param authUserData 授权响应实体 + */ + void socialRegister(AuthUser authUserData); + +} diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppUserService.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppUserService.java new file mode 100644 index 0000000..3df885f --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppUserService.java @@ -0,0 +1,155 @@ +package org.dromara.member.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppUser; +import org.dromara.member.domain.bo.AppUserBo; +import org.dromara.member.domain.vo.AppUserVo; + +import java.util.Collection; +import java.util.List; + +/** + * App端用户信息Service接口 + * + * @author pengles + */ +public interface AppUserService extends IService { + + /** + * 查询App端用户信息 + * + * @param userId App用户ID + * @return App端用户信息 + */ + AppUserVo queryById(Long userId); + + /** + * 查询App端用户信息列表 + * + * @param bo App端用户信息 + * @param pageQuery 分页查询条件 + * @return App端用户信息集合 + */ + TableDataInfo queryPageList(AppUserBo bo, PageQuery pageQuery); + + /** + * 查询App端用户信息列表 + * + * @param bo App端用户信息 + * @return App端用户信息集合 + */ + List queryList(AppUserBo bo); + + /** + * 新增App端用户信息 + * + * @param bo App端用户信息 + * @return 结果 + */ + Boolean insertByBo(AppUserBo bo); + + /** + * 修改App端用户信息 + * + * @param bo App端用户信息 + * @return 结果 + */ + Boolean updateByBo(AppUserBo bo); + + /** + * 校验并批量删除App端用户信息 + * + * @param ids 主键集合 + * @param isValid 是否校验,true-删除前校验,false-不校验 + * @return 结果 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 根据用户账号查询用户 + * + * @param userName 用户账号 + * @return 用户信息 + */ + AppUserVo selectUserByUserName(String userName); + + /** + * 根据手机号查询用户信息 + * + * @param phone 手机号 + * @return 用户信息 + */ + AppUserVo selectUserByPhone(String phone); + + /** + * 根据邮箱查询用户信息 + * + * @param email 邮箱 + * @return 用户信息 + */ + AppUserVo selectUserByEmail(String email); + + /** + * 校验用户账号是否唯一 + * + * @param userName 用户账号 + * @return 结果 + */ + boolean checkUserNameUnique(String userName); + + /** + * 校验手机号码是否唯一 + * + * @param phone 手机号码 + * @return 结果 + */ + boolean checkPhoneUnique(String phone); + + /** + * 校验email是否唯一 + * + * @param email 用户邮箱 + * @return 结果 + */ + boolean checkEmailUnique(String email); + + /** + * 用户注册 + * + * @param bo 用户信息 + * @return 结果 + */ + Boolean register(AppUserBo bo); + + /** + * 修改用户密码 + * + * @param userId 用户ID + * @param oldPassword 旧密码 + * @param newPassword 新密码 + * @return 结果 + */ + Boolean updatePassword(Long userId, String oldPassword, String newPassword); + + /** + * 重置用户密码 + * + * @param userId 用户ID + * @param newPassword 新密码 + * @return 结果 + */ + Boolean resetPassword(Long userId, String newPassword); + + /** + * 修改用户状态 + * + * @param userId 用户ID + * @param status 状态 + * @return 结果 + */ + Boolean updateUserStatus(Long userId, String status); + + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppVersionService.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppVersionService.java new file mode 100644 index 0000000..6549d43 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/AppVersionService.java @@ -0,0 +1,88 @@ +package org.dromara.member.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.bo.AppVersionBo; +import org.dromara.member.domain.vo.AppVersionVo; + +import java.util.Collection; +import java.util.List; + +/** + * App版本信息Service接口 + * + * @author pengles + */ +public interface AppVersionService { + + /** + * 查询App版本信息 + */ + AppVersionVo queryById(Long versionId); + + /** + * 查询App版本信息列表 + */ + TableDataInfo queryPageList(AppVersionBo bo, PageQuery pageQuery); + + /** + * 查询App版本信息列表 + */ + List queryList(AppVersionBo bo); + + /** + * 新增App版本信息 + */ + Boolean insertByBo(AppVersionBo bo); + + /** + * 修改App版本信息 + */ + Boolean updateByBo(AppVersionBo bo); + + /** + * 校验并批量删除App版本信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 检查版本更新 + */ + AppVersionVo checkUpdate(String platform, Integer currentVersionCode); + + /** + * 获取指定平台的最新版本 + */ + AppVersionVo getLatestVersion(String platform); + + /** + * 检查版本号是否唯一 + */ + Boolean checkVersionCodeUnique(AppVersionBo bo); + + /** + * 获取版本历史列表 + */ + List getVersionHistory(String platform, Integer limit); + + /** + * 检查是否需要强制更新 + */ + Boolean checkForceUpdate(String platform, Integer currentVersionCode); + + /** + * 获取版本更新信息 + */ + AppVersionVo getUpdateInfo(String platform, Integer currentVersionCode); + + /** + * 批量发布版本 + */ + Boolean batchPublish(List versionIds); + + /** + * 撤回版本 + */ + Boolean withdrawVersion(Long versionId); + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppDeviceServiceImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppDeviceServiceImpl.java new file mode 100644 index 0000000..ab0165a --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppDeviceServiceImpl.java @@ -0,0 +1,333 @@ +package org.dromara.member.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppDevice; +import org.dromara.member.domain.bo.AppDeviceBo; +import org.dromara.member.domain.vo.AppDeviceVo; +import org.dromara.member.exception.DeviceException; +import org.dromara.member.mapper.AppDeviceMapper; +import org.dromara.member.service.AppDeviceService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * App端设备信息Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class AppDeviceServiceImpl extends ServiceImpl implements AppDeviceService { + + private final AppDeviceMapper baseMapper; + + /** + * 查询App端设备信息 + */ + @Override + public AppDeviceVo queryById(Long deviceId) { + return baseMapper.selectVoById(deviceId); + } + + /** + * 查询App端设备信息列表 + */ + @Override + public TableDataInfo queryPageList(AppDeviceBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询App端设备信息列表 + */ + @Override + public List queryList(AppDeviceBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(AppDeviceBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getUserId() != null, AppDevice::getUserId, bo.getUserId()); + lqw.eq(StringUtils.isNotBlank(bo.getDeviceType()), AppDevice::getDeviceType, bo.getDeviceType()); + lqw.like(StringUtils.isNotBlank(bo.getDeviceName()), AppDevice::getDeviceName, bo.getDeviceName()); + lqw.like(StringUtils.isNotBlank(bo.getDeviceModel()), AppDevice::getDeviceModel, bo.getDeviceModel()); + lqw.eq(StringUtils.isNotBlank(bo.getDeviceBrand()), AppDevice::getDeviceBrand, bo.getDeviceBrand()); + lqw.eq(StringUtils.isNotBlank(bo.getOsVersion()), AppDevice::getOsVersion, bo.getOsVersion()); + lqw.eq(StringUtils.isNotBlank(bo.getAppVersion()), AppDevice::getAppVersion, bo.getAppVersion()); + lqw.eq(StringUtils.isNotBlank(bo.getIsActive()), AppDevice::getIsActive, bo.getIsActive()); + lqw.orderByDesc(AppDevice::getLastActiveTime); + return lqw; + } + + /** + * 新增App端设备信息 + */ + @Override + public Boolean insertByBo(AppDeviceBo bo) { + AppDevice add = BeanUtil.toBean(bo, AppDevice.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setDeviceId(add.getDeviceId()); + } + return flag; + } + + /** + * 修改App端设备信息 + */ + @Override + public Boolean updateByBo(AppDeviceBo bo) { + AppDevice update = BeanUtil.toBean(bo, AppDevice.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(AppDevice entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除App端设备信息 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据用户ID查询设备列表 + */ + @Override + public List queryByUserId(Long userId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppDevice::getUserId, userId); + lqw.orderByDesc(AppDevice::getLastActiveTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 绑定设备 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean bindDevice(AppDeviceBo bo) { + // 检查用户设备数量限制 + LambdaQueryWrapper countLqw = Wrappers.lambdaQuery(); + countLqw.eq(AppDevice::getUserId, bo.getUserId()); + countLqw.eq(AppDevice::getIsActive, "1"); + long deviceCount = baseMapper.selectCount(countLqw); + if (deviceCount >= 10) { // 限制每个用户最多绑定10台设备 + throw DeviceException.deviceLimitExceeded(10); + } + + // 检查是否已存在相同设备 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppDevice::getUserId, bo.getUserId()); + lqw.eq(AppDevice::getDeviceType, bo.getDeviceType()); + lqw.eq(StringUtils.isNotBlank(bo.getPushToken()), AppDevice::getPushToken, bo.getPushToken()); + + AppDevice existDevice = baseMapper.selectOne(lqw); + if (existDevice != null) { + // 更新现有设备信息 + existDevice.setDeviceName(bo.getDeviceName()); + existDevice.setDeviceModel(bo.getDeviceModel()); + existDevice.setDeviceBrand(bo.getDeviceBrand()); + existDevice.setOsVersion(bo.getOsVersion()); + existDevice.setAppVersion(bo.getAppVersion()); + existDevice.setIsActive("1"); + existDevice.setLastActiveTime(LocalDateTime.now()); + boolean result = baseMapper.updateById(existDevice) > 0; + if (!result) { + throw DeviceException.bindFailed("设备信息更新失败"); + } + return result; + } else { + // 新增设备 + bo.setIsActive("1"); + bo.setLastActiveTime(LocalDateTime.now()); + boolean result = insertByBo(bo); + if (!result) { + throw DeviceException.bindFailed("设备信息保存失败"); + } + return result; + } + } + + /** + * 解绑设备 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean unbindDevice(Long deviceId, Long userId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppDevice::getDeviceId, deviceId); + lqw.eq(AppDevice::getUserId, userId); + + AppDevice device = baseMapper.selectOne(lqw); + if (device == null) { + throw DeviceException.deviceNotFound(deviceId); + } + + boolean result = baseMapper.deleteById(deviceId) > 0; + if (!result) { + throw DeviceException.unbindFailed("设备删除失败"); + } + return result; + } + + /** + * 更新设备活跃状态 + */ + @Override + public Boolean updateActiveStatus(Long deviceId, String isActive) { + if (!"0".equals(isActive) && !"1".equals(isActive)) { + throw DeviceException.invalidStatus(isActive); + } + + AppDevice existDevice = baseMapper.selectById(deviceId); + if (existDevice == null) { + throw DeviceException.deviceNotFound(deviceId); + } + + AppDevice device = new AppDevice(); + device.setDeviceId(deviceId); + device.setIsActive(isActive); + if ("1".equals(isActive)) { + device.setLastActiveTime(LocalDateTime.now()); + } + return baseMapper.updateById(device) > 0; + } + + /** + * 更新推送令牌 + */ + @Override + public Boolean updatePushToken(Long deviceId, String pushToken) { + if (StringUtils.isBlank(pushToken)) { + throw DeviceException.invalidPushToken(pushToken); + } + + AppDevice existDevice = baseMapper.selectById(deviceId); + if (existDevice == null) { + throw DeviceException.deviceNotFound(deviceId); + } + + AppDevice device = new AppDevice(); + device.setDeviceId(deviceId); + device.setPushToken(pushToken); + device.setLastActiveTime(LocalDateTime.now()); + return baseMapper.updateById(device) > 0; + } + + /** + * 获取用户活跃设备 + */ + @Override + public List getActiveDevices(Long userId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppDevice::getUserId, userId); + lqw.eq(AppDevice::getIsActive, "1"); + lqw.orderByDesc(AppDevice::getLastActiveTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 清理非活跃设备 + */ + @Override + public Integer cleanInactiveDevices(Integer days) { + if (days == null || days <= 0) { + days = 30; // 默认30天 + } + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.lt(AppDevice::getLastActiveTime, LocalDateTime.now().minusDays(days)); + lqw.or(); + lqw.eq(AppDevice::getIsActive, "0"); + + List inactiveDevices = baseMapper.selectList(lqw); + if (!inactiveDevices.isEmpty()) { + List ids = inactiveDevices.stream().map(AppDevice::getDeviceId).toList(); + baseMapper.deleteBatchIds(ids); + log.info("清理非活跃设备 {} 个", ids.size()); + return ids.size(); + } + + return 0; + } + + /** + * 根据推送令牌查询设备 + */ + @Override + public AppDeviceVo queryByPushToken(String pushToken) { + if (StringUtils.isBlank(pushToken)) { + return null; + } + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppDevice::getPushToken, pushToken); + lqw.eq(AppDevice::getIsActive, "1"); + return baseMapper.selectVoOne(lqw); + } + + /** + * 设备心跳更新 + */ + @Override + public Boolean heartbeat(Long deviceId) { + AppDevice device = new AppDevice(); + device.setDeviceId(deviceId); + device.setIsActive("1"); + device.setLastActiveTime(LocalDateTime.now()); + return baseMapper.updateById(device) > 0; + } + + /** + * 批量更新设备状态 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean batchUpdateStatus(List deviceIds, String isActive) { + if (ObjectUtil.isEmpty(deviceIds)) { + return false; + } + + AppDevice device = new AppDevice(); + device.setIsActive(isActive); + if ("1".equals(isActive)) { + device.setLastActiveTime(LocalDateTime.now()); + } + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.in(AppDevice::getDeviceId, deviceIds); + + return baseMapper.update(device, lqw) > 0; + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppFileServiceImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppFileServiceImpl.java new file mode 100644 index 0000000..ec87ca1 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppFileServiceImpl.java @@ -0,0 +1,420 @@ +package org.dromara.member.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.oss.core.OssClient; +import org.dromara.common.oss.entity.UploadResult; +import org.dromara.common.oss.factory.OssFactory; +import org.dromara.member.domain.AppFile; +import org.dromara.member.domain.bo.AppFileBo; +import org.dromara.member.domain.vo.AppFileVo; +import org.dromara.member.exception.FileException; +import org.dromara.member.mapper.AppFileMapper; +import org.dromara.member.service.AppFileService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * App端文件信息Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class AppFileServiceImpl implements AppFileService { + + private final AppFileMapper baseMapper; + + /** + * 查询App端文件信息 + */ + @Override + public AppFileVo queryById(Long fileId) { + return baseMapper.selectVoById(fileId); + } + + /** + * 查询App端文件信息列表 + */ + @Override + public TableDataInfo queryPageList(AppFileBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询App端文件信息列表 + */ + @Override + public List queryList(AppFileBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(AppFileBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getUserId() != null, AppFile::getUserId, bo.getUserId()); + lqw.like(StringUtils.isNotBlank(bo.getFileName()), AppFile::getFileName, bo.getFileName()); + lqw.like(StringUtils.isNotBlank(bo.getOriginalName()), AppFile::getOriginalName, bo.getOriginalName()); + lqw.eq(StringUtils.isNotBlank(bo.getFileType()), AppFile::getFileType, bo.getFileType()); + lqw.eq(StringUtils.isNotBlank(bo.getFileSuffix()), AppFile::getFileSuffix, bo.getFileSuffix()); + lqw.eq(bo.getFileCategory() != null, AppFile::getFileCategory, bo.getFileCategory()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), AppFile::getStatus, bo.getStatus()); + lqw.eq(StringUtils.isNotBlank(bo.getIsPublic()), AppFile::getIsPublic, bo.getIsPublic()); + lqw.between(params.get("beginTime") != null && params.get("endTime") != null, + AppFile::getCreateTime, params.get("beginTime"), params.get("endTime")); + lqw.orderByDesc(AppFile::getCreateTime); + return lqw; + } + + /** + * 新增App端文件信息 + */ + @Override + public Boolean insertByBo(AppFileBo bo) { + AppFile add = BeanUtil.toBean(bo, AppFile.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setFileId(add.getFileId()); + } + return flag; + } + + /** + * 修改App端文件信息 + */ + @Override + public Boolean updateByBo(AppFileBo bo) { + AppFile update = BeanUtil.toBean(bo, AppFile.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(AppFile entity) { + // 可以添加一些业务校验逻辑 + } + + /** + * 批量删除App端文件信息 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 可以添加删除前的校验逻辑 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 上传文件 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public AppFileVo uploadFile(MultipartFile file, Long userId, Integer fileCategory) { + if (file.isEmpty()) { + throw FileException.fileEmpty(); + } + + // 检查文件大小(限制为100MB) + if (file.getSize() > 100 * 1024 * 1024) { + throw FileException.fileSizeExceeded(100); + } + + try { + // 计算文件MD5 + String fileMd5 = DigestUtil.md5Hex(file.getInputStream()); + + // 检查文件是否已存在 + AppFileVo existFile = checkFileExists(fileMd5, userId); + if (existFile != null) { + return existFile; + } + + // 上传到OSS + OssClient ossClient = OssFactory.instance(); + UploadResult uploadResult = ossClient.uploadSuffix(file.getBytes(), getFileSuffix(file.getOriginalFilename()), file.getContentType()); + + // 保存文件信息 + AppFile appFile = new AppFile(); + appFile.setUserId(userId); + appFile.setFileName(uploadResult.getFilename()); + appFile.setOriginalName(file.getOriginalFilename()); + appFile.setFilePath(uploadResult.getFilename()); + appFile.setFileUrl(uploadResult.getUrl()); + appFile.setFileSize(file.getSize()); + appFile.setFileType(file.getContentType()); + appFile.setFileSuffix(getFileSuffix(file.getOriginalFilename())); + appFile.setFileCategory(fileCategory != null ? fileCategory : 6); // 默认为其他 + appFile.setStorageService(ossClient.getConfigKey()); + appFile.setStatus("0"); // 正常 + appFile.setDownloadCount(0L); + appFile.setIsPublic("0"); // 默认私有 + appFile.setFileMd5(fileMd5); + + baseMapper.insert(appFile); + return MapstructUtils.convert(appFile, AppFileVo.class); + + } catch (IOException e) { + log.error("文件上传失败", e); + throw FileException.uploadFailed(e.getMessage()); + } + } + + /** + * 上传头像 + */ + @Override + public AppFileVo uploadAvatar(MultipartFile file, Long userId) { + // 检查文件类型 + String contentType = file.getContentType(); + if (!isImageFile(contentType)) { + throw FileException.unsupportedFileType(contentType); + } + + // 检查文件大小(限制为5MB) + if (file.getSize() > 5 * 1024 * 1024) { + throw FileException.fileSizeExceeded(5); + } + + return uploadFile(file, userId, 1); // 头像分类 + } + + /** + * 批量上传文件 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public List batchUploadFiles(MultipartFile[] files, Long userId, Integer fileCategory) { + List result = new ArrayList<>(); + for (MultipartFile file : files) { + try { + AppFileVo fileVo = uploadFile(file, userId, fileCategory); + result.add(fileVo); + } catch (Exception e) { + log.error("批量上传文件失败: {}", file.getOriginalFilename(), e); + // 继续处理其他文件 + } + } + return result; + } + + /** + * 根据用户ID查询文件列表 + */ + @Override + public List queryByUserId(Long userId, Integer fileCategory) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppFile::getUserId, userId); + lqw.eq(fileCategory != null, AppFile::getFileCategory, fileCategory); + lqw.eq(AppFile::getStatus, "0"); // 正常状态 + lqw.orderByDesc(AppFile::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 根据文件分类查询文件列表 + */ + @Override + public List queryByCategory(Integer fileCategory) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppFile::getFileCategory, fileCategory); + lqw.eq(AppFile::getStatus, "0"); // 正常状态 + lqw.eq(AppFile::getIsPublic, "1"); // 公开文件 + lqw.orderByDesc(AppFile::getCreateTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 下载文件 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public AppFileVo downloadFile(Long fileId, Long userId) { + AppFile appFile = baseMapper.selectById(fileId); + if (appFile == null) { + throw FileException.fileNotFound(fileId); + } + + // 检查权限 + if (!appFile.getUserId().equals(userId) && !"1".equals(appFile.getIsPublic())) { + throw FileException.accessDenied(fileId); + } + + // 更新下载次数 + appFile.setDownloadCount(appFile.getDownloadCount() + 1); + baseMapper.updateById(appFile); + + return MapstructUtils.convert(appFile, AppFileVo.class); + } + + /** + * 删除用户文件 + */ + @Override + public Boolean deleteUserFile(Long fileId, Long userId) { + AppFile appFile = baseMapper.selectById(fileId); + if (appFile == null) { + throw FileException.fileNotFound(fileId); + } + + if (!appFile.getUserId().equals(userId)) { + throw FileException.accessDenied(fileId); + } + + return baseMapper.deleteById(fileId) > 0; + } + + /** + * 批量删除用户文件 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean batchDeleteUserFiles(Collection fileIds, Long userId) { + for (Long fileId : fileIds) { + deleteUserFile(fileId, userId); + } + return true; + } + + /** + * 更新文件状态 + */ + @Override + public Boolean updateFileStatus(Long fileId, String status, Long userId) { + AppFile appFile = baseMapper.selectById(fileId); + if (appFile == null) { + throw new ServiceException("文件不存在"); + } + + if (!appFile.getUserId().equals(userId)) { + throw new ServiceException("无权限修改该文件"); + } + + appFile.setStatus(status); + return baseMapper.updateById(appFile) > 0; + } + + /** + * 获取用户文件统计信息 + */ + @Override + public AppFileVo getUserFileStatistics(Long userId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppFile::getUserId, userId); + lqw.eq(AppFile::getStatus, "0"); + + List files = baseMapper.selectList(lqw); + + AppFileVo statistics = new AppFileVo(); + statistics.setUserId(userId); + + if (!files.isEmpty()) { + long totalSize = files.stream().mapToLong(AppFile::getFileSize).sum(); + long totalDownloads = files.stream().mapToLong(AppFile::getDownloadCount).sum(); + + statistics.setFileSize(totalSize); + statistics.setDownloadCount(totalDownloads); + } + + return statistics; + } + + /** + * 清理过期文件 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Integer cleanExpiredFiles(Integer days) { + LocalDateTime expiredTime = LocalDateTime.now().minusDays(days); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.lt(AppFile::getCreateTime, expiredTime); + lqw.eq(AppFile::getStatus, "1"); // 已删除状态 + + List expiredFiles = baseMapper.selectList(lqw); + if (!expiredFiles.isEmpty()) { + List fileIds = expiredFiles.stream().map(AppFile::getFileId).collect(Collectors.toList()); + baseMapper.deleteBatchIds(fileIds); + } + + return expiredFiles.size(); + } + + /** + * 检查文件是否存在 + */ + @Override + public AppFileVo checkFileExists(String fileMd5, Long userId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppFile::getFileMd5, fileMd5); + lqw.eq(AppFile::getUserId, userId); + lqw.eq(AppFile::getStatus, "0"); + + AppFile existFile = baseMapper.selectOne(lqw); + return existFile != null ? MapstructUtils.convert(existFile, AppFileVo.class) : null; + } + + /** + * 更新文件公开状态 + */ + @Override + public Boolean updateFilePublicStatus(Long fileId, String isPublic, Long userId) { + AppFile appFile = baseMapper.selectById(fileId); + if (appFile == null) { + throw new ServiceException("文件不存在"); + } + + if (!appFile.getUserId().equals(userId)) { + throw new ServiceException("无权限修改该文件"); + } + + appFile.setIsPublic(isPublic); + return baseMapper.updateById(appFile) > 0; + } + + /** + * 获取文件后缀 + */ + private String getFileSuffix(String fileName) { + if (StringUtils.isBlank(fileName)) { + return ""; + } + int lastDotIndex = fileName.lastIndexOf("."); + return lastDotIndex > 0 ? fileName.substring(lastDotIndex) : ""; + } + + /** + * 检查是否为图片文件 + */ + private boolean isImageFile(String contentType) { + return StringUtils.isNotBlank(contentType) && + (contentType.startsWith("image/") || + contentType.equals("application/octet-stream")); + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppMessageServiceImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppMessageServiceImpl.java new file mode 100644 index 0000000..f74de06 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppMessageServiceImpl.java @@ -0,0 +1,310 @@ +package org.dromara.member.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppMessage; +import org.dromara.member.domain.bo.AppMessageBo; +import org.dromara.member.domain.vo.AppMessageVo; +import org.dromara.member.mapper.AppMessageMapper; +import org.dromara.member.service.AppMessageService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * App端消息Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class AppMessageServiceImpl extends ServiceImpl implements AppMessageService { + + private final AppMessageMapper baseMapper; + + /** + * 查询App端消息 + */ + @Override + public AppMessageVo queryById(Long messageId) { + return baseMapper.selectVoById(messageId); + } + + /** + * 查询App端消息列表 + */ + @Override + public TableDataInfo queryPageList(AppMessageBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询App端消息列表 + */ + @Override + public List queryList(AppMessageBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(AppMessageBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getTitle()), AppMessage::getTitle, bo.getTitle()); + lqw.like(StringUtils.isNotBlank(bo.getContent()), AppMessage::getContent, bo.getContent()); + lqw.eq(StringUtils.isNotBlank(bo.getMessageType()), AppMessage::getMessageType, bo.getMessageType()); + lqw.eq(StringUtils.isNotBlank(bo.getTargetType()), AppMessage::getTargetType, bo.getTargetType()); + lqw.like(StringUtils.isNotBlank(bo.getTargetIds()), AppMessage::getTargetIds, bo.getTargetIds()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), AppMessage::getStatus, bo.getStatus()); + + // 如果指定了接收者ID,查询该用户相关的消息 + if (ObjectUtil.isNotNull(bo.getReceiverId())) { + lqw.and(wrapper -> wrapper.eq(AppMessage::getTargetType, "all") + .or() + .and(subWrapper -> subWrapper.eq(AppMessage::getTargetType, "user") + .like(AppMessage::getTargetIds, bo.getReceiverId().toString()))); + } + + lqw.orderByDesc(AppMessage::getCreateTime); + return lqw; + } + + /** + * 新增App端消息 + */ + @Override + public Boolean insertByBo(AppMessageBo bo) { + AppMessage add = BeanUtil.toBean(bo, AppMessage.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setMessageId(add.getMessageId()); + } + return flag; + } + + /** + * 修改App端消息 + */ + @Override + public Boolean updateByBo(AppMessageBo bo) { + AppMessage update = BeanUtil.toBean(bo, AppMessage.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(AppMessage entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除App端消息 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 获取用户未读消息数量 + */ + @Override + public Long getUnreadCount(Long userId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.and(wrapper -> wrapper.eq(AppMessage::getTargetType, "user") + .like(AppMessage::getTargetIds, userId.toString()) + .or() + .eq(AppMessage::getTargetType, "all")); + lqw.eq(AppMessage::getStatus, "1"); // 已发送 + return baseMapper.selectCount(lqw); + } + + /** + * 标记消息为已读 + */ + @Override + public Boolean markAsRead(Long messageId, Long userId) { + // 由于当前表结构不支持已读状态,这里只是示例实现 + // 实际项目中可能需要单独的消息阅读记录表 + log.info("用户 {} 标记消息 {} 为已读", userId, messageId); + return true; + } + + /** + * 批量标记消息为已读 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean markAsReadBatch(List messageIds, Long userId) { + // 由于当前表结构不支持已读状态,这里只是示例实现 + log.info("用户 {} 批量标记消息为已读,消息数量: {}", userId, messageIds.size()); + return true; + } + + /** + * 标记所有消息为已读 + */ + @Override + public Boolean markAllAsRead(Long userId) { + // 由于当前表结构不支持已读状态,这里只是示例实现 + log.info("用户 {} 标记所有消息为已读", userId); + return true; + } + + /** + * 发送系统消息 + */ + @Override + public Boolean sendSystemMessage(AppMessageBo bo) { + bo.setMessageType("system"); // 系统消息 + bo.setTargetType("all"); // 全部用户 + bo.setStatus("0"); // 待发送 + bo.setSendTime(LocalDateTime.now()); + return insertByBo(bo); + } + + /** + * 发送个人消息 + */ + @Override + public Boolean sendPersonalMessage(AppMessageBo bo) { + bo.setMessageType("notice"); // 通知消息 + bo.setTargetType("user"); // 指定用户 + bo.setStatus("0"); // 待发送 + bo.setSendTime(LocalDateTime.now()); + return insertByBo(bo); + } + + /** + * 推送消息到指定用户 + */ + @Override + public Boolean pushMessage(AppMessageBo bo) { + bo.setTargetType("user"); + bo.setStatus("0"); // 待发送 + bo.setSendTime(LocalDateTime.now()); + + // 先保存消息 + Boolean result = insertByBo(bo); + if (result) { + // 发送推送通知 + String[] userIds = bo.getTargetIds().split(","); + for (String userId : userIds) { + sendPushNotification(Long.valueOf(userId), bo.getTitle(), bo.getContent()); + } + + // 更新发送状态 + AppMessage update = new AppMessage(); + update.setMessageId(bo.getMessageId()); + update.setStatus("1"); // 已发送 + baseMapper.updateById(update); + } + return result; + } + + /** + * 群发消息 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean broadcastMessage(AppMessageBo bo) { + bo.setMessageType("activity"); // 活动消息 + bo.setTargetType("all"); // 全部用户 + bo.setStatus("0"); // 待发送 + bo.setSendTime(LocalDateTime.now()); + + Boolean result = insertByBo(bo); + if (result) { + // 发送批量推送通知 + sendBatchPushNotification(null, bo.getTitle(), bo.getContent()); + + // 更新发送状态 + AppMessage update = new AppMessage(); + update.setMessageId(bo.getMessageId()); + update.setStatus("1"); // 已发送 + baseMapper.updateById(update); + } + + return result; + } + + /** + * 发送推送通知 + */ + @Override + public Boolean sendPushNotification(Long userId, String title, String content) { + try { + // TODO: 集成第三方推送服务(极光推送、友盟推送等) + // 这里只是示例代码,实际需要根据具体的推送服务进行实现 + log.info("发送推送通知 - 用户ID: {}, 标题: {}, 内容: {}", userId, title, content); + + // 模拟推送成功 + return true; + } catch (Exception e) { + log.error("发送推送通知失败", e); + return false; + } + } + + /** + * 批量发送推送通知 + */ + @Override + public Boolean sendBatchPushNotification(List userIds, String title, String content) { + try { + // TODO: 集成第三方推送服务的批量推送接口 + if (userIds != null) { + log.info("批量发送推送通知 - 用户数量: {}, 标题: {}, 内容: {}", userIds.size(), title, content); + } else { + log.info("全员推送通知 - 标题: {}, 内容: {}", title, content); + } + + // 模拟推送成功 + return true; + } catch (Exception e) { + log.error("批量发送推送通知失败", e); + return false; + } + } + + /** + * 清理过期消息 + */ + @Override + public Integer cleanExpiredMessages() { + // 清理30天前的已发送消息 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.lt(AppMessage::getCreateTime, LocalDateTime.now().minusDays(30)); + lqw.eq(AppMessage::getStatus, "1"); // 已发送 + + List expiredMessages = baseMapper.selectList(lqw); + if (!expiredMessages.isEmpty()) { + List ids = expiredMessages.stream().map(AppMessage::getMessageId).toList(); + baseMapper.deleteBatchIds(ids); + log.info("清理过期消息 {} 条", ids.size()); + return ids.size(); + } + + return 0; + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppOperLogServiceImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppOperLogServiceImpl.java new file mode 100644 index 0000000..2717d26 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppOperLogServiceImpl.java @@ -0,0 +1,296 @@ +package org.dromara.member.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppOperLog; +import org.dromara.member.domain.bo.AppOperLogBo; +import org.dromara.member.domain.vo.AppOperLogVo; +import org.dromara.member.mapper.AppOperLogMapper; +import org.dromara.member.service.AppOperLogService; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * App端操作日志Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class AppOperLogServiceImpl extends ServiceImpl implements AppOperLogService { + + private final AppOperLogMapper baseMapper; + + /** + * 查询App端操作日志 + */ + @Override + public AppOperLogVo queryById(Long operId) { + return baseMapper.selectVoById(operId); + } + + /** + * 查询App端操作日志列表 + */ + @Override + public TableDataInfo queryPageList(AppOperLogBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询App端操作日志列表 + */ + @Override + public List queryList(AppOperLogBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(AppOperLogBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getTitle()), AppOperLog::getTitle, bo.getTitle()); + lqw.eq(bo.getBusinessType() != null, AppOperLog::getBusinessType, bo.getBusinessType()); + lqw.like(StringUtils.isNotBlank(bo.getMethod()), AppOperLog::getMethod, bo.getMethod()); + lqw.eq(StringUtils.isNotBlank(bo.getRequestMethod()), AppOperLog::getRequestMethod, bo.getRequestMethod()); + lqw.like(StringUtils.isNotBlank(bo.getOperName()), AppOperLog::getOperName, bo.getOperName()); + lqw.like(StringUtils.isNotBlank(bo.getOperIp()), AppOperLog::getOperIp, bo.getOperIp()); + lqw.eq(bo.getStatus() != null, AppOperLog::getStatus, bo.getStatus()); + lqw.between(params.get("beginTime") != null && params.get("endTime") != null, + AppOperLog::getOperTime, params.get("beginTime"), params.get("endTime")); + lqw.orderByDesc(AppOperLog::getOperTime); + return lqw; + } + + /** + * 新增App端操作日志 + */ + @Override + public Boolean insertByBo(AppOperLogBo bo) { + AppOperLog add = MapstructUtils.convert(bo, AppOperLog.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setOperId(add.getOperId()); + } + return flag; + } + + /** + * 修改App端操作日志 + */ + @Override + public Boolean updateByBo(AppOperLogBo bo) { + AppOperLog update = MapstructUtils.convert(bo, AppOperLog.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(AppOperLog entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除App端操作日志 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 记录操作日志 + */ + @Override + public void recordOperLog(String title, Integer businessType, String method, String requestMethod, + String operName, String operUrl, String operIp, String operLocation, + String operParam, String jsonResult, Integer status, String errorMsg, Long costTime) { + AppOperLog operLog = new AppOperLog(); + operLog.setTitle(title); + operLog.setBusinessType(businessType); + operLog.setMethod(method); + operLog.setRequestMethod(requestMethod); + operLog.setOperName(operName); + operLog.setOperUrl(operUrl); + operLog.setOperIp(operIp); + operLog.setOperLocation(operLocation); + operLog.setOperParam(operParam); + operLog.setJsonResult(jsonResult); + operLog.setStatus(status); + operLog.setErrorMsg(errorMsg); + operLog.setOperTime(LocalDateTime.now()); + operLog.setCostTime(costTime); + + try { + baseMapper.insert(operLog); + } catch (Exception e) { + log.error("记录操作日志失败: {}", e.getMessage(), e); + } + } + + /** + * 根据操作人查询操作日志 + */ + @Override + public List queryByOperName(String operName) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(operName), AppOperLog::getOperName, operName); + lqw.orderByDesc(AppOperLog::getOperTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 清空操作日志 + */ + @Override + public void cleanOperLog() { + baseMapper.delete(Wrappers.lambdaQuery()); + } + + /** + * 根据时间范围删除操作日志 + */ + @Override + public void deleteOperLogByTime(LocalDateTime beginTime, LocalDateTime endTime) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + if (beginTime != null) { + lqw.ge(AppOperLog::getOperTime, beginTime); + } + if (endTime != null) { + lqw.le(AppOperLog::getOperTime, endTime); + } + baseMapper.delete(lqw); + } + + /** + * 获取操作统计信息 + */ + @Override + public Long getOperLogCount(String operName, LocalDateTime beginTime, LocalDateTime endTime) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(operName), AppOperLog::getOperName, operName); + if (beginTime != null) { + lqw.ge(AppOperLog::getOperTime, beginTime); + } + if (endTime != null) { + lqw.le(AppOperLog::getOperTime, endTime); + } + return baseMapper.selectCount(lqw); + } + + /** + * 获取用户行为分析数据 + */ + @Override + public List getUserBehaviorAnalysis(String operName, Integer days) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(operName), AppOperLog::getOperName, operName); + if (days != null && days > 0) { + LocalDateTime startTime = LocalDateTime.now().minusDays(days); + lqw.ge(AppOperLog::getOperTime, startTime); + } + lqw.orderByDesc(AppOperLog::getOperTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 获取热门操作统计 + */ + @Override + public List getPopularOperations(Integer days, Integer limit) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + if (days != null && days > 0) { + LocalDateTime startTime = LocalDateTime.now().minusDays(days); + lqw.ge(AppOperLog::getOperTime, startTime); + } + lqw.eq(AppOperLog::getStatus, 0); // 只统计成功的操作 + lqw.orderByDesc(AppOperLog::getOperTime); + + List allLogs = baseMapper.selectVoList(lqw); + + // 按操作标题分组统计 + Map operationCount = allLogs.stream() + .collect(Collectors.groupingBy(AppOperLogVo::getTitle, Collectors.counting())); + + // 返回前N个热门操作 + return allLogs.stream() + .collect(Collectors.groupingBy(AppOperLogVo::getTitle)) + .entrySet().stream() + .sorted((e1, e2) -> Long.compare(e2.getValue().size(), e1.getValue().size())) + .limit(limit != null ? limit : 10) + .flatMap(entry -> entry.getValue().stream().limit(1)) + .collect(Collectors.toList()); + } + + /** + * 获取错误操作统计 + */ + @Override + public List getErrorOperations(Integer days) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + if (days != null && days > 0) { + LocalDateTime startTime = LocalDateTime.now().minusDays(days); + lqw.ge(AppOperLog::getOperTime, startTime); + } + lqw.eq(AppOperLog::getStatus, 1); // 只查询异常操作 + lqw.orderByDesc(AppOperLog::getOperTime); + return baseMapper.selectVoList(lqw); + } + + /** + * 批量记录操作日志 + */ + @Override + public void batchInsertOperLog(List operLogs) { + if (operLogs == null || operLogs.isEmpty()) { + return; + } + + List logs = operLogs.stream() + .map(bo -> MapstructUtils.convert(bo, AppOperLog.class)) + .collect(Collectors.toList()); + + try { + this.saveBatch(logs); + } catch (Exception e) { + log.error("批量记录操作日志失败: {}", e.getMessage(), e); + } + } + + /** + * 异步记录操作日志 + */ + @Async + @Override + public void asyncRecordOperLog(AppOperLogBo bo) { + try { + insertByBo(bo); + } catch (Exception e) { + log.error("异步记录操作日志失败: {}", e.getMessage(), e); + } + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppRoleServiceImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppRoleServiceImpl.java new file mode 100644 index 0000000..1b78a7c --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppRoleServiceImpl.java @@ -0,0 +1,237 @@ +package org.dromara.member.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppRole; +import org.dromara.member.domain.AppUserRole; +import org.dromara.member.domain.bo.AppRoleBo; +import org.dromara.member.domain.vo.AppRoleVo; +import org.dromara.member.mapper.AppRoleMapper; +import org.dromara.member.mapper.AppUserRoleMapper; +import org.dromara.member.service.AppRoleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * App端角色信息Service业务层处理 + * + * @author pengles + */ +@Service +public class AppRoleServiceImpl extends ServiceImpl implements AppRoleService { + + @Autowired + private AppUserRoleMapper userRoleMapper; + + /** + * 查询App端角色信息 + */ + @Override + public AppRoleVo queryById(Long roleId) { + return baseMapper.selectVoById(roleId); + } + + /** + * 查询App端角色信息列表 + */ + @Override + public TableDataInfo queryPageList(AppRoleBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询App端角色信息列表 + */ + @Override + public List queryList(AppRoleBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(AppRoleBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getRoleName()), AppRole::getRoleName, bo.getRoleName()); + lqw.eq(StringUtils.isNotBlank(bo.getRoleKey()), AppRole::getRoleKey, bo.getRoleKey()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), AppRole::getStatus, bo.getStatus()); + lqw.orderByAsc(AppRole::getRoleSort); + return lqw; + } + + /** + * 新增App端角色信息 + */ + @Override + public Boolean insertByBo(AppRoleBo bo) { + AppRole add = BeanUtil.toBean(bo, AppRole.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setRoleId(add.getRoleId()); + } + return flag; + } + + /** + * 修改App端角色信息 + */ + @Override + public Boolean updateByBo(AppRoleBo bo) { + AppRole update = BeanUtil.toBean(bo, AppRole.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(AppRole entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除App端角色信息 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 校验角色是否存在用户关联 + for (Long id : ids) { + long count = countUserRoleByRoleId(id); + if (count > 0) { + AppRoleVo role = queryById(id); + throw new RuntimeException(String.format("角色%s已分配用户,不能删除", role.getRoleName())); + } + } + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据用户ID查询角色 + */ + @Override + public List selectRolesByUserId(Long userId) { + return baseMapper.selectRolesByUserId(userId); + } + + /** + * 根据用户ID获取角色权限 + */ + @Override + public Set selectRolePermissionByUserId(Long userId) { + Set perms = baseMapper.selectRolePermissionByUserId(userId); + return perms; + } + + /** + * 查询所有角色 + */ + @Override + public List selectRoleAll() { + return baseMapper.selectRoleAll(); + } + + /** + * 根据用户ID获取角色选择框列表 + */ + @Override + public List selectRoleListByUserId(Long userId) { + return baseMapper.selectRoleListByUserId(userId); + } + + /** + * 校验角色名称是否唯一 + */ + @Override + public boolean checkRoleNameUnique(String roleName) { + return baseMapper.checkRoleNameUnique(roleName) == 0; + } + + /** + * 校验角色权限是否唯一 + */ + @Override + public boolean checkRoleKeyUnique(String roleKey) { + return baseMapper.checkRoleKeyUnique(roleKey) == 0; + } + + /** + * 校验角色是否允许操作 + */ + @Override + public boolean checkRoleAllowed(Long roleId) { + return roleId != null && roleId != 1L; + } + + /** + * 校验角色是否有数据权限 + */ + @Override + public boolean checkRoleDataScope(Long roleId) { + return roleId != null && roleId != 1L; + } + + /** + * 通过角色ID查询角色使用数量 + */ + @Override + public long countUserRoleByRoleId(Long roleId) { + return userRoleMapper.countUserRoleByRoleId(roleId); + } + + /** + * 取消授权用户角色 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteAuthUser(Long userId, Long roleId) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.eq(AppUserRole::getUserId, userId) + .eq(AppUserRole::getRoleId, roleId); + return userRoleMapper.delete(wrapper) > 0; + } + + /** + * 批量取消授权用户角色 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteAuthUsers(Long roleId, Long[] userIds) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.eq(AppUserRole::getRoleId, roleId) + .in(AppUserRole::getUserId, Arrays.asList(userIds)); + return userRoleMapper.delete(wrapper) > 0; + } + + /** + * 批量选择授权用户角色 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean insertAuthUsers(Long roleId, Long[] userIds) { + // 新增用户与角色管理 + List list = Arrays.stream(userIds).map(userId -> { + AppUserRole ur = new AppUserRole(); + ur.setUserId(userId); + ur.setRoleId(roleId); + return ur; + }).collect(Collectors.toList()); + return userRoleMapper.batchUserRole(list) > 0; + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppSocialAuthServiceImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppSocialAuthServiceImpl.java new file mode 100644 index 0000000..a5e19f6 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppSocialAuthServiceImpl.java @@ -0,0 +1,273 @@ +package org.dromara.member.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.member.domain.AppSocialAuth; +import org.dromara.member.domain.vo.AppSocialAuthVo; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.member.mapper.AppSocialAuthMapper; +import org.dromara.member.service.AppSocialAuthService; +import org.dromara.member.service.AppUserService; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; + +/** + * App端第三方登录授权Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class AppSocialAuthServiceImpl extends ServiceImpl implements AppSocialAuthService { + + private final AppSocialAuthMapper baseMapper; + private final AppUserService appUserService; + + /** + * 查询App端第三方登录授权 + */ + @Override + public AppSocialAuthVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 根据用户ID查询社交认证列表 + */ + @Override + public List queryByUserId(Long userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppSocialAuth::getUserId, userId) + .eq(AppSocialAuth::getDelFlag, "0") + .orderByDesc(AppSocialAuth::getCreateTime); + return baseMapper.selectVoList(wrapper); + } + + /** + * 根据平台和openId查询社交认证 + */ + @Override + public AppSocialAuth queryByPlatformAndOpenId(String platform, String openId) { + if (StringUtils.isBlank(platform) || StringUtils.isBlank(openId)) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppSocialAuth::getPlatform, platform) + .eq(AppSocialAuth::getOpenId, openId) + .eq(AppSocialAuth::getDelFlag, "0") + .last("LIMIT 1"); + return baseMapper.selectOne(wrapper); + } + + /** + * 根据平台和unionId查询社交认证 + */ + @Override + public AppSocialAuth queryByPlatformAndUnionId(String platform, String unionId) { + if (StringUtils.isBlank(platform) || StringUtils.isBlank(unionId)) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppSocialAuth::getPlatform, platform) + .eq(AppSocialAuth::getUnionId, unionId) + .eq(AppSocialAuth::getDelFlag, "0") + .last("LIMIT 1"); + return baseMapper.selectOne(wrapper); + } + + /** + * 新增App端第三方登录授权 + */ + @Override + public Boolean insertAppSocialAuth(AppSocialAuth appSocialAuth) { + appSocialAuth.setDelFlag("0"); + appSocialAuth.setCreateTime(new Date()); + return baseMapper.insert(appSocialAuth) > 0; + } + + /** + * 修改App端第三方登录授权 + */ + @Override + public Boolean updateAppSocialAuth(AppSocialAuth appSocialAuth) { + appSocialAuth.setUpdateTime(new Date()); + return baseMapper.updateById(appSocialAuth) > 0; + } + + /** + * 删除App端第三方登录授权 + */ + @Override + public Boolean deleteAppSocialAuthById(Long id) { + AppSocialAuth appSocialAuth = new AppSocialAuth(); + appSocialAuth.setId(id); + appSocialAuth.setDelFlag("2"); + appSocialAuth.setUpdateTime(new Date()); + return baseMapper.updateById(appSocialAuth) > 0; + } + + /** + * 根据用户ID删除社交认证 + */ + @Override + public Boolean deleteByUserId(Long userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppSocialAuth::getUserId, userId); + List list = baseMapper.selectList(wrapper); + if (list.isEmpty()) { + return true; + } + + AppSocialAuth updateEntity = new AppSocialAuth(); + updateEntity.setDelFlag("2"); + updateEntity.setUpdateTime(new Date()); + + return baseMapper.update(updateEntity, wrapper) > 0; + } + + /** + * 更新访问令牌 + */ + @Override + public Boolean updateAccessToken(Long id, String accessToken, Integer expireIn, String refreshToken) { + AppSocialAuth appSocialAuth = new AppSocialAuth(); + appSocialAuth.setId(id); + appSocialAuth.setAccessToken(accessToken); + appSocialAuth.setExpireIn(expireIn); + appSocialAuth.setRefreshToken(refreshToken); + appSocialAuth.setUpdateTime(new Date()); + return baseMapper.updateById(appSocialAuth) > 0; + } + + @Override + public Boolean deleteByUserIdAndPlatform(Long userId, String platform) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppSocialAuth::getUserId, userId) + .eq(AppSocialAuth::getPlatform, platform) + .eq(AppSocialAuth::getDelFlag, "0"); + return baseMapper.delete(wrapper) > 0; + } + + @Override + public AppUserVo selectUserByPlatformAndOpenId(String platform, String openId) { + if (StringUtils.isBlank(platform) || StringUtils.isBlank(openId)) { + return null; + } + + // 根据平台和openId查询社交认证记录 + AppSocialAuth socialAuth = queryByPlatformAndOpenId(platform, openId); + if (socialAuth == null || socialAuth.getUserId() == null) { + return null; + } + + // 根据userId查询用户信息 + return appUserService.queryById(socialAuth.getUserId()); + } + + @Override + public AppUserVo selectUserByPlatformAndUnionId(String platform, String unionId) { + if (StringUtils.isBlank(platform) || StringUtils.isBlank(unionId)) { + return null; + } + AppSocialAuth socialAuth = queryByPlatformAndUnionId(platform, unionId); + if (socialAuth == null || socialAuth.getUserId() == null) { + return null; + } + return appUserService.queryById(socialAuth.getUserId()); + } + + @Override + public void socialRegister(me.zhyd.oauth.model.AuthUser authUserData) { + if (authUserData == null) { + throw new org.dromara.common.core.exception.ServiceException("第三方用户信息不能为空"); + } + + String platform = authUserData.getSource(); + String openId = authUserData.getUuid(); + Long userId = org.dromara.common.satoken.utils.LoginHelper.getUserId(); + + if (StringUtils.isBlank(platform) || StringUtils.isBlank(openId)) { + throw new org.dromara.common.core.exception.ServiceException("第三方平台信息不完整"); + } + + log.info("开始绑定第三方账号,用户ID: {}, 平台: {}, openId: {}", userId, platform, openId); + + // 检查是否已经绑定过该第三方账号 + AppSocialAuth existingSocialAuth = queryByPlatformAndOpenId(platform, openId); + if (existingSocialAuth != null) { + if (!userId.equals(existingSocialAuth.getUserId())) { + log.warn("第三方账号已被其他用户绑定,平台: {}, openId: {}, 绑定用户: {}", platform, openId, existingSocialAuth.getUserId()); + throw new org.dromara.common.core.exception.ServiceException("此第三方账号已经被其他用户绑定!"); + } else { + log.info("第三方账号已绑定当前用户,无需重复绑定,平台: {}, openId: {}", platform, openId); + return; + } + } + + // 检查当前用户是否已经绑定过该平台的账号 + List userSocialAuths = queryByUserId(userId); + boolean alreadyBound = userSocialAuths.stream() + .anyMatch(auth -> platform.equals(auth.getPlatform())); + + if (alreadyBound) { + // 如果已经绑定过该平台,则更新绑定信息 + log.info("用户已绑定该平台账号,更新绑定信息,用户ID: {}, 平台: {}", userId, platform); + AppSocialAuth updateAuth = userSocialAuths.stream() + .filter(auth -> platform.equals(auth.getPlatform())) + .map(vo -> { + AppSocialAuth auth = new AppSocialAuth(); + auth.setId(vo.getId()); + auth.setUserId(userId); + auth.setPlatform(platform); + auth.setPlatformUid(authUserData.getUuid()); + auth.setOpenId(openId); + auth.setUnionId(authUserData.getToken() != null ? authUserData.getToken().getUnionId() : null); + auth.setAccessToken(authUserData.getToken() != null ? authUserData.getToken().getAccessToken() : null); + auth.setExpireIn(authUserData.getToken() != null ? authUserData.getToken().getExpireIn() : null); + auth.setRefreshToken(authUserData.getToken() != null ? authUserData.getToken().getRefreshToken() : null); + auth.setScope(authUserData.getToken() != null ? authUserData.getToken().getScope() : null); + return auth; + }) + .findFirst() + .orElse(null); + + if (updateAuth != null) { + boolean success = updateAppSocialAuth(updateAuth); + if (success) { + log.info("更新第三方账号绑定成功,用户ID: {}, 平台: {}", userId, platform); + } else { + log.error("更新第三方账号绑定失败,用户ID: {}, 平台: {}", userId, platform); + throw new org.dromara.common.core.exception.ServiceException("更新绑定信息失败"); + } + } + } else { + // 新增绑定记录 + log.info("新增第三方账号绑定,用户ID: {}, 平台: {}", userId, platform); + AppSocialAuth newAuth = new AppSocialAuth(); + newAuth.setUserId(userId); + newAuth.setPlatform(platform); + newAuth.setPlatformUid(authUserData.getUuid()); + newAuth.setOpenId(openId); + newAuth.setUnionId(authUserData.getToken() != null ? authUserData.getToken().getUnionId() : null); + newAuth.setAccessToken(authUserData.getToken() != null ? authUserData.getToken().getAccessToken() : null); + newAuth.setExpireIn(authUserData.getToken() != null ? authUserData.getToken().getExpireIn() : null); + newAuth.setRefreshToken(authUserData.getToken() != null ? authUserData.getToken().getRefreshToken() : null); + newAuth.setScope(authUserData.getToken() != null ? authUserData.getToken().getScope() : null); + + boolean success = insertAppSocialAuth(newAuth); + if (success) { + log.info("新增第三方账号绑定成功,用户ID: {}, 平台: {}", userId, platform); + } else { + log.error("新增第三方账号绑定失败,用户ID: {}, 平台: {}", userId, platform); + throw new org.dromara.common.core.exception.ServiceException("绑定第三方账号失败"); + } + } + } + +} diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppUserServiceImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppUserServiceImpl.java new file mode 100644 index 0000000..8d85936 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppUserServiceImpl.java @@ -0,0 +1,239 @@ +package org.dromara.member.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.crypto.digest.BCrypt; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppUser; +import org.dromara.member.domain.AppUserRole; +import org.dromara.member.domain.bo.AppUserBo; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.member.mapper.AppUserMapper; +import org.dromara.member.mapper.AppUserRoleMapper; +import org.dromara.member.service.AppUserService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * App端用户信息Service业务层处理 + * + * @author pengles + */ +@Service +@RequiredArgsConstructor +public class AppUserServiceImpl extends ServiceImpl implements AppUserService { + + private final AppUserRoleMapper appUserRoleMapper; + + + /** + * 查询App端用户信息 + */ + @Override + public AppUserVo queryById(Long userId) { + return baseMapper.selectVoById(userId); + } + + /** + * 查询App端用户信息列表 + */ + @Override + public TableDataInfo queryPageList(AppUserBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询App端用户信息列表 + */ + @Override + public List queryList(AppUserBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(AppUserBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getUserName()), AppUser::getUserName, bo.getUserName()); + lqw.like(StringUtils.isNotBlank(bo.getNickName()), AppUser::getNickName, bo.getNickName()); + lqw.eq(StringUtils.isNotBlank(bo.getPhone()), AppUser::getPhone, bo.getPhone()); + lqw.eq(StringUtils.isNotBlank(bo.getEmail()), AppUser::getEmail, bo.getEmail()); + lqw.eq(StringUtils.isNotBlank(bo.getSex()), AppUser::getSex, bo.getSex()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), AppUser::getStatus, bo.getStatus()); + return lqw; + } + + /** + * 新增App端用户信息 + */ + @Override + public Boolean insertByBo(AppUserBo bo) { + AppUser add = BeanUtil.toBean(bo, AppUser.class); + validEntityBeforeSave(add); + // 加密密码 + if (StringUtils.isNotBlank(add.getPassword())) { + add.setPassword(BCrypt.hashpw(add.getPassword())); + } + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setUserId(add.getUserId()); + } + return flag; + } + + /** + * 修改App端用户信息 + */ + @Override + public Boolean updateByBo(AppUserBo bo) { + AppUser update = BeanUtil.toBean(bo, AppUser.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(AppUser entity) { + // TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除App端用户信息 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 根据用户账号查询用户信息 + */ + @Override + public AppUserVo selectUserByUserName(String userName) { + return baseMapper.selectUserByUserName(userName); + } + + /** + * 根据手机号查询用户信息 + */ + @Override + public AppUserVo selectUserByPhone(String phone) { + return baseMapper.selectUserByPhone(phone); + } + + /** + * 根据邮箱查询用户信息 + */ + @Override + public AppUserVo selectUserByEmail(String email) { + return baseMapper.selectUserByEmail(email); + } + + /** + * 校验用户账号是否唯一 + */ + @Override + public boolean checkUserNameUnique(String userName) { + return baseMapper.checkUserNameUnique(userName) == 0; + } + + /** + * 校验手机号码是否唯一 + */ + @Override + public boolean checkPhoneUnique(String phone) { + return baseMapper.checkPhoneUnique(phone) == 0; + } + + /** + * 校验email是否唯一 + */ + @Override + public boolean checkEmailUnique(String email) { + return baseMapper.checkEmailUnique(email) == 0; + } + + /** + * 用户注册 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean register(AppUserBo bo) { + // 校验账号唯一性 + if (!checkUserNameUnique(bo.getUserName())) { + throw new RuntimeException("用户账号已存在"); + } + // 校验手机号唯一性 + if (StringUtils.isNotBlank(bo.getPhone()) && !checkPhoneUnique(bo.getPhone())) { + throw new RuntimeException("手机号已存在"); + } + // 校验邮箱唯一性 + if (StringUtils.isNotBlank(bo.getEmail()) && !checkEmailUnique(bo.getEmail())) { + throw new RuntimeException("邮箱已存在"); + } + boolean result = insertByBo(bo); + if (result) { + // 分配默认角色 + AppUserRole userRole = new AppUserRole(); + userRole.setUserId(bo.getUserId()); + userRole.setRoleId(1L); // 默认角色ID为1 (App普通用户) + appUserRoleMapper.insert(userRole); + } + return result; + } + + /** + * 修改用户密码 + */ + @Override + public Boolean updatePassword(Long userId, String oldPassword, String newPassword) { + AppUser user = baseMapper.selectById(userId); + if (ObjectUtil.isNull(user)) { + throw new RuntimeException("用户不存在"); + } + if (!BCrypt.checkpw(oldPassword, user.getPassword())) { + throw new RuntimeException("旧密码错误"); + } + user.setPassword(BCrypt.hashpw(newPassword)); + return baseMapper.updateById(user) > 0; + } + + /** + * 重置用户密码 + */ + @Override + public Boolean resetPassword(Long userId, String newPassword) { + AppUser user = new AppUser(); + user.setUserId(userId); + user.setPassword(BCrypt.hashpw(newPassword)); + return baseMapper.updateById(user) > 0; + } + + /** + * 更新用户状态 + */ + @Override + public Boolean updateUserStatus(Long userId, String status) { + return baseMapper.update(null, + new LambdaUpdateWrapper() + .set(AppUser::getStatus, status) + .eq(AppUser::getUserId, userId)) > 0; + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppVersionServiceImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppVersionServiceImpl.java new file mode 100644 index 0000000..c490545 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/service/impl/AppVersionServiceImpl.java @@ -0,0 +1,297 @@ +package org.dromara.member.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.member.domain.AppVersion; +import org.dromara.member.domain.bo.AppVersionBo; +import org.dromara.member.domain.vo.AppVersionVo; +import org.dromara.member.exception.VersionException; +import org.dromara.member.mapper.AppVersionMapper; +import org.dromara.member.service.AppVersionService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * App版本信息Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class AppVersionServiceImpl extends ServiceImpl implements AppVersionService { + + private final AppVersionMapper baseMapper; + + /** + * 查询App版本信息 + */ + @Override + public AppVersionVo queryById(Long versionId) { + return baseMapper.selectVoById(versionId); + } + + /** + * 查询App版本信息列表 + */ + @Override + public TableDataInfo queryPageList(AppVersionBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询App版本信息列表 + */ + @Override + public List queryList(AppVersionBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(AppVersionBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getVersionCode() != null, AppVersion::getVersionCode, bo.getVersionCode()); + lqw.like(StringUtils.isNotBlank(bo.getVersionName()), AppVersion::getVersionName, bo.getVersionName()); + lqw.eq(StringUtils.isNotBlank(bo.getPlatform()), AppVersion::getPlatform, bo.getPlatform()); + lqw.eq(StringUtils.isNotBlank(bo.getIsForce()), AppVersion::getIsForce, bo.getIsForce()); + lqw.orderByDesc(AppVersion::getVersionCode); + return lqw; + } + + /** + * 新增App版本信息 + */ + @Override + public Boolean insertByBo(AppVersionBo bo) { + AppVersion add = BeanUtil.toBean(bo, AppVersion.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setVersionId(add.getVersionId()); + } + return flag; + } + + /** + * 修改App版本信息 + */ + @Override + public Boolean updateByBo(AppVersionBo bo) { + AppVersion update = BeanUtil.toBean(bo, AppVersion.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(AppVersion entity) { + // 校验版本号唯一性 + if (entity.getVersionCode() != null && StringUtils.isNotBlank(entity.getPlatform())) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppVersion::getVersionCode, entity.getVersionCode()); + lqw.eq(AppVersion::getPlatform, entity.getPlatform()); + if (entity.getVersionId() != null) { + lqw.ne(AppVersion::getVersionId, entity.getVersionId()); + } + + long count = baseMapper.selectCount(lqw); + if (count > 0) { + throw new RuntimeException("该平台下版本号已存在"); + } + } + } + + /** + * 批量删除App版本信息 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + /** + * 检查版本更新 + */ + @Override + public AppVersionVo checkUpdate(String platform, Integer currentVersionCode) { + if (StringUtils.isBlank(platform)) { + throw VersionException.unsupportedPlatform(platform); + } + if (currentVersionCode == null || currentVersionCode <= 0) { + throw VersionException.invalidVersionCode(currentVersionCode); + } + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppVersion::getPlatform, platform); + lqw.gt(AppVersion::getVersionCode, currentVersionCode); + lqw.orderByDesc(AppVersion::getVersionCode); + lqw.last("LIMIT 1"); + + return baseMapper.selectVoOne(lqw); + } + + /** + * 获取指定平台的最新版本 + */ + @Override + public AppVersionVo getLatestVersion(String platform) { + if (StringUtils.isBlank(platform)) { + throw VersionException.unsupportedPlatform(platform); + } + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppVersion::getPlatform, platform); + lqw.orderByDesc(AppVersion::getVersionCode); + lqw.last("LIMIT 1"); + + AppVersionVo result = baseMapper.selectVoOne(lqw); + if (result == null) { + throw VersionException.versionNotFound("平台 " + platform + " 暂无可用版本"); + } + + return result; + } + + /** + * 检查版本号是否唯一 + */ + @Override + public Boolean checkVersionCodeUnique(AppVersionBo bo) { + if (bo.getVersionCode() == null || bo.getVersionCode() <= 0) { + throw VersionException.invalidVersionCode(bo.getVersionCode()); + } + if (StringUtils.isBlank(bo.getPlatform())) { + throw VersionException.unsupportedPlatform(bo.getPlatform()); + } + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppVersion::getVersionCode, bo.getVersionCode()); + lqw.eq(AppVersion::getPlatform, bo.getPlatform()); + if (bo.getVersionId() != null) { + lqw.ne(AppVersion::getVersionId, bo.getVersionId()); + } + + boolean isUnique = baseMapper.selectCount(lqw) == 0; + if (!isUnique) { + throw VersionException.versionCodeDuplicate(bo.getVersionCode()); + } + return true; + } + + /** + * 获取版本历史列表 + */ + @Override + public List getVersionHistory(String platform, Integer limit) { + if (StringUtils.isBlank(platform)) { + return List.of(); + } + + if (limit == null || limit <= 0) { + limit = 10; // 默认返回10个版本 + } + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppVersion::getPlatform, platform); + lqw.orderByDesc(AppVersion::getVersionCode); + lqw.last("LIMIT " + limit); + + return baseMapper.selectVoList(lqw); + } + + /** + * 检查是否需要强制更新 + */ + @Override + public Boolean checkForceUpdate(String platform, Integer currentVersionCode) { + if (StringUtils.isBlank(platform)) { + throw VersionException.unsupportedPlatform(platform); + } + if (currentVersionCode == null || currentVersionCode <= 0) { + throw VersionException.invalidVersionCode(currentVersionCode); + } + + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppVersion::getPlatform, platform); + lqw.gt(AppVersion::getVersionCode, currentVersionCode); + lqw.eq(AppVersion::getIsForce, "1"); + + return baseMapper.selectCount(lqw) > 0; + } + + /** + * 获取版本更新信息 + */ + @Override + public AppVersionVo getUpdateInfo(String platform, Integer currentVersionCode) { + AppVersionVo latestVersion = checkUpdate(platform, currentVersionCode); + if (latestVersion == null) { + return null; + } + + // 检查是否有强制更新版本 + Boolean forceUpdate = checkForceUpdate(platform, currentVersionCode); + if (forceUpdate) { + // 如果有强制更新,返回最新的强制更新版本 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AppVersion::getPlatform, platform); + lqw.gt(AppVersion::getVersionCode, currentVersionCode); + lqw.eq(AppVersion::getIsForce, "1"); + lqw.orderByDesc(AppVersion::getVersionCode); + lqw.last("LIMIT 1"); + + AppVersionVo forceVersion = baseMapper.selectVoOne(lqw); + return forceVersion != null ? forceVersion : latestVersion; + } + + return latestVersion; + } + + /** + * 批量发布版本 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean batchPublish(List versionIds) { + if (ObjectUtil.isEmpty(versionIds)) { + return false; + } + + // 这里可以添加发布逻辑,比如更新状态等 + log.info("批量发布版本: {}", versionIds); + return true; + } + + /** + * 撤回版本 + */ + @Override + public Boolean withdrawVersion(Long versionId) { + if (versionId == null) { + return false; + } + + // 这里可以添加撤回逻辑,比如更新状态等 + log.info("撤回版本: {}", versionId); + return true; + } + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/utils/JwtUtil.java b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/utils/JwtUtil.java new file mode 100644 index 0000000..b9ef311 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/org/dromara/member/utils/JwtUtil.java @@ -0,0 +1,117 @@ +package org.dromara.member.utils; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * JWT工具类 + * + * @author pengles + */ +public class JwtUtil { + private final String secret; + + public JwtUtil(String secret) { + this.secret = secret; + } + + public String generateToken(String subject, long ttlSeconds, String jti) { + long now = Instant.now().getEpochSecond(); + long exp = now + ttlSeconds; + Map header = new LinkedHashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + Map payload = new LinkedHashMap<>(); + payload.put("iss", "ruoyi-app"); + payload.put("sub", subject); + payload.put("iat", now); + payload.put("exp", exp); + payload.put("jti", jti == null ? UUID.randomUUID().toString() : jti); + String h = base64Url(Json.write(header)); + String p = base64Url(Json.write(payload)); + String signing = h + "." + p; + String sig = base64Url(hmacSha256(signing, secret)); + return signing + "." + sig; + } + + public boolean verify(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length != 3) return false; + String signing = parts[0] + "." + parts[1]; + String calc = base64Url(hmacSha256(signing, secret)); + return slowEquals(calc, parts[2]); + } catch (Exception e) { + return false; + } + } + + public Map parsePayload(String token) { + String[] parts = token.split("\\."); + if (parts.length != 3) return Map.of(); + String json = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); + return Json.read(json); + } + + private static boolean slowEquals(String a, String b) { + if (a == null || b == null) return false; + if (a.length() != b.length()) return false; + int r = 0; + for (int i = 0; i < a.length(); i++) r |= a.charAt(i) ^ b.charAt(i); + return r == 0; + } + + private static byte[] hmacSha256(String data, String key) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String base64Url(String s) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(s.getBytes(StandardCharsets.UTF_8)); + } + + private static String base64Url(byte[] b) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(b); + } + + static class Json { + public static String write(Map map) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) sb.append(","); + sb.append("\"").append(e.getKey()).append("\":"); + Object v = e.getValue(); + if (v instanceof Number || v instanceof Boolean) { + sb.append(v.toString()); + } else { + sb.append("\"").append(String.valueOf(v).replace("\"", "\\\"")).append("\""); + } + first = false; + } + sb.append("}"); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + public static Map read(String json) { + try { + return org.dromara.common.json.utils.JsonUtils.parseObject(json, Map.class); + } catch (Exception e) { + return Map.of(); + } + } + } +} + diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppDeviceMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppDeviceMapper.xml new file mode 100644 index 0000000..5023c18 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppDeviceMapper.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select device_id, + user_id, + device_type, + device_name, + device_model, + device_brand, + os_version, + app_version, + push_token, + is_active, + last_active_time, + create_time, + update_time + from app_device + + + + + + + + + + + + + + + + + insert into app_device + + user_id, + device_type, + device_name, + device_model, + device_brand, + os_version, + app_version, + push_token, + is_active, + last_active_time, + create_time, + update_time, + + + #{userId}, + #{deviceType}, + #{deviceName}, + #{deviceModel}, + #{deviceBrand}, + #{osVersion}, + #{appVersion}, + #{pushToken}, + #{isActive}, + #{lastActiveTime}, + #{createTime}, + #{updateTime}, + + + + + + update app_device + + device_type = #{deviceType}, + device_name = #{deviceName}, + device_model = #{deviceModel}, + device_brand = #{deviceBrand}, + os_version = #{osVersion}, + app_version = #{appVersion}, + push_token = #{pushToken}, + is_active = #{isActive}, + last_active_time = #{lastActiveTime}, + update_time = #{updateTime}, + + where device_id = #{deviceId} + + + + + delete + from app_device + where device_id = #{deviceId} + + + + + delete from app_device where device_id in + + #{deviceId} + + + + + + update app_device + set is_active = #{isActive}, + last_active_time = #{lastActiveTime} + where device_id = #{deviceId} + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppFileMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppFileMapper.xml new file mode 100644 index 0000000..18106f3 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppFileMapper.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select file_id, + user_id, + file_name, + original_name, + file_path, + file_url, + file_size, + file_type, + file_suffix, + file_category, + storage_service, + status, + download_count, + is_public, + file_md5, + remark, + create_by, + create_time, + update_by, + update_time + from app_file + + + + + + + + insert into app_file + + user_id, + file_name, + original_name, + file_path, + file_url, + file_size, + file_type, + file_suffix, + file_category, + storage_service, + status, + download_count, + is_public, + file_md5, + remark, + create_by, + create_time, + update_by, + update_time, + + + #{userId}, + #{fileName}, + #{originalName}, + #{filePath}, + #{fileUrl}, + #{fileSize}, + #{fileType}, + #{fileSuffix}, + #{fileCategory}, + #{storageService}, + #{status}, + #{downloadCount}, + #{isPublic}, + #{fileMd5}, + #{remark}, + #{createBy}, + #{createTime}, + #{updateBy}, + #{updateTime}, + + + + + update app_file + + user_id = #{userId}, + file_name = #{fileName}, + original_name = #{originalName}, + file_path = #{filePath}, + file_url = #{fileUrl}, + file_size = #{fileSize}, + file_type = #{fileType}, + file_suffix = #{fileSuffix}, + file_category = #{fileCategory}, + storage_service = #{storageService}, + status = #{status}, + download_count = #{downloadCount}, + is_public = #{isPublic}, + file_md5 = #{fileMd5}, + remark = #{remark}, + update_by = #{updateBy}, + update_time = #{updateTime}, + + where file_id = #{fileId} + + + + delete + from app_file + where file_id = #{fileId} + + + + delete from app_file where file_id in + + #{fileId} + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppLoginLogMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppLoginLogMapper.xml new file mode 100644 index 0000000..e6115cc --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppLoginLogMapper.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select info_id, + user_name, + status, + ipaddr, + login_location, + browser, + os, + msg, + login_time + from app_login_log + + + + + + + + + + + + + + insert into app_login_log + + user_name, + status, + ipaddr, + login_location, + browser, + os, + msg, + login_time, + + + #{userName}, + #{status}, + #{ipaddr}, + #{loginLocation}, + #{browser}, + #{os}, + #{msg}, + #{loginTime}, + + + + + + delete + from app_login_log + where info_id = #{infoId} + + + + + delete from app_login_log where info_id in + + #{infoId} + + + + + + truncate table app_login_log + + + + + delete from app_login_log + + and login_time >= #{beginTime} + and login_time <= #{endTime} + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppMessageMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppMessageMapper.xml new file mode 100644 index 0000000..ef0d208 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppMessageMapper.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select message_id, + title, + content, + message_type, + push_target, + target_ids, + send_time, + status, + create_by, + create_time, + update_by, + update_time, + remark + from app_message + + + + + + + + + + + + + + + + insert into app_message + + title, + content, + message_type, + push_target, + target_ids, + send_time, + status, + create_by, + remark, + create_time + + + #{title}, + #{content}, + #{messageType}, + #{pushTarget}, + #{targetIds}, + #{sendTime}, + #{status}, + #{createBy}, + #{remark}, + sysdate() + + + + + update app_message + + title = #{title}, + content = #{content}, + message_type = #{messageType}, + push_target = #{pushTarget}, + target_ids = #{targetIds}, + send_time = #{sendTime}, + status = #{status}, + update_by = #{updateBy}, + remark = #{remark}, + update_time = sysdate() + + where message_id = #{messageId} + + + + update app_message + set status = #{status}, + update_by = #{updateBy}, + update_time = sysdate() + where message_id = #{messageId} + + + + delete + from app_message + where message_id = #{messageId} + + + + delete from app_message where message_id in + + #{messageId} + + + + + delete + from app_message + where status = #{status} + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppOperLogMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppOperLogMapper.xml new file mode 100644 index 0000000..faabbaa --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppOperLogMapper.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select oper_id, + title, + business_type, + method, + request_method, + oper_name, + oper_url, + oper_ip, + oper_location, + oper_param, + json_result, + status, + error_msg, + oper_time, + cost_time + from app_oper_log + + + + + + + + + + + + + + insert into app_oper_log + + title, + business_type, + method, + request_method, + oper_name, + oper_url, + oper_ip, + oper_location, + oper_param, + json_result, + status, + error_msg, + oper_time, + cost_time, + + + #{title}, + #{businessType}, + #{method}, + #{requestMethod}, + #{operName}, + #{operUrl}, + #{operIp}, + #{operLocation}, + #{operParam}, + #{jsonResult}, + #{status}, + #{errorMsg}, + #{operTime}, + #{costTime}, + + + + + + delete + from app_oper_log + where oper_id = #{operId} + + + + + delete from app_oper_log where oper_id in + + #{operId} + + + + + + truncate table app_oper_log + + + + + delete from app_oper_log + + and oper_time >= #{beginTime} + and oper_time <= #{endTime} + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppRoleMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppRoleMapper.xml new file mode 100644 index 0000000..e6904f6 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppRoleMapper.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select distinct r.role_id, + r.role_name, + r.role_key, + r.role_sort, + r.status, + r.del_flag, + r.create_time, + r.remark + from app_role r + left join app_user_role ur on ur.role_id = r.role_id + left join app_user u on u.user_id = ur.user_id + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppSocialAuthMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppSocialAuthMapper.xml new file mode 100644 index 0000000..5bb6d26 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppSocialAuthMapper.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select id, + user_id, + platform, + platform_uid, + open_id, + union_id, + access_token, + expire_in, + refresh_token, + scope, + del_flag, + create_by, + create_time, + update_by, + update_time + from app_social_auth + + + + + + + + + + + + + + + + + + + + insert into app_social_auth + + user_id, + platform, + platform_uid, + open_id, + union_id, + access_token, + expire_in, + refresh_token, + scope, + del_flag, + create_by, + create_time, + update_by, + update_time, + + + #{userId}, + #{platform}, + #{platformUid}, + #{openId}, + #{unionId}, + #{accessToken}, + #{expireIn}, + #{refreshToken}, + #{scope}, + #{delFlag}, + #{createBy}, + #{createTime}, + #{updateBy}, + #{updateTime}, + + + + + + update app_social_auth + + user_id = #{userId}, + platform = #{platform}, + platform_uid = #{platformUid}, + open_id = #{openId}, + union_id = #{unionId}, + access_token = #{accessToken}, + expire_in = #{expireIn}, + refresh_token = #{refreshToken}, + scope = #{scope}, + update_by = #{updateBy}, + update_time = #{updateTime}, + + where id = #{id} + + + + + update app_social_auth + set del_flag = '2' + where id = #{id} + + + + + update app_social_auth set del_flag = '2' where id in + + #{id} + + + + + + update app_social_auth + set del_flag = '2' + where user_id = #{userId} + + + + + update app_social_auth + set access_token = #{accessToken}, + expire_in = #{expireIn}, + refresh_token = #{refreshToken}, + update_time = #{updateTime} + where id = #{id} + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserMapper.xml new file mode 100644 index 0000000..4b5626b --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserMapper.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select user_id, + user_name, + nick_name, + phone, + email, + sex, + avatar, + password, + status, + del_flag, + login_ip, + login_date, + register_time, + create_by, + create_time, + update_by, + update_time, + remark + from app_user + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserMessageMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserMessageMapper.xml new file mode 100644 index 0000000..5f91c6d --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserMessageMapper.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + select user_message_id, user_id, message_id, is_read, read_time, create_time + from app_user_message + + + + + + + + + + + + + + + + insert into app_user_message + + user_id, + message_id, + is_read, + read_time, + create_time + + + #{userId}, + #{messageId}, + #{isRead}, + #{readTime}, + sysdate() + + + + + insert into app_user_message(user_id, message_id, is_read, create_time) + values + + (#{item.userId}, #{item.messageId}, #{item.isRead}, sysdate()) + + + + + update app_user_message + + user_id = #{userId}, + message_id = #{messageId}, + is_read = #{isRead}, + read_time = #{readTime}, + + where user_message_id = #{userMessageId} + + + + update app_user_message + set is_read = 1, + read_time = sysdate() + where user_message_id = #{userMessageId} + + + + update app_user_message + set is_read = 1, + read_time = sysdate() + where user_id = #{userId} + and is_read = 0 + + + + delete + from app_user_message + where user_message_id = #{userMessageId} + + + + delete from app_user_message where user_message_id in + + #{userMessageId} + + + + + delete + from app_user_message + where user_id = #{userId} + + + + delete + from app_user_message + where message_id = #{messageId} + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserRoleMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserRoleMapper.xml new file mode 100644 index 0000000..612ea92 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppUserRoleMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + delete + from app_user_role + where user_id = #{userId} + + + + + delete from app_user_role where user_id in + + #{userId} + + + + + + + + + insert into app_user_role(user_id, role_id) values + + (#{item.userId}, #{item.roleId}) + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppVersionMapper.xml b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppVersionMapper.xml new file mode 100644 index 0000000..8f05559 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/resources/mapper/member/AppVersionMapper.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + select version_id, + version_code, + version_name, + update_content, + download_url, + is_force, + platform, + create_time + from app_version + + + + + + + + + + + + insert into app_version + + version_code, + version_name, + update_content, + download_url, + is_force, + platform, + create_time + + + #{versionCode}, + #{versionName}, + #{updateContent}, + #{downloadUrl}, + #{isForce}, + #{platform}, + sysdate() + + + + + update app_version + + version_code = #{versionCode}, + version_name = #{versionName}, + update_content = #{updateContent}, + download_url = #{downloadUrl}, + is_force = #{isForce}, + platform = #{platform}, + + where version_id = #{versionId} + + + + delete + from app_version + where version_id = #{versionId} + + + + delete from app_version where version_id in + + #{versionId} + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/pom.xml b/ruoyi-modules/ruoyi-mp/pom.xml new file mode 100644 index 0000000..b0bde9f --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + org.dromara + ruoyi-modules + ${revision} + + + ruoyi-mp + 微信公众号模块 + + + + + org.dromara + ruoyi-common-core + + + + + org.dromara + ruoyi-common-web + + + + + org.dromara + ruoyi-common-satoken + + + + + com.github.binarywang + weixin-java-mp + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.projectlombok + lombok + true + + + + + cn.hutool + hutool-all + ${hutool.version} + + + + redis.clients + jedis + + + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/annotation/SwitchoverCheck.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/annotation/SwitchoverCheck.java new file mode 100644 index 0000000..654b8ed --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/annotation/SwitchoverCheck.java @@ -0,0 +1,16 @@ +package org.dromara.mp.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 微信公众号切换配置注解 + * + * @author Peng Les + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface SwitchoverCheck { +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/aspect/SwitchoverCheckAspect.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/aspect/SwitchoverCheckAspect.java new file mode 100644 index 0000000..112d090 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/aspect/SwitchoverCheckAspect.java @@ -0,0 +1,46 @@ +package org.dromara.mp.aspect; + +import me.chanjar.weixin.mp.api.WxMpService; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.annotation.Pointcut; +import org.dromara.common.core.utils.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 微信公众号切面 用于切换公众号配置 + * + * @author Peng Les + */ +@Aspect +@Component +public class SwitchoverCheckAspect { + + @Autowired + private WxMpService wxMpService; + + @Pointcut("@annotation(org.dromara.mp.annotation.SwitchoverCheck)") + public void switchoverCheckPointcut() { + } + + @Before("switchoverCheckPointcut()") + public void beforeSwitchover(JoinPoint joinPoint) { + // 获取方法参数 + Object[] args = joinPoint.getArgs(); + if (args.length > 0 && args[0] instanceof String) { + String appid = (String) args[0]; + System.out.println("前置切面:准备切换公众号配置,appid: " + appid); + + // 检查 appid 是否为空 + if (StringUtils.isEmpty(appid)) { + throw new IllegalArgumentException("appid 不能为空!"); + } + + if (!wxMpService.switchover(appid)) { + throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid)); + } + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpAbstractBuilder.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpAbstractBuilder.java new file mode 100644 index 0000000..73fbf31 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpAbstractBuilder.java @@ -0,0 +1,17 @@ +package org.dromara.mp.builder; + +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * @author Peng Les + */ +public abstract class WxMpAbstractBuilder { + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + public abstract WxMpXmlOutMessage build(String content, + WxMpXmlMessage wxMessage, WxMpService service); +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpImageBuilderWxMp.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpImageBuilderWxMp.java new file mode 100644 index 0000000..4ff8f6e --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpImageBuilderWxMp.java @@ -0,0 +1,23 @@ +package org.dromara.mp.builder; + +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutImageMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; + +/** + * @author Peng Les + */ +public class WxMpImageBuilderWxMp extends WxMpAbstractBuilder { + + @Override + public WxMpXmlOutMessage build(String content, WxMpXmlMessage wxMessage, + WxMpService service) { + logger.info("build 图片消息,内容:{}", content); + WxMpXmlOutImageMessage m = WxMpXmlOutMessage.IMAGE().mediaId(content) + .fromUser(wxMessage.getToUser()).toUser(wxMessage.getFromUser()) + .build(); + return m; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpTextBuilderWxMp.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpTextBuilderWxMp.java new file mode 100644 index 0000000..d48c3fe --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/builder/WxMpTextBuilderWxMp.java @@ -0,0 +1,23 @@ +package org.dromara.mp.builder; + +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutTextMessage; + +/** + * @author Peng Les + */ +public class WxMpTextBuilderWxMp extends WxMpAbstractBuilder { + + @Override + public WxMpXmlOutMessage build(String content, WxMpXmlMessage wxMessage, + WxMpService service) { + logger.info("build 文本消息,内容:{}", content); + WxMpXmlOutTextMessage m = WxMpXmlOutMessage.TEXT().content(content) + .fromUser(wxMessage.getToUser()).toUser(wxMessage.getFromUser()) + .build(); + return m; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpConfiguration.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpConfiguration.java new file mode 100644 index 0000000..7b29202 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpConfiguration.java @@ -0,0 +1,81 @@ +package org.dromara.mp.config; + +import lombok.RequiredArgsConstructor; +import me.chanjar.weixin.common.redis.JedisWxRedisOps; +import me.chanjar.weixin.mp.api.WxMpMessageRouter; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl; +import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl; +import me.chanjar.weixin.mp.config.impl.WxMpRedisConfigImpl; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.JedisPoolConfig; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 微信公众号配置 + * + * @author pengles + * + * 微信公众号服务端装配: + * - 读取 wx.mp.* 配置,支持多公众号与可选 Redis 存储 + * - 注入 WxMpService 与消息路由器,用于处理消息、二维码、模板消息等能力 + */ +@Configuration +@EnableConfigurationProperties(WxMpProperties.class) +@ConditionalOnClass(WxMpService.class) +@RequiredArgsConstructor +public class WxMpConfiguration { + + private final WxMpProperties properties; + + /** + * 构建 WxMpService,支持多公众号配置与 Redis 存储 + */ + @Bean + @ConditionalOnMissingBean + public WxMpService wxMpService() { + final List configs = this.properties.getConfigs(); + if (configs == null || configs.isEmpty()) { + throw new RuntimeException("请先配置微信公众号相关信息!"); + } + + WxMpService service = new WxMpServiceImpl(); + service.setMultiConfigStorages(configs + .stream().map(a -> { + WxMpDefaultConfigImpl configStorage; + if (this.properties.isUseRedis()) { + final WxMpProperties.RedisConfig redisConfig = this.properties.getRedisConfig(); + JedisPoolConfig poolConfig = new JedisPoolConfig(); + JedisPool jedisPool = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(), + redisConfig.getTimeout(), redisConfig.getPassword()); + configStorage = new WxMpRedisConfigImpl(new JedisWxRedisOps(jedisPool), a.getAppId()); + } else { + configStorage = new WxMpDefaultConfigImpl(); + } + + configStorage.setAppId(a.getAppId()); + configStorage.setSecret(a.getSecret()); + configStorage.setToken(a.getToken()); + configStorage.setAesKey(a.getAesKey()); + configStorage.setOauth2RedirectUrl(a.getOauth2RedirectUrl()); + configStorage.setQrConnectRedirectUrl(a.getQrConnectRedirectUrl()); + return configStorage; + }).collect(Collectors.toMap(WxMpDefaultConfigImpl::getAppId, a -> a, (o, n) -> o))); + return service; + } + + /** + * 构建消息路由器,按规则分发处理入口消息 + */ + @Bean + public WxMpMessageRouter messageRouter(WxMpService wxMpService) { + return new WxMpMessageRouter(wxMpService); + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpProperties.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpProperties.java new file mode 100644 index 0000000..c258809 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpProperties.java @@ -0,0 +1,87 @@ +package org.dromara.mp.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +/** + * 微信公众号属性绑定(wx.mp.*) + * - useRedis:是否使用 Redis 存储 access token + * - redisConfig:Redis 连接参数 + * - configs:多公众号配置(appid、secret、token、aesKey 以及各类回调地址) + */ +@Data +@ConfigurationProperties(prefix = "wx.mp") +public class WxMpProperties { + /** + * 是否使用redis存储access token + */ + private boolean useRedis; + + /** + * redis 配置 + */ + private RedisConfig redisConfig; + /** + * 多个公众号配置信息 + */ + private List configs; + + @Data + public static class RedisConfig { + /** + * redis服务器 主机地址 + */ + private String host; + /** + * redis服务器 端口号 + */ + private Integer port; + /** + * redis服务器 数据库索引 + */ + private Integer database; + /** + * redis服务器 密码 + */ + private String password; + /** + * redis服务器 连接超时时间 + */ + private Integer timeout; + } + + @Data + public static class MpConfig { + /** + * 设置微信公众号的appid + */ + private String appId; + + /** + * 设置微信公众号的app secret + */ + private String secret; + + /** + * 设置微信公众号的token + */ + private String token; + + /** + * 设置微信公众号的EncodingAESKey + */ + private String aesKey; + + /** + * 设置微信公众号网页授权获取用户信息回调地址 + */ + private String oauth2RedirectUrl; + + /** + * 设置微信公众号网页应用授权登陆回调地址回调地址 + */ + private String qrConnectRedirectUrl; + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpJsapiController.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpJsapiController.java new file mode 100644 index 0000000..0d7663d --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpJsapiController.java @@ -0,0 +1,107 @@ +package org.dromara.mp.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.bean.WxJsapiSignature; +import me.chanjar.weixin.mp.api.WxMpService; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 微信公众号JSAPI控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/jsapi/{appid}") +public class WxMpJsapiController extends BaseController { + + private final WxMpService wxMpService; + + /** + * 获取JSAPI票据 + * + * @param appid 公众号appid + * @param forceRefresh 是否强制刷新 + * @return JSAPI票据 + */ + @GetMapping("/ticket") + @SwitchoverCheck + public R getJsapiTicket(@PathVariable String appid, + @RequestParam(defaultValue = "false") boolean forceRefresh) { + try { + String ticket = wxMpService.getJsapiTicket(forceRefresh); + return R.ok(ticket); + } catch (Exception e) { + log.error("获取JSAPI票据失败: {}", e.getMessage(), e); + return R.fail("获取JSAPI票据失败: " + e.getMessage()); + } + } + + /** + * 创建JSAPI签名 + * + * @param appid 公众号appid + * @param url 当前网页的URL,不包含#及其后面部分 + * @return JSAPI签名信息 + */ + @GetMapping("/signature") + @SwitchoverCheck + public R createJsapiSignature(@PathVariable String appid, + @RequestParam String url) { + try { + WxJsapiSignature signature = wxMpService.createJsapiSignature(url); + return R.ok(signature); + } catch (Exception e) { + log.error("创建JSAPI签名失败: {}", e.getMessage(), e); + return R.fail("创建JSAPI签名失败: " + e.getMessage()); + } + } + + /** + * 获取微信卡券签名 + * + * @param appid 公众号appid + * @param url 当前网页的URL + * @return 卡券签名信息 + */ + @GetMapping("/card/signature") + @SwitchoverCheck + public R createCardSignature(@PathVariable String appid, + @RequestParam String url) { + try { + // 这里可以添加卡券签名的逻辑 + log.info("创建卡券签名: appid={}, url={}", appid, url); + return R.ok("卡券签名功能待实现"); + } catch (Exception e) { + log.error("创建卡券签名失败: {}", e.getMessage(), e); + return R.fail("创建卡券签名失败: " + e.getMessage()); + } + } + + /** + * 获取微信JS接口的临时票据 + * + * @param appid 公众号appid + * @param type 票据类型 + * @return 临时票据 + */ + @GetMapping("/ticket/{type}") + @SwitchoverCheck + public R getTicket(@PathVariable String appid, + @PathVariable String type) { + try { + String ticket = wxMpService.getTicket(me.chanjar.weixin.common.enums.TicketType.valueOf(type.toUpperCase())); + return R.ok(ticket); + } catch (Exception e) { + log.error("获取票据失败: {}", e.getMessage(), e); + return R.fail("获取票据失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpMediaController.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpMediaController.java new file mode 100644 index 0000000..247f3ca --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpMediaController.java @@ -0,0 +1,205 @@ +package org.dromara.mp.controller; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.bean.result.WxMediaUploadResult; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.material.WxMpMaterialFileBatchGetResult; +import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.dromara.mp.service.WxMpMaterialService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * 微信公众号素材管理控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/media/{appid}") +public class WxMpMediaController extends BaseController { + + private final WxMpService wxMpService; + private final WxMpMaterialService wxMpMaterialService; + + /** + * 上传临时素材 + * + * @param appid 公众号appid + * @param type 素材类型(image、voice、video、thumb) + * @param file 文件 + * @return 上传结果 + */ + @PostMapping("/upload/temp") + @SwitchoverCheck + public R uploadTempMedia(@PathVariable String appid, + @RequestParam String type, + @RequestParam("file") File file) { + try { + WxMediaUploadResult result = wxMpMaterialService.uploadTempMedia(type, file); + return R.ok(result); + } catch (Exception e) { + log.error("上传临时素材失败: {}", e.getMessage(), e); + return R.fail("上传临时素材失败: " + e.getMessage()); + } + } + + /** + * 下载临时素材 + * + * @param appid 公众号appid + * @param mediaId 素材ID + * @param response HTTP响应 + */ + @GetMapping("/download/temp/{mediaId}") + @SwitchoverCheck + public void downloadTempMedia(@PathVariable String appid, + @PathVariable String mediaId, + HttpServletResponse response) { + try { + InputStream inputStream = wxMpMaterialService.downloadTempMedia(mediaId); + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment; filename=" + mediaId); + + try (OutputStream outputStream = response.getOutputStream()) { + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + } + } catch (Exception e) { + log.error("下载临时素材失败: {}", e.getMessage(), e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + /** + * 上传永久素材 + * + * @param appid 公众号appid + * @param type 素材类型(image、voice、video、thumb) + * @param file 文件 + * @return 上传结果 + */ + @PostMapping("/upload/forever") + @SwitchoverCheck + public R uploadForeverMedia(@PathVariable String appid, + @RequestParam String type, + @RequestParam("file") File file) { + try { + WxMpMaterialUploadResult result = wxMpMaterialService.uploadMaterial(type, file); + return R.ok(result); + } catch (Exception e) { + log.error("上传永久素材失败: {}", e.getMessage(), e); + return R.fail("上传永久素材失败: " + e.getMessage()); + } + } + + /** + * 删除永久素材 + * + * @param appid 公众号appid + * @param mediaId 素材ID + * @return 删除结果 + */ + @DeleteMapping("/delete/forever/{mediaId}") + @SwitchoverCheck + public R deleteForeverMedia(@PathVariable String appid, @PathVariable String mediaId) { + try { + boolean result = wxMpMaterialService.deleteMaterial(mediaId); + return result ? R.ok() : R.fail("删除永久素材失败"); + } catch (Exception e) { + log.error("删除永久素材失败: {}", e.getMessage(), e); + return R.fail("删除永久素材失败: " + e.getMessage()); + } + } + + /** + * 获取永久素材 + * + * @param appid 公众号appid + * @param mediaId 素材ID + */ + @GetMapping("/get/forever/{mediaId}") + @SwitchoverCheck + public R getForeverMedia(@PathVariable String appid, + @PathVariable String mediaId) { + WxMpMaterialFileBatchGetResult.WxMaterialFileBatchGetNewsItem wxMpMaterialServiceMaterial = wxMpMaterialService.getMaterial(mediaId); + return R.ok(wxMpMaterialServiceMaterial); + } + + /** + * 获取素材总数 + * + * @param appid 公众号appid + * @return 素材总数 + */ + @GetMapping("/count") + @SwitchoverCheck + public R getMaterialCount(@PathVariable String appid) { + try { + Object count = wxMpMaterialService.getMaterialCount(); + return R.ok(count); + } catch (Exception e) { + log.error("获取素材总数失败: {}", e.getMessage(), e); + return R.fail("获取素材总数失败: " + e.getMessage()); + } + } + + /** + * 获取素材列表 + * + * @param appid 公众号appid + * @param type 素材类型 + * @param offset 偏移量 + * @param count 数量 + * @return 素材列表 + */ + @GetMapping("/list") + @SwitchoverCheck + public R getMaterialList(@PathVariable String appid, + @RequestParam String type, + @RequestParam(defaultValue = "0") int offset, + @RequestParam(defaultValue = "20") int count) { + try { + Object materialList = wxMpMaterialService.getMaterialList(type, offset, count); + return R.ok(materialList); + } catch (Exception e) { + log.error("获取素材列表失败: {}", e.getMessage(), e); + return R.fail("获取素材列表失败: " + e.getMessage()); + } + } + + /** + * 上传图文消息内的图片 + * + * @param appid 公众号appid + * @param file 图片文件 + * @return 上传结果 + */ + @PostMapping("/upload/img") + @SwitchoverCheck + public R uploadImg(@PathVariable String appid, + @RequestParam("file") File file) { + try { + String url = wxMpMaterialService.uploadImg(file); + return R.ok(url); + } catch (Exception e) { + log.error("上传图文消息图片失败: {}", e.getMessage(), e); + return R.fail("上传图文消息图片失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpMenuController.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpMenuController.java new file mode 100644 index 0000000..e4f52eb --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpMenuController.java @@ -0,0 +1,124 @@ +package org.dromara.mp.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.bean.menu.WxMenu; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.menu.WxMpMenu; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.dromara.mp.service.WxMpMenuService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 微信公众号菜单管理控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/menu/{appid}") +public class WxMpMenuController extends BaseController { + + private final WxMpService wxMpService; + private final WxMpMenuService wxMpMenuService; + + /** + * 创建菜单 + * + * @param appid 公众号appid + * @param wxMenu 菜单信息 + * @return 创建结果 + */ + @PostMapping("/create") + @SwitchoverCheck + public R createMenu(@PathVariable String appid, @RequestBody WxMenu wxMenu) { + try { + boolean result = wxMpMenuService.createMenu(wxMenu); + return result ? R.ok() : R.fail("创建菜单失败"); + } catch (Exception e) { + log.error("创建菜单失败: {}", e.getMessage(), e); + return R.fail("创建菜单失败: " + e.getMessage()); + } + } + + /** + * 删除菜单 + * + * @param appid 公众号appid + * @return 删除结果 + */ + @DeleteMapping("/delete") + @SwitchoverCheck + public R deleteMenu(@PathVariable String appid) { + try { + boolean result = wxMpMenuService.deleteMenu(); + return result ? R.ok() : R.fail("删除菜单失败"); + } catch (Exception e) { + log.error("删除菜单失败: {}", e.getMessage(), e); + return R.fail("删除菜单失败: " + e.getMessage()); + } + } + + /** + * 查询菜单 + * + * @param appid 公众号appid + * @return 菜单信息 + */ + @GetMapping("/get") + @SwitchoverCheck + public R getMenu(@PathVariable String appid) { + try { + WxMpMenu menu = wxMpMenuService.getMenu(); + return R.ok(menu); + } catch (Exception e) { + log.error("查询菜单失败: {}", e.getMessage(), e); + return R.fail("查询菜单失败: " + e.getMessage()); + } + } + + /** + * 创建默认菜单 + * + * @param appid 公众号appid + * @return 创建结果 + */ + @PostMapping("/create-default") + @SwitchoverCheck + public R createDefaultMenu(@PathVariable String appid) { + try { + boolean result = wxMpMenuService.createDefaultMenu(); + return result ? R.ok() : R.fail("创建默认菜单失败"); + } catch (Exception e) { + log.error("创建默认菜单失败: {}", e.getMessage(), e); + return R.fail("创建默认菜单失败: " + e.getMessage()); + } + } + + /** + * 处理菜单点击事件 + * + * @param appid 公众号appid + * @param eventKey 事件key + * @param openid 用户openid + * @return 处理结果 + */ + @PostMapping("/handle-click") + @SwitchoverCheck + public R handleMenuClick(@PathVariable String appid, + @RequestParam String eventKey, + @RequestParam String openid) { + try { + String response = wxMpMenuService.handleMenuClick(eventKey); + return R.ok(response); + } catch (Exception e) { + log.error("处理菜单点击事件失败: {}", e.getMessage(), e); + return R.fail("处理菜单点击事件失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpOAuth2Controller.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpOAuth2Controller.java new file mode 100644 index 0000000..72d6b7b --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpOAuth2Controller.java @@ -0,0 +1,235 @@ +package org.dromara.mp.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken; +import me.chanjar.weixin.common.enums.TicketType; +import me.chanjar.weixin.mp.api.WxMpService; +import org.apache.commons.codec.digest.DigestUtils; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * 微信公众号OAuth2控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/oauth2/{appid}") +public class WxMpOAuth2Controller extends BaseController { + + private final WxMpService wxMpService; + + /** + * 构建授权URL + * + * @param appid 公众号appid + * @param redirectUrl 重定向URL + * @param scope 授权作用域(snsapi_base静默授权,snsapi_userinfo用户信息授权) + * @param state 防CSRF攻击的随机字符串 + * @return 授权URL + */ + @GetMapping("/buildAuthorizationUrl") + @SwitchoverCheck + public R buildAuthorizationUrl(@PathVariable String appid, + @RequestParam String redirectUrl, + @RequestParam(defaultValue = "snsapi_userinfo") String scope, + @RequestParam String state) { + try { + String url = wxMpService.getOAuth2Service().buildAuthorizationUrl(redirectUrl, scope, state); + return R.ok(url); + } catch (Exception e) { + log.error("构建授权URL失败: {}", e.getMessage(), e); + return R.fail("构建授权URL失败: " + e.getMessage()); + } + } + + /** + * 获取访问令牌 + * + * @param appid 公众号appid + * @param code 授权码 + * @return 访问令牌信息 + */ + @GetMapping("/getAccessToken") + @SwitchoverCheck + public R getAccessToken(@PathVariable String appid, + @RequestParam String code) { + try { + WxOAuth2AccessToken accessToken = wxMpService.getOAuth2Service().getAccessToken(code); + return R.ok(accessToken); + } catch (Exception e) { + log.error("获取访问令牌失败: {}", e.getMessage(), e); + return R.fail("获取访问令牌失败: " + e.getMessage()); + } + } + + /** + * 刷新访问令牌 + * + * @param appid 公众号appid + * @param refreshToken 刷新令牌 + * @return 新的访问令牌信息 + */ + @GetMapping("/refreshAccessToken") + @SwitchoverCheck + public R refreshAccessToken(@PathVariable String appid, + @RequestParam String refreshToken) { + try { + WxOAuth2AccessToken accessToken = wxMpService.getOAuth2Service().refreshAccessToken(refreshToken); + return R.ok(accessToken); + } catch (Exception e) { + log.error("刷新访问令牌失败: {}", e.getMessage(), e); + return R.fail("刷新访问令牌失败: " + e.getMessage()); + } + } + + /** + * 验证访问令牌 + * + * @param appid 公众号appid + * @param accessToken 访问令牌 + * @param openId 用户openid + * @return 验证结果 + */ + @PostMapping("/validateAccessToken") + @SwitchoverCheck + public R validateAccessToken(@PathVariable String appid, + @RequestParam String accessToken, + @RequestParam String openId) { + try { + WxOAuth2AccessToken wxOAuth2AccessToken = new WxOAuth2AccessToken(); + wxOAuth2AccessToken.setAccessToken(accessToken); + wxOAuth2AccessToken.setOpenId(openId); + boolean isValid = wxMpService.getOAuth2Service().validateAccessToken(wxOAuth2AccessToken); + return R.ok(isValid); + } catch (Exception e) { + log.error("验证访问令牌失败: {}", e.getMessage(), e); + return R.fail("验证访问令牌失败: " + e.getMessage()); + } + } + + /** + * 获取JSAPI签名 + * + * @param appid 公众号appid + * @param url 当前网页URL + * @return JSAPI签名信息 + */ + @GetMapping("/getSignature") + @SwitchoverCheck + public R getSignature(@PathVariable String appid, + @RequestParam String url) { + try { + String jsapiTicket = wxMpService.getTicket(TicketType.JSAPI); + String nonceStr = UUID.randomUUID().toString(); + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + String signature = DigestUtils.sha1Hex( + "jsapi_ticket=" + jsapiTicket + + "&noncestr=" + nonceStr + + "×tamp=" + timestamp + + "&url=" + url + ); + + WxSignatureVo signatureVo = new WxSignatureVo(); + signatureVo.setAppId(appid); + signatureVo.setNonceStr(nonceStr); + signatureVo.setTimestamp(timestamp); + signatureVo.setSignature(signature); + signatureVo.setUrl(url); + + return R.ok(signatureVo); + } catch (Exception e) { + log.error("获取JSAPI签名失败: {}", e.getMessage(), e); + return R.fail("获取JSAPI签名失败: " + e.getMessage()); + } + } + + /** + * 获取用户信息 + * + * @param appid 公众号appid + * @param accessToken 访问令牌 + * @param openId 用户openid + * @param lang 语言 + * @return 用户信息 + */ + @GetMapping("/getUserInfo") + @SwitchoverCheck + public R getUserInfo(@PathVariable String appid, + @RequestParam String accessToken, + @RequestParam String openId, + @RequestParam(defaultValue = "zh_CN") String lang) { + try { + WxOAuth2AccessToken token = new WxOAuth2AccessToken(); + token.setAccessToken(accessToken); + token.setOpenId(openId); + + Object userInfo = wxMpService.getOAuth2Service().getUserInfo(token, lang); + return R.ok(userInfo); + } catch (Exception e) { + log.error("获取用户信息失败: {}", e.getMessage(), e); + return R.fail("获取用户信息失败: " + e.getMessage()); + } + } + + /** + * 签名VO类 + */ + public static class WxSignatureVo { + private String appId; + private String nonceStr; + private String timestamp; + private String signature; + private String url; + + // Getters and Setters + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getNonceStr() { + return nonceStr; + } + + public void setNonceStr(String nonceStr) { + this.nonceStr = nonceStr; + } + + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + public String getSignature() { + return signature; + } + + public void setSignature(String signature) { + this.signature = signature; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpOauth2RedirectController.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpOauth2RedirectController.java new file mode 100644 index 0000000..40a7a0b --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpOauth2RedirectController.java @@ -0,0 +1,177 @@ +package org.dromara.mp.controller; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.bean.WxOAuth2UserInfo; +import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.config.WxMpConfigStorage; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + +/** + * 微信公众号OAuth2授权回调控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/oauth2/redirect/{appid}") +public class WxMpOauth2RedirectController extends BaseController { + + private final WxMpService wxMpService; + + /** + * 微信授权回调处理 + * + * @param appid 公众号appid + * @param code 授权码 + * @param state 状态参数 + * @param response HTTP响应 + */ + @RequestMapping("/greet") + @SwitchoverCheck + public void greetUser(@PathVariable String appid, + @RequestParam String code, + @RequestParam String state, + HttpServletResponse response) { + try { + // 获取访问令牌 + WxOAuth2AccessToken wxOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(code); + + // 获取用户信息 + WxOAuth2UserInfo user = wxMpService.getOAuth2Service().getUserInfo(wxOAuth2AccessToken, null); + log.info("OAuth2授权用户信息: {}", user); + + // 获取配置信息 + WxMpConfigStorage mpConfigStorage = wxMpService.getWxMpConfigStorage(); + String redirectUrl = mpConfigStorage.getOauth2RedirectUrl(); + + // 构建重定向URL,携带用户信息 + String redirectWithToken = redirectUrl + "?unionId=" + user.getUnionId() + + "&openId=" + user.getOpenid() + + "&mpAccessToken=" + wxOAuth2AccessToken.getAccessToken() + + "&mpRefreshToken=" + wxOAuth2AccessToken.getRefreshToken() + + "&nickname=" + user.getNickname() + + "&headImgUrl=" + user.getHeadImgUrl(); + + // 执行重定向 + response.setStatus(HttpServletResponse.SC_FOUND); + response.setHeader("Location", redirectWithToken); + + } catch (Exception e) { + log.error("OAuth2授权回调处理失败: {}", e.getMessage(), e); + try { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "授权处理失败"); + } catch (IOException ioException) { + log.error("发送错误响应失败: {}", ioException.getMessage(), ioException); + } + } + } + + /** + * 获取授权用户信息(API接口) + * + * @param appid 公众号appid + * @param code 授权码 + * @param state 状态参数 + * @return 用户信息 + */ + @GetMapping("/userinfo") + @SwitchoverCheck + public R getUserInfo(@PathVariable String appid, + @RequestParam String code, + @RequestParam(required = false) String state) { + try { + // 获取访问令牌 + WxOAuth2AccessToken wxOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(code); + + // 获取用户信息 + WxOAuth2UserInfo user = wxMpService.getOAuth2Service().getUserInfo(wxOAuth2AccessToken, null); + log.info("获取OAuth2用户信息: {}", user); + + return R.ok(user); + } catch (Exception e) { + log.error("获取OAuth2用户信息失败: {}", e.getMessage(), e); + return R.fail("获取用户信息失败: " + e.getMessage()); + } + } + + /** + * 静默授权回调处理 + * + * @param appid 公众号appid + * @param code 授权码 + * @param state 状态参数 + * @return 访问令牌信息 + */ + @GetMapping("/silent") + @SwitchoverCheck + public R silentAuth(@PathVariable String appid, + @RequestParam String code, + @RequestParam(required = false) String state) { + try { + // 静默授权只获取访问令牌,不获取用户详细信息 + WxOAuth2AccessToken wxOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(code); + log.info("静默授权成功,openId: {}", wxOAuth2AccessToken.getOpenId()); + + return R.ok(wxOAuth2AccessToken); + } catch (Exception e) { + log.error("静默授权失败: {}", e.getMessage(), e); + return R.fail("静默授权失败: " + e.getMessage()); + } + } + + /** + * 自定义授权回调处理 + * + * @param appid 公众号appid + * @param code 授权码 + * @param state 状态参数 + * @param redirectUrl 自定义重定向URL + * @param response HTTP响应 + */ + @RequestMapping("/custom") + @SwitchoverCheck + public void customRedirect(@PathVariable String appid, + @RequestParam String code, + @RequestParam(required = false) String state, + @RequestParam String redirectUrl, + HttpServletResponse response) { + try { + // 获取访问令牌 + WxOAuth2AccessToken wxOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(code); + + // 获取用户信息 + WxOAuth2UserInfo user = wxMpService.getOAuth2Service().getUserInfo(wxOAuth2AccessToken, null); + log.info("自定义授权回调用户信息: {}", user); + + // 构建自定义重定向URL + String customRedirectUrl = redirectUrl + + (redirectUrl.contains("?") ? "&" : "?") + + "openId=" + user.getOpenid() + + "&unionId=" + user.getUnionId() + + "&accessToken=" + wxOAuth2AccessToken.getAccessToken(); + + // 执行重定向 + response.setStatus(HttpServletResponse.SC_FOUND); + response.setHeader("Location", customRedirectUrl); + + } catch (Exception e) { + log.error("自定义授权回调处理失败: {}", e.getMessage(), e); + try { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "授权处理失败"); + } catch (IOException ioException) { + log.error("发送错误响应失败: {}", ioException.getMessage(), ioException); + } + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpPortalController.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpPortalController.java new file mode 100644 index 0000000..212f653 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpPortalController.java @@ -0,0 +1,181 @@ +package org.dromara.mp.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.mp.api.WxMpMessageRouter; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.apache.commons.lang3.StringUtils; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 微信公众号消息处理控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/portal/{appid}") +public class WxMpPortalController extends BaseController { + + private final WxMpService wxMpService; + private final WxMpMessageRouter messageRouter; + + /** + * 微信公众号服务器配置验证 + * + * @param appid 公众号appid + * @param signature 微信加密签名 + * @param timestamp 时间戳 + * @param nonce 随机数 + * @param echostr 随机字符串 + * @return 验证结果 + */ + @GetMapping(produces = "text/plain;charset=utf-8") + @SwitchoverCheck + public String authGet(@PathVariable String appid, + @RequestParam(name = "signature", required = false) String signature, + @RequestParam(name = "timestamp", required = false) String timestamp, + @RequestParam(name = "nonce", required = false) String nonce, + @RequestParam(name = "echostr", required = false) String echostr) { + log.info("接收到来自微信服务器的认证消息:[{}, {}, {}, {}]", signature, timestamp, nonce, echostr); + + if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) { + log.error("请求参数非法,请核实!"); + return "非法请求"; + } + + try { + if (wxMpService.checkSignature(timestamp, nonce, signature)) { + log.info("微信公众号验证成功"); + return echostr; + } else { + log.error("微信公众号验证失败"); + return "非法请求"; + } + } catch (Exception e) { + log.error("微信公众号验证异常: {}", e.getMessage(), e); + return "验证异常"; + } + } + + /** + * 微信公众号消息处理 + * + * @param appid 公众号appid + * @param requestBody 请求体 + * @param signature 微信加密签名 + * @param timestamp 时间戳 + * @param nonce 随机数 + * @param openid 用户openid + * @param encType 加密类型 + * @param msgSignature 消息签名 + * @param request HTTP请求 + * @return 处理结果 + */ + @PostMapping(produces = "application/xml; charset=UTF-8") + @SwitchoverCheck + public String post(@PathVariable String appid, + @RequestBody String requestBody, + @RequestParam("signature") String signature, + @RequestParam("timestamp") String timestamp, + @RequestParam("nonce") String nonce, + @RequestParam("openid") String openid, + @RequestParam(name = "encrypt_type", required = false) String encType, + @RequestParam(name = "msg_signature", required = false) String msgSignature) { + log.info("接收微信请求:[openid=[{}], [signature=[{}], encType=[{}], msgSignature=[{}], timestamp=[{}], nonce=[{}], requestBody=[\n{}\n] ]", + openid, signature, encType, msgSignature, timestamp, nonce, requestBody); + + try { + if (!wxMpService.checkSignature(timestamp, nonce, signature)) { + log.error("非法请求,可能属于伪造的请求!"); + return "非法请求"; + } + + String out = null; + if (encType == null) { + // 明文传输的消息 + WxMpXmlMessage inMessage = WxMpXmlMessage.fromXml(requestBody); + WxMpXmlOutMessage outMessage = this.route(inMessage); + if (outMessage == null) { + return ""; + } + out = outMessage.toXml(); + } else if ("aes".equalsIgnoreCase(encType)) { + // aes加密的消息 + WxMpXmlMessage inMessage = WxMpXmlMessage.fromEncryptedXml(requestBody, wxMpService.getWxMpConfigStorage(), timestamp, nonce, msgSignature); + log.debug("消息解密后内容为:\n{} ", inMessage.toString()); + WxMpXmlOutMessage outMessage = this.route(inMessage); + if (outMessage == null) { + return ""; + } + out = outMessage.toEncryptedXml(wxMpService.getWxMpConfigStorage()); + } + + log.debug("组装回复信息:{}", out); + return out; + } catch (Exception e) { + log.error("微信消息处理异常: {}", e.getMessage(), e); + return ""; + } + } + + /** + * 消息路由处理 + * + * @param message 微信消息 + * @return 回复消息 + */ + private WxMpXmlOutMessage route(WxMpXmlMessage message) { + try { + return this.messageRouter.route(message); + } catch (Exception e) { + log.error("路由消息时出现异常!", e); + return null; + } + } + + /** + * 获取微信公众号配置信息 + * + * @param appid 公众号appid + * @return 配置信息 + */ + @GetMapping("/config") + @SwitchoverCheck + public R getConfig(@PathVariable String appid) { + try { + Object config = wxMpService.getWxMpConfigStorage(); + return R.ok(config); + } catch (Exception e) { + log.error("获取微信公众号配置失败: {}", e.getMessage(), e); + return R.fail("获取配置失败: " + e.getMessage()); + } + } + + /** + * 清理微信公众号配置缓存 + * + * @param appid 公众号appid + * @return 清理结果 + */ + @DeleteMapping("/config/cache") + @SwitchoverCheck + public R clearConfigCache(@PathVariable String appid) { + try { + // 这里可以添加清理缓存的逻辑 + log.info("清理微信公众号配置缓存: {}", appid); + return R.ok(); + } catch (Exception e) { + log.error("清理配置缓存失败: {}", e.getMessage(), e); + return R.fail("清理缓存失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpQrCodeController.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpQrCodeController.java new file mode 100644 index 0000000..569adbf --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpQrCodeController.java @@ -0,0 +1,129 @@ +package org.dromara.mp.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.result.WxMpQrCodeTicket; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.dromara.mp.service.WxMpQrCodeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 微信公众号二维码管理控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/qrcode/{appid}") +public class WxMpQrCodeController extends BaseController { + + private final WxMpService wxMpService; + private final WxMpQrCodeService wxMpQrCodeService; + + /** + * 生成临时二维码 + * + * @param appid 公众号appid + * @param expireSeconds 过期时间(秒) + * @param sceneStr 场景字符串 + * @return 二维码ticket + */ + @PostMapping("/temp") + @SwitchoverCheck + public R createTempQrCode(@PathVariable String appid, + @RequestParam(defaultValue = "2592000") int expireSeconds, + @RequestParam int sceneStr) { + try { + WxMpQrCodeTicket ticket = wxMpQrCodeService.createTempQrCode(expireSeconds, sceneStr); + return R.ok(ticket); + } catch (Exception e) { + log.error("生成临时二维码失败: {}", e.getMessage(), e); + return R.fail("生成临时二维码失败: " + e.getMessage()); + } + } + + /** + * 生成永久二维码 + * + * @param appid 公众号appid + * @param sceneStr 场景字符串 + * @return 二维码ticket + */ + @PostMapping("/forever") + @SwitchoverCheck + public R createForeverQrCode(@PathVariable String appid, + @RequestParam String sceneStr) { + try { + WxMpQrCodeTicket ticket = wxMpQrCodeService.createForeverQrCode(sceneStr); + return R.ok(ticket); + } catch (Exception e) { + log.error("生成永久二维码失败: {}", e.getMessage(), e); + return R.fail("生成永久二维码失败: " + e.getMessage()); + } + } + + /** + * 获取二维码URL + * + * @param appid 公众号appid + * @param ticket 二维码ticket + * @return 二维码URL + */ + @GetMapping("/url") + @SwitchoverCheck + public R getQrCodeUrl(@PathVariable String appid, @RequestParam String ticket) { + try { + String url = wxMpQrCodeService.getQrCodeUrl(ticket); + return R.ok(url); + } catch (Exception e) { + log.error("获取二维码URL失败: {}", e.getMessage(), e); + return R.fail("获取二维码URL失败: " + e.getMessage()); + } + } + + /** + * 生成活动二维码 + * + * @param appid 公众号appid + * @param activityId 活动ID + * @return 二维码ticket + */ + @PostMapping("/activity") + @SwitchoverCheck + public R createActivityQrCode(@PathVariable String appid, + @RequestParam String activityId) { + try { + WxMpQrCodeTicket ticket = wxMpQrCodeService.createActivityQrCode(activityId); + return R.ok(ticket); + } catch (Exception e) { + log.error("生成活动二维码失败: {}", e.getMessage(), e); + return R.fail("生成活动二维码失败: " + e.getMessage()); + } + } + + /** + * 生成渠道二维码 + * + * @param appid 公众号appid + * @param channelId 渠道ID + * @return 二维码ticket + */ + @PostMapping("/channel") + @SwitchoverCheck + public R createChannelQrCode(@PathVariable String appid, + @RequestParam String channelId) { + try { + WxMpQrCodeTicket ticket = wxMpQrCodeService.createChannelQrCode(channelId); + return R.ok(ticket); + } catch (Exception e) { + log.error("生成渠道二维码失败: {}", e.getMessage(), e); + return R.fail("生成渠道二维码失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpTemplateController.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpTemplateController.java new file mode 100644 index 0000000..3549fb5 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpTemplateController.java @@ -0,0 +1,226 @@ +package org.dromara.mp.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.template.WxMpTemplate; +import me.chanjar.weixin.mp.bean.template.WxMpTemplateIndustry; +import me.chanjar.weixin.mp.bean.template.WxMpTemplateIndustryEnum; +import me.chanjar.weixin.mp.bean.template.WxMpTemplateMessage; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.dromara.mp.service.WxMpTemplateService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 微信公众号模板消息控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/template/{appid}") +public class WxMpTemplateController extends BaseController { + + private final WxMpService wxMpService; + private final WxMpTemplateService wxMpTemplateService; + + /** + * 发送模板消息 + * + * @param appid 公众号appid + * @param templateMessage 模板消息 + * @return 发送结果 + */ + @PostMapping("/send") + @SwitchoverCheck + public R sendTemplateMessage(@PathVariable String appid, + @RequestBody WxMpTemplateMessage templateMessage) { + try { + String msgId = wxMpTemplateService.sendTemplateMessage(templateMessage); + return R.ok(msgId); + } catch (Exception e) { + log.error("发送模板消息失败: {}", e.getMessage(), e); + return R.fail("发送模板消息失败: " + e.getMessage()); + } + } + + /** + * 获取所有模板列表 + * + * @param appid 公众号appid + * @return 模板列表 + */ + @GetMapping("/list") + @SwitchoverCheck + public R> getAllTemplates(@PathVariable String appid) { + try { + List templates = wxMpTemplateService.getAllTemplates(); + return R.ok(templates); + } catch (Exception e) { + log.error("获取模板列表失败: {}", e.getMessage(), e); + return R.fail("获取模板列表失败: " + e.getMessage()); + } + } + + /** + * 删除模板 + * + * @param appid 公众号appid + * @param templateId 模板ID + * @return 删除结果 + */ + @DeleteMapping("/delete/{templateId}") + @SwitchoverCheck + public R deleteTemplate(@PathVariable String appid, @PathVariable String templateId) { + try { + boolean result = wxMpTemplateService.deleteTemplate(templateId); + return result ? R.ok() : R.fail("删除模板失败"); + } catch (Exception e) { + log.error("删除模板失败: {}", e.getMessage(), e); + return R.fail("删除模板失败: " + e.getMessage()); + } + } + + /** + * 获取行业信息 + * + * @param appid 公众号appid + * @return 行业信息 + */ + @GetMapping("/industry") + @SwitchoverCheck + public R getIndustry(@PathVariable String appid) { + try { + WxMpTemplateIndustry industry = wxMpService.getTemplateMsgService().getIndustry(); + return R.ok(industry); + } catch (Exception e) { + log.error("获取行业信息失败: {}", e.getMessage(), e); + return R.fail("获取行业信息失败: " + e.getMessage()); + } + } + + /** + * 设置行业信息 + * + * @param appid 公众号appid + * @param industryId1 行业代码1 + * @param industryId2 行业代码2 + * @return 设置结果 + */ + @PostMapping("/industry") + @SwitchoverCheck + public R setIndustry(@PathVariable String appid, + @RequestParam String industryId1, + @RequestParam String industryId2) { + try { + WxMpTemplateIndustry wxMpTemplateIndustry = new WxMpTemplateIndustry(); + wxMpTemplateIndustry.setPrimaryIndustry(WxMpTemplateIndustryEnum.valueOf(industryId1)); + wxMpTemplateIndustry.setSecondIndustry(WxMpTemplateIndustryEnum.valueOf(industryId2)); + wxMpService.getTemplateMsgService().setIndustry(wxMpTemplateIndustry); + return R.ok(); + } catch (Exception e) { + log.error("设置行业信息失败: {}", e.getMessage(), e); + return R.fail("设置行业信息失败: " + e.getMessage()); + } + } + + /** + * 发送通知消息 + * + * @param appid 公众号appid + * @param openid 用户openid + * @param templateId 模板ID + * @param url 跳转URL + * @param data 消息数据 + * @return 发送结果 + */ + @PostMapping("/send-notification") + @SwitchoverCheck + public R sendNotification(@PathVariable String appid, + @RequestParam String openid, + @RequestParam String templateId, + @RequestParam String url, + @RequestBody Map data) { + try { + String msgId = wxMpTemplateService.sendNotificationMessage(openid, templateId, url, data); + return R.ok(msgId); + } catch (Exception e) { + log.error("发送通知消息失败: {}", e.getMessage(), e); + return R.fail("发送通知消息失败: " + e.getMessage()); + } + } + + /** + * 发送订单消息 + * + * @param appid 公众号appid + * @param openid 用户openid + * @param data 订单数据 + * @return 发送结果 + */ + @PostMapping("/send-order") + @SwitchoverCheck + public R sendOrderMessage(@PathVariable String appid, + @RequestParam String openid, + @RequestBody Map data) { + try { + String msgId = wxMpTemplateService.sendOrderMessage(openid, null, null, null); + return R.ok(msgId); + } catch (Exception e) { + log.error("发送订单消息失败: {}", e.getMessage(), e); + return R.fail("发送订单消息失败: " + e.getMessage()); + } + } + + /** + * 发送审核结果消息 + * + * @param appid 公众号appid + * @param openid 用户openid + * @param data 审核数据 + * @return 发送结果 + */ + @PostMapping("/send-audit-result") + @SwitchoverCheck + public R sendAuditResult(@PathVariable String appid, + @RequestParam String openid, + @RequestBody Map data) { + try { + String msgId = wxMpTemplateService.sendAuditResultMessage(openid, null, null); + return R.ok(msgId); + } catch (Exception e) { + log.error("发送审核结果消息失败: {}", e.getMessage(), e); + return R.fail("发送审核结果消息失败: " + e.getMessage()); + } + } + + /** + * 发送活动通知 + * + * @param appid 公众号appid + * @param openid 用户openid + * @param data 活动数据 + * @return 发送结果 + */ + @PostMapping("/send-activity") + @SwitchoverCheck + public R sendActivityNotification(@PathVariable String appid, + @RequestParam String openid, + @RequestBody Map data) { + try { + String msgId = wxMpTemplateService.sendActivityNotificationMessage(openid, null, null, null); + return R.ok(msgId); + } catch (Exception e) { + log.error("发送活动通知失败: {}", e.getMessage(), e); + return R.fail("发送活动通知失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpUserController.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpUserController.java new file mode 100644 index 0000000..4fa398f --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpUserController.java @@ -0,0 +1,251 @@ +package org.dromara.mp.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.result.WxMpUser; +import me.chanjar.weixin.mp.bean.result.WxMpUserList; +import me.chanjar.weixin.mp.bean.tag.WxUserTag; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.dromara.mp.annotation.SwitchoverCheck; +import org.dromara.mp.service.WxMpUserService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 微信公众号用户管理控制器 + * + * @author pengles + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/wx/mp/user/{appid}") +public class WxMpUserController extends BaseController { + + private final WxMpService wxMpService; + private final WxMpUserService wxMpUserService; + + /** + * 获取用户信息 + * + * @param appid 公众号appid + * @param openid 用户openid + * @return 用户信息 + */ + @GetMapping("/info/{openid}") + @SwitchoverCheck + public R getUserInfo(@PathVariable String appid, @PathVariable String openid) { + try { + WxMpUser user = wxMpUserService.getUserInfo(openid); + return R.ok(user); + } catch (Exception e) { + log.error("获取用户信息失败: {}", e.getMessage(), e); + return R.fail("获取用户信息失败: " + e.getMessage()); + } + } + + /** + * 获取用户列表 + * + * @param appid 公众号appid + * @param nextOpenid 下一个openid + * @return 用户列表 + */ + @GetMapping("/list") + @SwitchoverCheck + public R getUserList(@PathVariable String appid, + @RequestParam(required = false) String nextOpenid) { + try { + return R.ok(wxMpUserService.getUserList(nextOpenid)); + } catch (Exception e) { + log.error("获取用户列表失败: {}", e.getMessage(), e); + return R.fail("获取用户列表失败: " + e.getMessage()); + } + } + + /** + * 获取所有用户 + * + * @param appid 公众号appid + * @return 所有用户openid列表 + */ + @GetMapping("/all") + @SwitchoverCheck + public R> getAllUsers(@PathVariable String appid) { + try { + List allUsers = wxMpUserService.getAllUsers(); + return R.ok(allUsers); + } catch (Exception e) { + log.error("获取所有用户失败: {}", e.getMessage(), e); + return R.fail("获取所有用户失败: " + e.getMessage()); + } + } + + /** + * 创建标签 + * + * @param appid 公众号appid + * @param tagName 标签名称 + * @return 创建结果 + */ + @PostMapping("/tag/create") + @SwitchoverCheck + public R createTag(@PathVariable String appid, @RequestParam String tagName) { + try { + WxUserTag tag = wxMpUserService.createTag(tagName); + return R.ok(tag); + } catch (Exception e) { + log.error("创建标签失败: {}", e.getMessage(), e); + return R.fail("创建标签失败: " + e.getMessage()); + } + } + + /** + * 获取所有标签 + * + * @param appid 公众号appid + * @return 标签列表 + */ + @GetMapping("/tag/list") + @SwitchoverCheck + public R> getTags(@PathVariable String appid) { + try { + List tags = wxMpUserService.getTags(); + return R.ok(tags); + } catch (Exception e) { + log.error("获取标签列表失败: {}", e.getMessage(), e); + return R.fail("获取标签列表失败: " + e.getMessage()); + } + } + + /** + * 更新标签 + * + * @param appid 公众号appid + * @param tagId 标签ID + * @param tagName 新标签名称 + * @return 更新结果 + */ + @PutMapping("/tag/{tagId}") + @SwitchoverCheck + public R updateTag(@PathVariable String appid, + @PathVariable Long tagId, + @RequestParam String tagName) { + try { + boolean result = wxMpUserService.updateTag(tagId, tagName); + return result ? R.ok() : R.fail("更新标签失败"); + } catch (Exception e) { + log.error("更新标签失败: {}", e.getMessage(), e); + return R.fail("更新标签失败: " + e.getMessage()); + } + } + + /** + * 删除标签 + * + * @param appid 公众号appid + * @param tagId 标签ID + * @return 删除结果 + */ + @DeleteMapping("/tag/{tagId}") + @SwitchoverCheck + public R deleteTag(@PathVariable String appid, @PathVariable Long tagId) { + try { + boolean result = wxMpUserService.deleteTag(tagId); + return result ? R.ok() : R.fail("删除标签失败"); + } catch (Exception e) { + log.error("删除标签失败: {}", e.getMessage(), e); + return R.fail("删除标签失败: " + e.getMessage()); + } + } + + /** + * 批量为用户打标签 + * + * @param appid 公众号appid + * @param tagId 标签ID + * @param openidList 用户openid列表 + * @return 操作结果 + */ + @PostMapping("/tag/{tagId}/users") + @SwitchoverCheck + public R batchTagging(@PathVariable String appid, + @PathVariable Long tagId, + @RequestBody List openidList) { + try { + boolean result = wxMpUserService.batchTagging(tagId, openidList); + return result ? R.ok() : R.fail("批量打标签失败"); + } catch (Exception e) { + log.error("批量打标签失败: {}", e.getMessage(), e); + return R.fail("批量打标签失败: " + e.getMessage()); + } + } + + /** + * 批量为用户取消标签 + * + * @param appid 公众号appid + * @param tagId 标签ID + * @param openidList 用户openid列表 + * @return 操作结果 + */ + @DeleteMapping("/tag/{tagId}/users") + @SwitchoverCheck + public R batchUntagging(@PathVariable String appid, + @PathVariable Long tagId, + @RequestBody List openidList) { + try { + boolean result = wxMpUserService.batchUntagging(tagId, openidList); + return result ? R.ok() : R.fail("批量取消标签失败"); + } catch (Exception e) { + log.error("批量取消标签失败: {}", e.getMessage(), e); + return R.fail("批量取消标签失败: " + e.getMessage()); + } + } + + /** + * 获取用户身上的标签列表 + * + * @param appid 公众号appid + * @param openid 用户openid + * @return 用户标签列表 + */ + @GetMapping("/tag/user/{openid}") + @SwitchoverCheck + public R> getUserTags(@PathVariable String appid, @PathVariable String openid) { + try { + List userTags = wxMpUserService.getUserTags(openid); + return R.ok(userTags); + } catch (Exception e) { + log.error("获取用户标签失败: {}", e.getMessage(), e); + return R.fail("获取用户标签失败: " + e.getMessage()); + } + } + + /** + * 获取标签下的用户列表 + * + * @param appid 公众号appid + * @param tagId 标签ID + * @param nextOpenid 下一个openid + * @return 用户列表 + */ + @GetMapping("/tag/{tagId}/users") + @SwitchoverCheck + public R> getTagUsers(@PathVariable String appid, + @PathVariable Long tagId, + @RequestParam(required = false) String nextOpenid) { + try { + List tagUsers = wxMpUserService.getTagUsers(tagId, nextOpenid); + return R.ok(tagUsers); + } catch (Exception e) { + log.error("获取标签用户列表失败: {}", e.getMessage(), e); + return R.fail("获取标签用户列表失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/AbstractHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/AbstractHandler.java new file mode 100644 index 0000000..e938431 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/AbstractHandler.java @@ -0,0 +1,67 @@ +package org.dromara.mp.handler; + +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpMessageHandler; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutTextMessage; + +import java.util.Map; + +/** + * 微信公众号消息处理器抽象类 + * + * @author pengles + */ +@Slf4j +public abstract class AbstractHandler implements WxMpMessageHandler { + + /** + * 构建文本消息 + * + * @param content 消息内容 + * @param wxMessage 微信消息 + * @param service 微信服务 + * @return 文本消息 + */ + protected WxMpXmlOutTextMessage buildTextMessage(String content, WxMpXmlMessage wxMessage, WxMpService service) { + return WxMpXmlOutTextMessage.TEXT().content(content) + .fromUser(wxMessage.getToUser()) + .toUser(wxMessage.getFromUser()) + .build(); + } + + /** + * 记录日志 + * + * @param wxMessage 微信消息 + */ + protected void logMessage(WxMpXmlMessage wxMessage) { + log.info("接收到微信消息:{}", wxMessage.toString()); + } + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, + WxMpService wxMpService, + WxSessionManager sessionManager) { + logMessage(wxMessage); + return doHandle(wxMessage, context, wxMpService, sessionManager); + } + + /** + * 具体的消息处理逻辑 + * + * @param wxMessage 微信消息 + * @param context 上下文 + * @param wxMpService 微信服务 + * @param sessionManager 会话管理器 + * @return 回复消息 + */ + protected abstract WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, + Map context, + WxMpService wxMpService, + WxSessionManager sessionManager); +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/KfSessionHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/KfSessionHandler.java new file mode 100644 index 0000000..cab5658 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/KfSessionHandler.java @@ -0,0 +1,30 @@ +package org.dromara.mp.handler; + +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * @author Peng Les + */ +@Component +public class KfSessionHandler extends AbstractHandler { + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, WxMpService wxMpService, + WxSessionManager sessionManager) { + //TODO 对会话做处理 + return null; + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/LocationHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/LocationHandler.java new file mode 100644 index 0000000..0069eda --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/LocationHandler.java @@ -0,0 +1,52 @@ +package org.dromara.mp.handler; + +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.dromara.mp.builder.WxMpTextBuilderWxMp; +import org.springframework.stereotype.Component; + +import java.util.Map; + +import static me.chanjar.weixin.common.api.WxConsts.XmlMsgType; + +/** + * 地理位消息处理 + * + * @author Peng Les + */ +@Slf4j +@Component +public class LocationHandler extends AbstractHandler { + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, WxMpService wxMpService, + WxSessionManager sessionManager) { + if (wxMessage.getMsgType().equals(XmlMsgType.LOCATION)) { + // TODO 接收处理用户发送的地理位置消息 + try { + String content = "感谢反馈,您的的地理位置已收到!"; + return new WxMpTextBuilderWxMp().build(content, wxMessage, null); + } catch (Exception e) { + return null; + } + } + + //上报地理位置事件 + log.info("上报地理位置,纬度 : {},经度 : {},精度 : {}", + wxMessage.getLatitude(), wxMessage.getLongitude(), wxMessage.getPrecision()); + + // TODO 可以将用户地理位置信息保存到本地数据库,以便以后使用 + + return null; + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/LogHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/LogHandler.java new file mode 100644 index 0000000..9733258 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/LogHandler.java @@ -0,0 +1,28 @@ +package org.dromara.mp.handler; + +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * @author Peng Les + */ +@Component +public class LogHandler extends AbstractHandler { + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, WxMpService wxMpService, + WxSessionManager sessionManager) { + return null; + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/MenuHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/MenuHandler.java new file mode 100644 index 0000000..e6f9776 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/MenuHandler.java @@ -0,0 +1,42 @@ +package org.dromara.mp.handler; + +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +import static me.chanjar.weixin.common.api.WxConsts.EventType; + +/** + * 自定义菜单事件 + * + * @author Peng Les + */ +@Component +public class MenuHandler extends AbstractHandler { + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, WxMpService weixinService, + WxSessionManager sessionManager) { + String msg = String.format("type:%s, event:%s, key:%s", + wxMessage.getMsgType(), wxMessage.getEvent(), + wxMessage.getEventKey()); + if (EventType.VIEW.equals(wxMessage.getEvent())) { + return null; + } + + return WxMpXmlOutMessage.TEXT().content(msg) + .fromUser(wxMessage.getToUser()).toUser(wxMessage.getFromUser()) + .build(); + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/MsgHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/MsgHandler.java new file mode 100644 index 0000000..75e96fb --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/MsgHandler.java @@ -0,0 +1,44 @@ +package org.dromara.mp.handler; + +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 文本消息处理器 + * + * @author pengles + */ +@Component +public class MsgHandler extends AbstractHandler { + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, + Map context, + WxMpService wxMpService, + WxSessionManager sessionManager) { + + String content = wxMessage.getContent(); + + // 简单的自动回复逻辑 + String replyContent; + if ("你好".equals(content) || "hello".equalsIgnoreCase(content)) { + replyContent = "您好!欢迎关注我们的公众号!"; + } else if ("帮助".equals(content) || "help".equalsIgnoreCase(content)) { + replyContent = "您可以发送以下关键词获取帮助:\n" + + "1. 你好 - 获取欢迎信息\n" + + "2. 帮助 - 获取帮助信息\n" + + "3. 联系我们 - 获取联系方式"; + } else if ("联系我们".equals(content)) { + replyContent = "如需帮助,请联系客服:400-123-4567"; + } else { + replyContent = "感谢您的消息,我们已收到。如需帮助,请回复'帮助'。"; + } + + return buildTextMessage(replyContent, wxMessage, wxMpService); + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/NullHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/NullHandler.java new file mode 100644 index 0000000..da0b616 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/NullHandler.java @@ -0,0 +1,32 @@ +package org.dromara.mp.handler; + +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * @author Peng Les + */ +@Slf4j +@Component +public class NullHandler extends AbstractHandler { + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, WxMpService wxMpService, + WxSessionManager sessionManager) { + log.info("\n接收到请求消息,内容:{}", wxMessage.toString()); + return null; + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/ScanHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/ScanHandler.java new file mode 100644 index 0000000..2d9fb62 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/ScanHandler.java @@ -0,0 +1,62 @@ +package org.dromara.mp.handler; + +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import me.chanjar.weixin.mp.bean.result.WxMpUser; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 扫描事件处理 + * + * @author Peng Les + */ +@Component +public class ScanHandler extends AbstractHandler { + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map map, + WxMpService wxMpService, WxSessionManager wxSessionManager) { + return doHandle(wxMessage, map, wxMpService, wxSessionManager); + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + String eventKey = wxMessage.getEventKey(); + String openId = wxMessage.getFromUser(); + if (StringUtils.isBlank(eventKey) || StringUtils.isBlank(openId)) { + return buildTextMessage("参数缺失,绑定失败。", wxMessage, wxMpService); + } + // 处理二维码场景值 + String scene = eventKey; + if (scene.startsWith("qrscene_")) { + scene = scene.substring("qrscene_".length()); + } + if (!scene.startsWith("sys_user:")) { + return buildTextMessage("二维码场景不匹配,未执行绑定。", wxMessage, wxMpService); + } + String userIdStr = scene.substring("sys_user:".length()); + Long userId = null; + try { + userId = Long.valueOf(userIdStr); + } catch (Exception e) { + return buildTextMessage("用户标识解析失败,绑定终止。", wxMessage, wxMpService); + } + + String unionId = null; + try { + WxMpUser user = wxMpService.getUserService().userInfo(openId); + unionId = user.getUnionId(); + } catch (Exception ignore) { + } + + String msg = "绑定请求已识别。\nopenid:" + openId + + (StringUtils.isNotBlank(unionId) ? ("\nunionId:" + unionId) : "") + + "\n请在后台完成管理员微信绑定。"; + return buildTextMessage(msg, wxMessage, wxMpService); + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/StoreCheckNotifyHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/StoreCheckNotifyHandler.java new file mode 100644 index 0000000..eee13c0 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/StoreCheckNotifyHandler.java @@ -0,0 +1,32 @@ +package org.dromara.mp.handler; + +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 门店审核事件处理 + * + * @author Peng Les + */ +@Component +public class StoreCheckNotifyHandler extends AbstractHandler { + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, WxMpService wxMpService, + WxSessionManager sessionManager) { + // TODO 处理门店审核事件 + return null; + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/SubscribeHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/SubscribeHandler.java new file mode 100644 index 0000000..80dbb96 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/SubscribeHandler.java @@ -0,0 +1,34 @@ +package org.dromara.mp.handler; + +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 关注事件处理器 + * + * @author pengles + */ +@Component +public class SubscribeHandler extends AbstractHandler { + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, + Map context, + WxMpService wxMpService, + WxSessionManager sessionManager) { + + String welcomeMsg = "感谢您关注我们的公众号!\n\n" + + "您可以发送以下关键词获取帮助:\n" + + "• 你好 - 获取欢迎信息\n" + + "• 帮助 - 获取帮助信息\n" + + "• 联系我们 - 获取联系方式\n\n" + + "我们将为您提供优质的服务!"; + + return buildTextMessage(welcomeMsg, wxMessage, wxMpService); + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/UnsubscribeHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/UnsubscribeHandler.java new file mode 100644 index 0000000..d6a68cd --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/UnsubscribeHandler.java @@ -0,0 +1,35 @@ +package org.dromara.mp.handler; + +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 取消关注事件处理 + * + * @author pengles + */ +@Slf4j +@Component +public class UnsubscribeHandler extends AbstractHandler { + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, WxMpService wxMpService, + WxSessionManager sessionManager) { + log.info("取消关注用户 openId: {}, unionId: {}", wxMessage.getFromUser(), wxMessage.getUnionId()); + + return null; + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + return null; + } + +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/UserAuthorizationRevokeHandler.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/UserAuthorizationRevokeHandler.java new file mode 100644 index 0000000..2dd9d42 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/handler/UserAuthorizationRevokeHandler.java @@ -0,0 +1,34 @@ +package org.dromara.mp.handler; + +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.session.WxSessionManager; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 用户撤回授权处理 + * + * @author: Peng Les + */ +@Slf4j +@Component +public class UserAuthorizationRevokeHandler extends AbstractHandler { + + @Override + public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, + Map context, WxMpService wxMpService, + WxSessionManager sessionManager) { + log.info("用户撤回授权 openId: {}, unionId: {}", wxMessage.getFromUser(), wxMessage.getUnionId()); + + return null; + } + + @Override + protected WxMpXmlOutMessage doHandle(WxMpXmlMessage wxMessage, Map context, WxMpService wxMpService, WxSessionManager sessionManager) { + return null; + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMaterialService.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMaterialService.java new file mode 100644 index 0000000..232400f --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMaterialService.java @@ -0,0 +1,84 @@ +package org.dromara.mp.service; + +import me.chanjar.weixin.common.bean.result.WxMediaUploadResult; +import me.chanjar.weixin.mp.bean.material.WxMpMaterialCountResult; +import me.chanjar.weixin.mp.bean.material.WxMpMaterialFileBatchGetResult; +import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult; + +import java.io.File; +import java.io.InputStream; + +/** + * 微信公众号素材管理服务接口 + * + * @author pengles + */ +public interface WxMpMaterialService { + + /** + * 上传临时素材 + * + * @param mediaType 媒体类型 + * @param file 文件 + * @return 上传结果 + */ + WxMediaUploadResult uploadTempMedia(String mediaType, File file); + + /** + * 下载临时素材 + * + * @param mediaId 媒体ID + * @return 文件输入流 + */ + InputStream downloadTempMedia(String mediaId); + + /** + * 上传永久素材 + * + * @param mediaType 媒体类型 + * @param file 文件 + * @return 上传结果 + */ + WxMpMaterialUploadResult uploadMaterial(String mediaType, File file); + + /** + * 删除永久素材 + * + * @param mediaId 媒体ID + * @return 是否删除成功 + */ + boolean deleteMaterial(String mediaId); + + /** + * 获取永久素材 + * + * @param mediaId 媒体ID + * @return 素材信息 + */ + WxMpMaterialFileBatchGetResult.WxMaterialFileBatchGetNewsItem getMaterial(String mediaId); + + /** + * 获取素材列表 + * + * @param type 素材类型 + * @param offset 偏移量 + * @param count 数量 + * @return 素材列表 + */ + WxMpMaterialFileBatchGetResult getMaterialList(String type, int offset, int count); + + /** + * 获取素材总数 + * + * @return 素材总数 + */ + WxMpMaterialCountResult getMaterialCount(); + + /** + * 上传图文消息内的图片 + * + * @param file 图片文件 + * @return 图片URL + */ + String uploadImg(File file); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMenuService.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMenuService.java new file mode 100644 index 0000000..94c1c0a --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMenuService.java @@ -0,0 +1,50 @@ +package org.dromara.mp.service; + + +import me.chanjar.weixin.common.bean.menu.WxMenu; +import me.chanjar.weixin.mp.bean.menu.WxMpMenu; + +/** + * 微信公众号菜单管理服务接口 + * + * @author pengles + */ +public interface WxMpMenuService { + + /** + * 创建菜单 + * + * @param menu 菜单对象 + * @return 是否成功 + */ + boolean createMenu(WxMenu menu); + + /** + * 删除菜单 + * + * @return 是否成功 + */ + boolean deleteMenu(); + + /** + * 查询菜单 + * + * @return 菜单对象 + */ + WxMpMenu getMenu(); + + /** + * 创建默认菜单 + * + * @return 是否成功 + */ + boolean createDefaultMenu(); + + /** + * 处理菜单点击事件 + * + * @param eventKey 事件key + * @return 响应消息 + */ + String handleMenuClick(String eventKey); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMessageService.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMessageService.java new file mode 100644 index 0000000..54e5042 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpMessageService.java @@ -0,0 +1,123 @@ +package org.dromara.mp.service; + +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; + +/** + * 微信公众号消息处理服务接口 + * + * @author pengles + */ +public interface WxMpMessageService { + + /** + * 处理文本消息 + * + * @param inMessage 接收到的消息 + * @return 回复消息 + */ + WxMpXmlOutMessage handleTextMessage(WxMpXmlMessage inMessage); + + /** + * 处理图片消息 + * + * @param inMessage 接收到的消息 + * @return 回复消息 + */ + WxMpXmlOutMessage handleImageMessage(WxMpXmlMessage inMessage); + + /** + * 处理语音消息 + * + * @param inMessage 接收到的消息 + * @return 回复消息 + */ + WxMpXmlOutMessage handleVoiceMessage(WxMpXmlMessage inMessage); + + /** + * 处理视频消息 + * + * @param inMessage 接收到的消息 + * @return 回复消息 + */ + WxMpXmlOutMessage handleVideoMessage(WxMpXmlMessage inMessage); + + /** + * 处理位置消息 + * + * @param inMessage 接收到的消息 + * @return 回复消息 + */ + WxMpXmlOutMessage handleLocationMessage(WxMpXmlMessage inMessage); + + /** + * 处理链接消息 + * + * @param inMessage 接收到的消息 + * @return 回复消息 + */ + WxMpXmlOutMessage handleLinkMessage(WxMpXmlMessage inMessage); + + /** + * 处理事件消息 + * + * @param inMessage 接收到的消息 + * @return 回复消息 + */ + WxMpXmlOutMessage handleEventMessage(WxMpXmlMessage inMessage); + + /** + * 自动回复消息 + * + * @param keyword 关键词 + * @param openId 用户openId + * @return 回复内容 + */ + String getAutoReplyContent(String keyword, String openId); + + /** + * 保存用户消息记录 + * + * @param inMessage 接收到的消息 + */ + void saveUserMessage(WxMpXmlMessage inMessage); + + /** + * 保存回复消息记录 + * + * @param outMessage 回复的消息 + * @param openId 用户openId + */ + void saveReplyMessage(WxMpXmlOutMessage outMessage, String openId); + + /** + * 获取默认回复内容 + * + * @return 默认回复内容 + */ + String getDefaultReplyContent(); + + /** + * 处理关注事件 + * + * @param openId 用户openId + * @return 欢迎消息 + */ + String handleSubscribeEvent(String openId); + + /** + * 处理取消关注事件 + * + * @param openId 用户openId + */ + void handleUnsubscribeEvent(String openId); + + /** + * 处理扫码事件 + * + * @param openId 用户openId + * @param sceneStr 场景值 + * @return 回复内容 + */ + String handleScanEvent(String openId, String sceneStr); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpQrCodeService.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpQrCodeService.java new file mode 100644 index 0000000..8b12801 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpQrCodeService.java @@ -0,0 +1,76 @@ +package org.dromara.mp.service; + +import me.chanjar.weixin.mp.bean.result.WxMpQrCodeTicket; + +/** + * 微信公众号二维码生成服务接口 + * + * @author pengles + */ +public interface WxMpQrCodeService { + + /** + * 创建临时二维码 + * + * @param sceneId 场景值ID + * @param expireSeconds 过期时间(秒) + * @return 二维码ticket + */ + WxMpQrCodeTicket createTempQrCode(int sceneId, int expireSeconds); + + /** + * 创建永久二维码 + * + * @param sceneStr 场景值字符串 + * @return 二维码ticket + */ + WxMpQrCodeTicket createPermanentQrCode(String sceneStr); + + /** + * 创建永久二维码(别名方法) + * + * @param sceneStr 场景值字符串 + * @return 二维码ticket + */ + WxMpQrCodeTicket createForeverQrCode(String sceneStr); + + /** + * 创建永久二维码 + * + * @param sceneId 场景值ID + * @return 二维码ticket + */ + WxMpQrCodeTicket createPermanentQrCode(int sceneId); + + /** + * 通过ticket换取二维码图片URL + * + * @param ticket 二维码ticket + * @return 二维码图片URL + */ + String getQrCodeUrl(String ticket); + + /** + * 创建用户邀请二维码 + * + * @param userId 用户ID + * @return 二维码ticket + */ + WxMpQrCodeTicket createUserInviteQrCode(String userId); + + /** + * 创建活动推广二维码 + * + * @param activityId 活动ID + * @return 二维码ticket + */ + WxMpQrCodeTicket createActivityQrCode(String activityId); + + /** + * 创建渠道推广二维码 + * + * @param channelId 渠道ID + * @return 二维码ticket + */ + WxMpQrCodeTicket createChannelQrCode(String channelId); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpStatisticsService.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpStatisticsService.java new file mode 100644 index 0000000..81fea1f --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpStatisticsService.java @@ -0,0 +1,158 @@ +package org.dromara.mp.service; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** + * 微信公众号数据统计服务接口 + * + * @author pengles + */ +public interface WxMpStatisticsService { + + /** + * 获取用户增减数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 用户增减数据 + */ + List> getUserSummary(LocalDate beginDate, LocalDate endDate); + + /** + * 获取用户累计数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 用户累计数据 + */ + List> getUserCumulate(LocalDate beginDate, LocalDate endDate); + + /** + * 获取图文群发每日数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 图文群发数据 + */ + List> getArticleSummary(LocalDate beginDate, LocalDate endDate); + + /** + * 获取图文群发总数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 图文群发总数据 + */ + List> getArticleTotal(LocalDate beginDate, LocalDate endDate); + + /** + * 获取图文统计数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 图文统计数据 + */ + List> getUserRead(LocalDate beginDate, LocalDate endDate); + + /** + * 获取图文统计分时数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 图文统计分时数据 + */ + List> getUserReadHour(LocalDate beginDate, LocalDate endDate); + + /** + * 获取图文分享转发数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 图文分享转发数据 + */ + List> getUserShare(LocalDate beginDate, LocalDate endDate); + + /** + * 获取图文分享转发分时数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 图文分享转发分时数据 + */ + List> getUserShareHour(LocalDate beginDate, LocalDate endDate); + + /** + * 获取消息发送概况数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 消息发送概况数据 + */ + List> getUpstreamMsg(LocalDate beginDate, LocalDate endDate); + + /** + * 获取消息分送分时数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 消息分送分时数据 + */ + List> getUpstreamMsgHour(LocalDate beginDate, LocalDate endDate); + + /** + * 获取消息关键词发送概况 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 消息关键词发送概况 + */ + List> getUpstreamMsgWeek(LocalDate beginDate, LocalDate endDate); + + + /** + * 获取菜单分析数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 菜单分析数据 + */ + List> getMenuSummary(LocalDate beginDate, LocalDate endDate); + + /** + * 获取统计概览数据 + * + * @return 统计概览 + */ + Map getStatisticsOverview(); + + /** + * 获取用户画像数据 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @return 用户画像数据 + */ + Map getUserPortrait(LocalDate beginDate, LocalDate endDate); + + /** + * 获取热门内容排行 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @param limit 限制数量 + * @return 热门内容排行 + */ + List> getPopularContent(LocalDate beginDate, LocalDate endDate, int limit); + + /** + * 获取关键词统计 + * + * @param beginDate 开始日期 + * @param endDate 结束日期 + * @param limit 限制数量 + * @return 关键词统计 + */ + List> getKeywordStatistics(LocalDate beginDate, LocalDate endDate, int limit); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpTemplateService.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpTemplateService.java new file mode 100644 index 0000000..f2b14ae --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpTemplateService.java @@ -0,0 +1,81 @@ +package org.dromara.mp.service; + +import me.chanjar.weixin.mp.bean.template.WxMpTemplate; +import me.chanjar.weixin.mp.bean.template.WxMpTemplateMessage; + +import java.util.List; +import java.util.Map; + +/** + * 微信公众号模板消息服务接口 + * + * @author pengles + */ +public interface WxMpTemplateService { + + /** + * 发送模板消息 + * + * @param templateMessage 模板消息对象 + * @return 消息ID + */ + String sendTemplateMessage(WxMpTemplateMessage templateMessage); + + /** + * 获取所有模板列表 + * + * @return 模板列表 + */ + List getAllTemplates(); + + /** + * 删除模板 + * + * @param templateId 模板ID + * @return 是否成功 + */ + boolean deleteTemplate(String templateId); + + /** + * 发送通知模板消息 + * + * @param openId 用户openId + * @param templateId 模板ID + * @param url 跳转链接 + * @param data 模板数据 + * @return 消息ID + */ + String sendNotificationMessage(String openId, String templateId, String url, Map data); + + /** + * 发送订单模板消息 + * + * @param openId 用户openId + * @param orderNo 订单号 + * @param amount 金额 + * @param status 状态 + * @return 消息ID + */ + String sendOrderMessage(String openId, String orderNo, String amount, String status); + + /** + * 发送审核结果模板消息 + * + * @param openId 用户openId + * @param result 审核结果 + * @param reason 审核原因 + * @return 消息ID + */ + String sendAuditResultMessage(String openId, String result, String reason); + + /** + * 发送活动通知模板消息 + * + * @param openId 用户openId + * @param activityName 活动名称 + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 消息ID + */ + String sendActivityNotificationMessage(String openId, String activityName, String startTime, String endTime); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpUserService.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpUserService.java new file mode 100644 index 0000000..3bbef0c --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/WxMpUserService.java @@ -0,0 +1,133 @@ +package org.dromara.mp.service; + +import me.chanjar.weixin.mp.bean.result.WxMpUser; +import me.chanjar.weixin.mp.bean.result.WxMpUserList; +import me.chanjar.weixin.mp.bean.tag.WxTagListUser; +import me.chanjar.weixin.mp.bean.tag.WxUserTag; + +import java.util.List; + +/** + * 微信公众号用户管理服务接口 + * + * @author pengles + */ +public interface WxMpUserService { + + /** + * 获取用户基本信息 + * + * @param openId 用户openId + * @return 用户信息 + */ + WxMpUser getUserInfo(String openId); + + /** + * 获取用户列表 + * + * @param nextOpenId 下一个openId,用于分页 + * @return 用户列表 + */ + WxMpUserList getUserList(String nextOpenId); + + /** + * 获取所有用户列表 + * + * @return 所有用户的openId列表 + */ + List getAllUsers(); + + /** + * 创建标签 + * + * @param tagName 标签名称 + * @return 标签对象 + */ + WxUserTag createTag(String tagName); + + /** + * 获取所有标签 + * + * @return 标签列表 + */ + List getTags(); + + /** + * 更新标签 + * + * @param tagId 标签ID + * @param tagName 新标签名称 + * @return 是否成功 + */ + boolean updateTag(Long tagId, String tagName); + + /** + * 删除标签 + * + * @param tagId 标签ID + * @return 是否成功 + */ + boolean deleteTag(Long tagId); + + /** + * 给用户打标签 + * + * @param tagId 标签ID + * @param openIds 用户openId列表 + * @return 是否成功 + */ + boolean tagUsers(Long tagId, String[] openIds); + + /** + * 取消用户标签 + * + * @param tagId 标签ID + * @param openIds 用户openId列表 + * @return 是否成功 + */ + boolean untagUsers(Long tagId, String[] openIds); + + /** + * 获取用户的标签列表 + * + * @param openId 用户openId + * @return 标签ID列表 + */ + List getUserTags(String openId); + + /** + * 获取标签下的用户列表 + * + * @param tagId 标签ID + * @param nextOpenId 下一个openId,用于分页 + * @return 用户列表 + */ + WxTagListUser getUsersByTag(Long tagId, String nextOpenId); + + /** + * 获取标签下的用户openId列表 + * + * @param tagId 标签ID + * @param nextOpenid 下一个openid,用于分页 + * @return 用户openId列表 + */ + List getTagUsers(Long tagId, String nextOpenid); + + /** + * 批量为用户打标签 + * + * @param tagId 标签ID + * @param openidList 用户openid列表 + * @return 操作结果 + */ + boolean batchTagging(Long tagId, List openidList); + + /** + * 批量为用户取消标签 + * + * @param tagId 标签ID + * @param openidList 用户openid列表 + * @return 操作结果 + */ + boolean batchUntagging(Long tagId, List openidList); +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMaterialServiceImpl.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMaterialServiceImpl.java new file mode 100644 index 0000000..9447885 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMaterialServiceImpl.java @@ -0,0 +1,139 @@ +package org.dromara.mp.service.impl; + +import me.chanjar.weixin.common.bean.result.WxMediaUploadResult; +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.material.*; +import org.dromara.mp.service.WxMpMaterialService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.InputStream; + +/** + * 微信公众号素材管理服务实现类 + * + * @author pengles + */ +@Service +public class WxMpMaterialServiceImpl implements WxMpMaterialService { + + private static final Logger logger = LoggerFactory.getLogger(WxMpMaterialServiceImpl.class); + + @Autowired + private WxMpService wxMpService; + + @Override + public WxMediaUploadResult uploadTempMedia(String mediaType, File file) { + try { + WxMediaUploadResult result = wxMpService.getMaterialService().mediaUpload(mediaType, file); + logger.info("上传临时素材成功: {}", result.getMediaId()); + return result; + } catch (WxErrorException e) { + logger.error("上传临时素材失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public InputStream downloadTempMedia(String mediaId) { + try { + File file = wxMpService.getMaterialService().mediaDownload(mediaId); + logger.info("下载临时素材成功: {}", mediaId); + return file.toURI().toURL().openStream(); + } catch (Exception e) { + logger.error("下载临时素材失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxMpMaterialUploadResult uploadMaterial(String mediaType, File file) { + try { + WxMpMaterial material = new WxMpMaterial(); + material.setFile(file); + material.setName(file.getName()); + + WxMpMaterialUploadResult result = wxMpService.getMaterialService().materialFileUpload(mediaType, material); + logger.info("上传永久素材成功: {}", result.getMediaId()); + return result; + } catch (WxErrorException e) { + logger.error("上传永久素材失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public boolean deleteMaterial(String mediaId) { + try { + boolean result = wxMpService.getMaterialService().materialDelete(mediaId); + logger.info("删除永久素材成功: {}", mediaId); + return result; + } catch (WxErrorException e) { + logger.error("删除永久素材失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public WxMpMaterialFileBatchGetResult.WxMaterialFileBatchGetNewsItem getMaterial(String mediaId) { + try { + WxMpMaterialFileBatchGetResult.WxMaterialFileBatchGetNewsItem item = + wxMpService.getMaterialService().materialFileBatchGet("news", 0, 1) + .getItems().stream() + .filter(i -> mediaId.equals(i.getMediaId())) + .findFirst() + .orElse(null); + + if (item != null) { + logger.info("获取永久素材成功: {}", mediaId); + } else { + logger.warn("未找到永久素材: {}", mediaId); + } + return item; + } catch (WxErrorException e) { + logger.error("获取永久素材失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxMpMaterialFileBatchGetResult getMaterialList(String type, int offset, int count) { + try { + WxMpMaterialFileBatchGetResult result = wxMpService.getMaterialService().materialFileBatchGet(type, offset, count); + logger.info("获取素材列表成功: 类型={}, 数量={}", type, result.getItemCount()); + return result; + } catch (WxErrorException e) { + logger.error("获取素材列表失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxMpMaterialCountResult getMaterialCount() { + try { + WxMpMaterialCountResult result = wxMpService.getMaterialService().materialCount(); + logger.info("获取素材总数成功: 图片={}, 语音={}, 视频={}, 图文={}", + result.getImageCount(), result.getVoiceCount(), result.getVideoCount(), result.getNewsCount()); + return result; + } catch (WxErrorException e) { + logger.error("获取素材总数失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public String uploadImg(File file) { + try { + WxMediaImgUploadResult result = wxMpService.getMaterialService().mediaImgUpload(file); + logger.info("上传图文消息图片成功: {}", result.getUrl()); + return result.getUrl(); + } catch (WxErrorException e) { + logger.error("上传图文消息图片失败: {}", e.getMessage(), e); + return null; + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMenuServiceImpl.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMenuServiceImpl.java new file mode 100644 index 0000000..ca953da --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMenuServiceImpl.java @@ -0,0 +1,120 @@ +package org.dromara.mp.service.impl; + +import me.chanjar.weixin.common.bean.menu.WxMenu; +import me.chanjar.weixin.common.bean.menu.WxMenuButton; +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.menu.WxMpMenu; +import org.dromara.mp.service.WxMpMenuService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 微信公众号菜单管理服务实现类 + * + * @author pengles + */ +@Service +public class WxMpMenuServiceImpl implements WxMpMenuService { + + private static final Logger logger = LoggerFactory.getLogger(WxMpMenuServiceImpl.class); + + @Autowired + private WxMpService wxMpService; + + @Override + public boolean createMenu(WxMenu menu) { + try { + String result = wxMpService.getMenuService().menuCreate(menu); + logger.info("创建菜单成功: {}", result); + return true; + } catch (WxErrorException e) { + logger.error("创建菜单失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public boolean deleteMenu() { + try { + wxMpService.getMenuService().menuDelete(); + logger.info("删除菜单成功"); + return true; + } catch (WxErrorException e) { + logger.error("删除菜单失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public WxMpMenu getMenu() { + try { + WxMpMenu menu = wxMpService.getMenuService().menuGet(); + logger.info("查询菜单成功"); + return menu; + } catch (WxErrorException e) { + logger.error("查询菜单失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public boolean createDefaultMenu() { + WxMenu menu = new WxMenu(); + + // 一级菜单1 + WxMenuButton button1 = new WxMenuButton(); + button1.setName("关于我们"); + button1.setType("click"); + button1.setKey("ABOUT_US"); + + // 一级菜单2 + WxMenuButton button2 = new WxMenuButton(); + button2.setName("服务中心"); + + // 二级菜单2-1 + WxMenuButton subButton21 = new WxMenuButton(); + subButton21.setName("在线客服"); + subButton21.setType("click"); + subButton21.setKey("ONLINE_SERVICE"); + + // 二级菜单2-2 + WxMenuButton subButton22 = new WxMenuButton(); + subButton22.setName("常见问题"); + subButton22.setType("click"); + subButton22.setKey("FAQ"); + + button2.getSubButtons().add(subButton21); + button2.getSubButtons().add(subButton22); + + // 一级菜单3 + WxMenuButton button3 = new WxMenuButton(); + button3.setName("联系我们"); + button3.setType("click"); + button3.setKey("CONTACT_US"); + + menu.getButtons().add(button1); + menu.getButtons().add(button2); + menu.getButtons().add(button3); + + return createMenu(menu); + } + + @Override + public String handleMenuClick(String eventKey) { + switch (eventKey) { + case "ABOUT_US": + return "欢迎了解我们!\n\n我们致力于为用户提供优质的服务和产品。"; + case "ONLINE_SERVICE": + return "客服服务时间:\n周一至周五 9:00-18:00\n\n如需帮助,请直接回复您的问题。"; + case "FAQ": + return "常见问题:\n\n1. 如何使用我们的服务?\n2. 如何联系客服?\n3. 服务时间是什么?\n\n更多问题请咨询在线客服。"; + case "CONTACT_US": + return "联系方式:\n\n客服电话:400-123-4567\n客服邮箱:service@example.com\n工作时间:周一至周五 9:00-18:00"; + default: + return "感谢您的关注!"; + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMessageServiceImpl.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMessageServiceImpl.java new file mode 100644 index 0000000..5610160 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpMessageServiceImpl.java @@ -0,0 +1,293 @@ +package org.dromara.mp.service.impl; + +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutTextMessage; +import org.dromara.mp.service.WxMpMessageService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * 微信公众号消息处理服务实现类 + * + * @author pengles + */ +@Service +public class WxMpMessageServiceImpl implements WxMpMessageService { + + private static final Logger logger = LoggerFactory.getLogger(WxMpMessageServiceImpl.class); + + // 自动回复关键词映射 + private static final Map AUTO_REPLY_MAP = new HashMap<>(); + + static { + AUTO_REPLY_MAP.put("帮助", "您好!我是智能客服助手,可以为您提供以下服务:\n\n1. 输入\"联系客服\"获取人工客服\n2. 输入\"常见问题\"查看FAQ\n3. 输入\"产品介绍\"了解我们的产品\n4. 输入\"使用教程\"获取使用指南"); + AUTO_REPLY_MAP.put("联系客服", "客服工作时间:周一至周五 9:00-18:00\n\n如需人工客服,请拨打:400-123-4567\n或发送邮件至:service@example.com"); + AUTO_REPLY_MAP.put("常见问题", "常见问题:\n\n1. 如何注册账号?\n2. 忘记密码怎么办?\n3. 如何修改个人信息?\n4. 如何联系客服?\n\n更多问题请输入具体问题关键词。"); + AUTO_REPLY_MAP.put("产品介绍", "我们的产品特色:\n\n✅ 功能强大,操作简单\n✅ 安全可靠,数据保护\n✅ 7x24小时技术支持\n✅ 持续更新,免费升级\n\n了解更多请访问官网或联系客服。"); + AUTO_REPLY_MAP.put("使用教程", "使用教程:\n\n📖 新手入门指南\n📖 功能操作说明\n📖 常见问题解答\n📖 视频教程\n\n详细教程请访问帮助中心或联系客服获取。"); + } + + @Override + public WxMpXmlOutMessage handleTextMessage(WxMpXmlMessage inMessage) { + String content = inMessage.getContent(); + String openId = inMessage.getFromUser(); + + logger.info("收到文本消息: openId={}, content={}", openId, content); + + // 保存用户消息 + saveUserMessage(inMessage); + + // 获取自动回复内容 + String replyContent = getAutoReplyContent(content, openId); + + // 构建回复消息 + WxMpXmlOutTextMessage outMessage = WxMpXmlOutMessage.TEXT() + .content(replyContent) + .fromUser(inMessage.getToUser()) + .toUser(openId) + .build(); + + // 保存回复消息 + saveReplyMessage(outMessage, openId); + + return outMessage; + } + + @Override + public WxMpXmlOutMessage handleImageMessage(WxMpXmlMessage inMessage) { + String openId = inMessage.getFromUser(); + logger.info("收到图片消息: openId={}, mediaId={}", openId, inMessage.getMediaId()); + + saveUserMessage(inMessage); + + String replyContent = "感谢您发送的图片!如需帮助,请输入\"帮助\"查看可用功能。"; + + WxMpXmlOutTextMessage outMessage = WxMpXmlOutMessage.TEXT() + .content(replyContent) + .fromUser(inMessage.getToUser()) + .toUser(openId) + .build(); + + saveReplyMessage(outMessage, openId); + return outMessage; + } + + @Override + public WxMpXmlOutMessage handleVoiceMessage(WxMpXmlMessage inMessage) { + String openId = inMessage.getFromUser(); + logger.info("收到语音消息: openId={}, mediaId={}", openId, inMessage.getMediaId()); + + saveUserMessage(inMessage); + + String replyContent = "收到您的语音消息!目前暂不支持语音识别,请发送文字消息或输入\"帮助\"查看可用功能。"; + + WxMpXmlOutTextMessage outMessage = WxMpXmlOutMessage.TEXT() + .content(replyContent) + .fromUser(inMessage.getToUser()) + .toUser(openId) + .build(); + + saveReplyMessage(outMessage, openId); + return outMessage; + } + + @Override + public WxMpXmlOutMessage handleVideoMessage(WxMpXmlMessage inMessage) { + String openId = inMessage.getFromUser(); + logger.info("收到视频消息: openId={}, mediaId={}", openId, inMessage.getMediaId()); + + saveUserMessage(inMessage); + + String replyContent = "感谢您发送的视频!如需帮助,请输入\"帮助\"查看可用功能。"; + + WxMpXmlOutTextMessage outMessage = WxMpXmlOutMessage.TEXT() + .content(replyContent) + .fromUser(inMessage.getToUser()) + .toUser(openId) + .build(); + + saveReplyMessage(outMessage, openId); + return outMessage; + } + + @Override + public WxMpXmlOutMessage handleLocationMessage(WxMpXmlMessage inMessage) { + String openId = inMessage.getFromUser(); + logger.info("收到位置消息: openId={}, location=({}, {})", + openId, inMessage.getLocationX(), inMessage.getLocationY()); + + saveUserMessage(inMessage); + + String replyContent = String.format("收到您的位置信息:\n纬度:%s\n经度:%s\n\n如需基于位置的服务,请联系客服。", + inMessage.getLocationX(), inMessage.getLocationY()); + + WxMpXmlOutTextMessage outMessage = WxMpXmlOutMessage.TEXT() + .content(replyContent) + .fromUser(inMessage.getToUser()) + .toUser(openId) + .build(); + + saveReplyMessage(outMessage, openId); + return outMessage; + } + + @Override + public WxMpXmlOutMessage handleLinkMessage(WxMpXmlMessage inMessage) { + String openId = inMessage.getFromUser(); + logger.info("收到链接消息: openId={}, url={}", openId, inMessage.getUrl()); + + saveUserMessage(inMessage); + + String replyContent = "感谢您分享的链接!如需帮助,请输入\"帮助\"查看可用功能。"; + + WxMpXmlOutTextMessage outMessage = WxMpXmlOutMessage.TEXT() + .content(replyContent) + .fromUser(inMessage.getToUser()) + .toUser(openId) + .build(); + + saveReplyMessage(outMessage, openId); + return outMessage; + } + + @Override + public WxMpXmlOutMessage handleEventMessage(WxMpXmlMessage inMessage) { + String openId = inMessage.getFromUser(); + String event = inMessage.getEvent(); + + logger.info("收到事件消息: openId={}, event={}", openId, event); + + String replyContent; + + switch (event) { + case "subscribe": + replyContent = handleSubscribeEvent(openId); + break; + case "unsubscribe": + handleUnsubscribeEvent(openId); + return null; // 取消关注不回复消息 + case "SCAN": + replyContent = handleScanEvent(openId, inMessage.getEventKey()); + break; + case "CLICK": + replyContent = handleMenuClickEvent(inMessage.getEventKey()); + break; + default: + replyContent = getDefaultReplyContent(); + break; + } + + if (replyContent != null) { + WxMpXmlOutTextMessage outMessage = WxMpXmlOutMessage.TEXT() + .content(replyContent) + .fromUser(inMessage.getToUser()) + .toUser(openId) + .build(); + + saveReplyMessage(outMessage, openId); + return outMessage; + } + + return null; + } + + @Override + public String getAutoReplyContent(String keyword, String openId) { + // 精确匹配 + if (AUTO_REPLY_MAP.containsKey(keyword)) { + return AUTO_REPLY_MAP.get(keyword); + } + + // 模糊匹配 + for (Map.Entry entry : AUTO_REPLY_MAP.entrySet()) { + if (keyword.contains(entry.getKey()) || entry.getKey().contains(keyword)) { + return entry.getValue(); + } + } + + // 默认回复 + return getDefaultReplyContent(); + } + + @Override + public void saveUserMessage(WxMpXmlMessage inMessage) { + // TODO: 保存用户消息到数据库 + logger.info("保存用户消息: openId={}, msgType={}, content={}", + inMessage.getFromUser(), inMessage.getMsgType(), inMessage.getContent()); + } + + @Override + public void saveReplyMessage(WxMpXmlOutMessage outMessage, String openId) { + // TODO: 保存回复消息到数据库 + logger.info("保存回复消息: openId={}, content={}", openId, outMessage.getMsgSignature()); + } + + @Override + public String getDefaultReplyContent() { + return "您好!欢迎关注我们!\n\n输入\"帮助\"查看可用功能,或直接描述您的问题,我们会尽快为您解答。"; + } + + @Override + public String handleSubscribeEvent(String openId) { + logger.info("用户关注: openId={}", openId); + + String welcomeMessage = "🎉 欢迎关注我们!\n\n" + + "感谢您的关注,我们将为您提供优质的服务。\n\n" + + "💡 输入\"帮助\"查看可用功能\n" + + "📞 输入\"联系客服\"获取人工服务\n" + + "❓ 输入\"常见问题\"查看FAQ\n\n" + + "有任何问题随时联系我们!"; + + return welcomeMessage; + } + + @Override + public void handleUnsubscribeEvent(String openId) { + logger.info("用户取消关注: openId={}", openId); + // TODO: 更新用户关注状态 + } + + @Override + public String handleScanEvent(String openId, String sceneStr) { + logger.info("用户扫码: openId={}, sceneStr={}", openId, sceneStr); + + if (sceneStr.startsWith("invite_")) { + String inviteUserId = sceneStr.substring(7); + return String.format("感谢通过邀请码关注我们!\n邀请人ID:%s\n\n您已获得新用户专属福利,详情请联系客服。", inviteUserId); + } else if (sceneStr.startsWith("activity_")) { + String activityId = sceneStr.substring(9); + return String.format("欢迎参与活动!\n活动ID:%s\n\n活动详情和参与方式请联系客服了解。", activityId); + } else if (sceneStr.startsWith("channel_")) { + String channelId = sceneStr.substring(8); + return String.format("欢迎通过渠道关注我们!\n渠道ID:%s\n\n感谢您的关注,有任何问题请随时联系。", channelId); + } + + return "感谢扫码关注我们!如需帮助,请输入\"帮助\"查看可用功能。"; + } + + /** + * 处理菜单点击事件 + * + * @param eventKey 事件key + * @return 回复内容 + */ + private String handleMenuClickEvent(String eventKey) { + switch (eventKey) { + case "ABOUT_US": + return "关于我们\n\n我们是一家专注于为用户提供优质服务的公司,致力于通过技术创新改善用户体验。"; + case "ONLINE_SERVICE": + return "在线客服\n\n客服工作时间:周一至周五 9:00-18:00\n\n请直接描述您的问题,我们会尽快回复。"; + case "FAQ": + return AUTO_REPLY_MAP.get("常见问题"); + case "CONTACT_US": + return AUTO_REPLY_MAP.get("联系客服"); + default: + return getDefaultReplyContent(); + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpQrCodeServiceImpl.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpQrCodeServiceImpl.java new file mode 100644 index 0000000..31c6974 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpQrCodeServiceImpl.java @@ -0,0 +1,96 @@ +package org.dromara.mp.service.impl; + +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.result.WxMpQrCodeTicket; +import org.dromara.mp.service.WxMpQrCodeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 微信公众号二维码生成服务实现类 + * + * @author pengles + */ +@Service +public class WxMpQrCodeServiceImpl implements WxMpQrCodeService { + + private static final Logger logger = LoggerFactory.getLogger(WxMpQrCodeServiceImpl.class); + + @Autowired + private WxMpService wxMpService; + + @Override + public WxMpQrCodeTicket createTempQrCode(int sceneId, int expireSeconds) { + try { + WxMpQrCodeTicket ticket = wxMpService.getQrcodeService().qrCodeCreateTmpTicket(sceneId, expireSeconds); + logger.info("创建临时二维码成功: sceneId={}, ticket={}", sceneId, ticket.getTicket()); + return ticket; + } catch (WxErrorException e) { + logger.error("创建临时二维码失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxMpQrCodeTicket createPermanentQrCode(String sceneStr) { + try { + WxMpQrCodeTicket ticket = wxMpService.getQrcodeService().qrCodeCreateLastTicket(sceneStr); + logger.info("创建永久二维码成功: sceneStr={}, ticket={}", sceneStr, ticket.getTicket()); + return ticket; + } catch (WxErrorException e) { + logger.error("创建永久二维码失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxMpQrCodeTicket createPermanentQrCode(int sceneId) { + try { + WxMpQrCodeTicket ticket = wxMpService.getQrcodeService().qrCodeCreateLastTicket(sceneId); + logger.info("创建永久二维码成功: sceneId={}, ticket={}", sceneId, ticket.getTicket()); + return ticket; + } catch (WxErrorException e) { + logger.error("创建永久二维码失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxMpQrCodeTicket createForeverQrCode(String sceneStr) { + // 别名方法,直接调用createPermanentQrCode + return createPermanentQrCode(sceneStr); + } + + @Override + public String getQrCodeUrl(String ticket) { + String url = null; + try { + url = wxMpService.getQrcodeService().qrCodePictureUrl(ticket); + } catch (WxErrorException e) { + throw new RuntimeException(e); + } + logger.info("获取二维码URL成功: ticket={}, url={}", ticket, url); + return url; + } + + @Override + public WxMpQrCodeTicket createUserInviteQrCode(String userId) { + String sceneStr = "invite_" + userId; + return createPermanentQrCode(sceneStr); + } + + @Override + public WxMpQrCodeTicket createActivityQrCode(String activityId) { + String sceneStr = "activity_" + activityId; + return createPermanentQrCode(sceneStr); + } + + @Override + public WxMpQrCodeTicket createChannelQrCode(String channelId) { + String sceneStr = "channel_" + channelId; + return createPermanentQrCode(sceneStr); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpStatisticsServiceImpl.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpStatisticsServiceImpl.java new file mode 100644 index 0000000..7de6f57 --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpStatisticsServiceImpl.java @@ -0,0 +1,493 @@ +package org.dromara.mp.service.impl; + +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.datacube.*; +import org.dromara.mp.service.WxMpStatisticsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 微信公众号数据统计服务实现类 + * + * @author pengles + */ +@Service +public class WxMpStatisticsServiceImpl implements WxMpStatisticsService { + + private static final Logger logger = LoggerFactory.getLogger(WxMpStatisticsServiceImpl.class); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Autowired + private WxMpService wxMpService; + + @Override + public List> getUserSummary(LocalDate beginDate, LocalDate endDate) { + try { + List summaries = wxMpService.getDataCubeService() + .getUserSummary(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeUserSummary summary : summaries) { + Map data = new HashMap<>(); + data.put("refDate", summary.getRefDate()); + data.put("userSource", summary.getUserSource()); + data.put("newUser", summary.getNewUser()); + data.put("cancelUser", summary.getCancelUser()); + result.add(data); + } + + logger.info("获取用户增减数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取用户增减数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getUserCumulate(LocalDate beginDate, LocalDate endDate) { + try { + List cumulates = wxMpService.getDataCubeService() + .getUserCumulate(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeUserCumulate cumulate : cumulates) { + Map data = new HashMap<>(); + data.put("refDate", cumulate.getRefDate()); + data.put("cumulateUser", cumulate.getCumulateUser()); + result.add(data); + } + + logger.info("获取用户累计数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取用户累计数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getArticleSummary(LocalDate beginDate, LocalDate endDate) { + try { + List summaries = wxMpService.getDataCubeService() + .getArticleSummary(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeArticleResult summary : summaries) { + Map data = new HashMap<>(); + data.put("refDate", summary.getRefDate()); + data.put("msgid", summary.getMsgId()); + data.put("title", summary.getTitle()); + data.put("intPageReadUser", summary.getIntPageReadUser()); + data.put("intPageReadCount", summary.getIntPageReadCount()); + data.put("oriPageReadUser", summary.getOriPageReadUser()); + data.put("oriPageReadCount", summary.getOriPageReadCount()); + data.put("shareUser", summary.getShareUser()); + data.put("shareCount", summary.getShareCount()); + data.put("addToFavUser", summary.getAddToFavUser()); + data.put("addToFavCount", summary.getAddToFavCount()); + result.add(data); + } + + logger.info("获取图文群发每日数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取图文群发每日数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getArticleTotal(LocalDate beginDate, LocalDate endDate) { + try { + List totals = wxMpService.getDataCubeService() + .getArticleTotal(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeArticleTotal total : totals) { + Map data = new HashMap<>(); + data.put("refDate", total.getRefDate()); + data.put("msgid", total.getMsgId()); + data.put("title", total.getTitle()); + data.put("details", total.getDetails()); + result.add(data); + } + + logger.info("获取图文群发总数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取图文群发总数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getUserRead(LocalDate beginDate, LocalDate endDate) { + try { + List reads = wxMpService.getDataCubeService() + .getUserRead(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + + List> result = new ArrayList<>(); + for (WxDataCubeArticleResult read : reads) { + Map data = new HashMap<>(); + data.put("refDate", read.getRefDate()); + data.put("userSource", read.getUserSource()); + data.put("intPageReadUser", read.getIntPageReadUser()); + data.put("intPageReadCount", read.getIntPageReadCount()); + data.put("oriPageReadUser", read.getOriPageReadUser()); + data.put("oriPageReadCount", read.getOriPageReadCount()); + data.put("shareUser", read.getShareUser()); + data.put("shareCount", read.getShareCount()); + data.put("addToFavUser", read.getAddToFavUser()); + data.put("addToFavCount", read.getAddToFavCount()); + result.add(data); + } + + logger.info("获取图文统计数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取图文统计数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getUserReadHour(LocalDate beginDate, LocalDate endDate) { + try { + List reads = wxMpService.getDataCubeService() + .getUserReadHour(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + + List> result = new ArrayList<>(); + for (WxDataCubeArticleResult read : reads) { + Map data = new HashMap<>(); + data.put("refDate", read.getRefDate()); + data.put("refHour", read.getRefHour()); + data.put("userSource", read.getUserSource()); + data.put("intPageReadUser", read.getIntPageReadUser()); + data.put("intPageReadCount", read.getIntPageReadCount()); + data.put("oriPageReadUser", read.getOriPageReadUser()); + data.put("oriPageReadCount", read.getOriPageReadCount()); + data.put("shareUser", read.getShareUser()); + data.put("shareCount", read.getShareCount()); + data.put("addToFavUser", read.getAddToFavUser()); + data.put("addToFavCount", read.getAddToFavCount()); + result.add(data); + } + + logger.info("获取图文统计分时数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取图文统计分时数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getUserShare(LocalDate beginDate, LocalDate endDate) { + try { + List shares = wxMpService.getDataCubeService() + .getUserShare(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeArticleResult share : shares) { + Map data = new HashMap<>(); + data.put("refDate", share.getRefDate()); + data.put("shareScene", share.getShareScene()); + data.put("shareUser", share.getShareUser()); + data.put("shareCount", share.getShareCount()); + result.add(data); + } + + logger.info("获取图文分享转发数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取图文分享转发数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getUserShareHour(LocalDate beginDate, LocalDate endDate) { + try { + List shares = wxMpService.getDataCubeService() + .getUserShareHour(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeArticleResult share : shares) { + Map data = new HashMap<>(); + data.put("refDate", share.getRefDate()); + data.put("refHour", share.getRefHour()); + data.put("shareScene", share.getShareScene()); + data.put("shareUser", share.getShareUser()); + data.put("shareCount", share.getShareCount()); + result.add(data); + } + + logger.info("获取图文分享转发分时数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取图文分享转发分时数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getUpstreamMsg(LocalDate beginDate, LocalDate endDate) { + try { + List msgs = wxMpService.getDataCubeService() + .getUpstreamMsg(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeMsgResult msg : msgs) { + Map data = new HashMap<>(); + data.put("refDate", msg.getRefDate()); + data.put("msgType", msg.getMsgType()); + data.put("msgUser", msg.getMsgUser()); + data.put("msgCount", msg.getMsgCount()); + result.add(data); + } + + logger.info("获取消息发送概况数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取消息发送概况数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getUpstreamMsgHour(LocalDate beginDate, LocalDate endDate) { + try { + List msgs = wxMpService.getDataCubeService() + .getUpstreamMsgHour(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeMsgResult msg : msgs) { + Map data = new HashMap<>(); + data.put("refDate", msg.getRefDate()); + data.put("refHour", msg.getRefHour()); + data.put("msgType", msg.getMsgType()); + data.put("msgUser", msg.getMsgUser()); + data.put("msgCount", msg.getMsgCount()); + result.add(data); + } + + logger.info("获取消息分送分时数据成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取消息分送分时数据失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getUpstreamMsgWeek(LocalDate beginDate, LocalDate endDate) { + try { + List msgs = wxMpService.getDataCubeService() + .getUpstreamMsgWeek(Date.from(beginDate.atStartOfDay(ZoneId.systemDefault()).toInstant()), Date.from(endDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + List> result = new ArrayList<>(); + for (WxDataCubeMsgResult msg : msgs) { + Map data = new HashMap<>(); + data.put("refDate", msg.getRefDate()); + data.put("msgType", msg.getMsgType()); + data.put("msgUser", msg.getMsgUser()); + data.put("msgCount", msg.getMsgCount()); + result.add(data); + } + + logger.info("获取消息关键词发送概况(周)成功: {} - {}", beginDate, endDate); + return result; + } catch (WxErrorException e) { + logger.error("获取消息关键词发送概况(周)失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getMenuSummary(LocalDate beginDate, LocalDate endDate) { + // 微信API暂不支持菜单分析数据接口,这里提供模拟数据结构 + List> result = new ArrayList<>(); + + // 模拟菜单点击数据 + Map menuData = new HashMap<>(); + menuData.put("refDate", beginDate.format(DATE_FORMATTER)); + menuData.put("menuId", "ABOUT_US"); + menuData.put("menuName", "关于我们"); + menuData.put("clickCount", 0); + menuData.put("clickUser", 0); + result.add(menuData); + + logger.info("获取菜单分析数据(模拟): {} - {}", beginDate, endDate); + return result; + } + + @Override + public Map getStatisticsOverview() { + Map overview = new HashMap<>(); + + try { + LocalDate today = LocalDate.now(); + LocalDate yesterday = today.minusDays(1); + LocalDate weekAgo = today.minusDays(7); + + // 获取用户数据 + List> userSummary = getUserSummary(yesterday, yesterday); + List> userCumulate = getUserCumulate(yesterday, yesterday); + + int newUsers = userSummary.stream() + .mapToInt(data -> (Integer) data.getOrDefault("newUser", 0)) + .sum(); + int cancelUsers = userSummary.stream() + .mapToInt(data -> (Integer) data.getOrDefault("cancelUser", 0)) + .sum(); + int totalUsers = userCumulate.stream() + .mapToInt(data -> (Integer) data.getOrDefault("cumulateUser", 0)) + .max().orElse(0); + + overview.put("newUsers", newUsers); + overview.put("cancelUsers", cancelUsers); + overview.put("totalUsers", totalUsers); + overview.put("netGrowth", newUsers - cancelUsers); + + // 获取消息数据 + List> msgData = getUpstreamMsg(yesterday, yesterday); + int totalMessages = msgData.stream() + .mapToInt(data -> (Integer) data.getOrDefault("msgCount", 0)) + .sum(); + int activeUsers = msgData.stream() + .mapToInt(data -> (Integer) data.getOrDefault("msgUser", 0)) + .sum(); + + overview.put("totalMessages", totalMessages); + overview.put("activeUsers", activeUsers); + + // 计算活跃度 + double activityRate = totalUsers > 0 ? (double) activeUsers / totalUsers * 100 : 0; + overview.put("activityRate", Math.round(activityRate * 100.0) / 100.0); + + overview.put("updateTime", LocalDate.now().format(DATE_FORMATTER)); + + logger.info("获取统计概览数据成功"); + + } catch (Exception e) { + logger.error("获取统计概览数据失败: {}", e.getMessage(), e); + // 返回默认数据 + overview.put("newUsers", 0); + overview.put("cancelUsers", 0); + overview.put("totalUsers", 0); + overview.put("netGrowth", 0); + overview.put("totalMessages", 0); + overview.put("activeUsers", 0); + overview.put("activityRate", 0.0); + overview.put("updateTime", LocalDate.now().format(DATE_FORMATTER)); + } + + return overview; + } + + @Override + public Map getUserPortrait(LocalDate beginDate, LocalDate endDate) { + Map portrait = new HashMap<>(); + + try { + // 获取用户来源分析 + List> userSummary = getUserSummary(beginDate, endDate); + + Map sourceAnalysis = new HashMap<>(); + for (Map data : userSummary) { + String source = (String) data.get("userSource"); + int newUser = (Integer) data.getOrDefault("newUser", 0); + sourceAnalysis.put(source, sourceAnalysis.getOrDefault(source, 0) + newUser); + } + + portrait.put("sourceAnalysis", sourceAnalysis); + + // 获取消息类型分析 + List> msgData = getUpstreamMsg(beginDate, endDate); + + Map msgTypeAnalysis = new HashMap<>(); + for (Map data : msgData) { + String msgType = (String) data.get("msgType"); + int msgCount = (Integer) data.getOrDefault("msgCount", 0); + msgTypeAnalysis.put(msgType, msgTypeAnalysis.getOrDefault(msgType, 0) + msgCount); + } + + portrait.put("msgTypeAnalysis", msgTypeAnalysis); + + logger.info("获取用户画像数据成功: {} - {}", beginDate, endDate); + + } catch (Exception e) { + logger.error("获取用户画像数据失败: {}", e.getMessage(), e); + portrait.put("sourceAnalysis", new HashMap<>()); + portrait.put("msgTypeAnalysis", new HashMap<>()); + } + + return portrait; + } + + @Override + public List> getPopularContent(LocalDate beginDate, LocalDate endDate, int limit) { + try { + List> articleData = getArticleSummary(beginDate, endDate); + + // 按阅读量排序 + articleData.sort((a, b) -> { + int readCountA = (Integer) a.getOrDefault("intPageReadCount", 0); + int readCountB = (Integer) b.getOrDefault("intPageReadCount", 0); + return Integer.compare(readCountB, readCountA); + }); + + // 限制返回数量 + List> result = articleData.stream() + .limit(limit) + .collect(ArrayList::new, (list, item) -> list.add(item), ArrayList::addAll); + + logger.info("获取热门内容排行成功: {} - {}, limit={}", beginDate, endDate, limit); + return result; + + } catch (Exception e) { + logger.error("获取热门内容排行失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public List> getKeywordStatistics(LocalDate beginDate, LocalDate endDate, int limit) { + // 这里需要结合实际的消息记录数据库来统计关键词 + // 目前提供模拟数据结构 + List> result = new ArrayList<>(); + + // 模拟关键词统计数据 + String[] keywords = {"帮助", "客服", "产品", "价格", "使用", "问题", "联系", "服务"}; + Random random = new Random(); + + for (int i = 0; i < Math.min(keywords.length, limit); i++) { + Map data = new HashMap<>(); + data.put("keyword", keywords[i]); + data.put("count", random.nextInt(100) + 1); + data.put("userCount", random.nextInt(50) + 1); + result.add(data); + } + + // 按使用次数排序 + result.sort((a, b) -> { + int countA = (Integer) a.get("count"); + int countB = (Integer) b.get("count"); + return Integer.compare(countB, countA); + }); + + logger.info("获取关键词统计(模拟)成功: {} - {}, limit={}", beginDate, endDate, limit); + return result; + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpTemplateServiceImpl.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpTemplateServiceImpl.java new file mode 100644 index 0000000..c2282ed --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpTemplateServiceImpl.java @@ -0,0 +1,134 @@ +package org.dromara.mp.service.impl; + +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.template.WxMpTemplate; +import me.chanjar.weixin.mp.bean.template.WxMpTemplateData; +import me.chanjar.weixin.mp.bean.template.WxMpTemplateMessage; +import org.dromara.mp.service.WxMpTemplateService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 微信公众号模板消息服务实现类 + * + * @author pengles + */ +@Service +public class WxMpTemplateServiceImpl implements WxMpTemplateService { + + private static final Logger logger = LoggerFactory.getLogger(WxMpTemplateServiceImpl.class); + + @Autowired + private WxMpService wxMpService; + + @Override + public String sendTemplateMessage(WxMpTemplateMessage templateMessage) { + try { + String msgId = wxMpService.getTemplateMsgService().sendTemplateMsg(templateMessage); + logger.info("发送模板消息成功: {}", msgId); + return msgId; + } catch (WxErrorException e) { + logger.error("发送模板消息失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public List getAllTemplates() { + try { + List templates = wxMpService.getTemplateMsgService().getAllPrivateTemplate(); + logger.info("获取模板列表成功,模板数量: {}", templates.size()); + return templates; + } catch (WxErrorException e) { + logger.error("获取模板列表失败: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } + + @Override + public boolean deleteTemplate(String templateId) { + try { + boolean result = wxMpService.getTemplateMsgService().delPrivateTemplate(templateId); + logger.info("删除模板成功: {}", templateId); + return result; + } catch (WxErrorException e) { + logger.error("删除模板失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public String sendNotificationMessage(String openId, String templateId, String url, Map data) { + WxMpTemplateMessage templateMessage = WxMpTemplateMessage.builder() + .toUser(openId) + .templateId(templateId) + .url(url) + .build(); + + // 添加模板数据 + for (Map.Entry entry : data.entrySet()) { + templateMessage.addData(new WxMpTemplateData(entry.getKey(), entry.getValue(), "#173177")); + } + + return sendTemplateMessage(templateMessage); + } + + @Override + public String sendOrderMessage(String openId, String orderNo, String amount, String status) { + WxMpTemplateMessage templateMessage = WxMpTemplateMessage.builder() + .toUser(openId) + .templateId("ORDER_TEMPLATE_ID") // 需要配置实际的模板ID + .build(); + + // 添加模板数据 + templateMessage.addData(new WxMpTemplateData("first", "订单状态通知", "#173177")); + templateMessage.addData(new WxMpTemplateData("keyword1", orderNo, "#173177")); + templateMessage.addData(new WxMpTemplateData("keyword2", amount, "#173177")); + templateMessage.addData(new WxMpTemplateData("keyword3", status, "#173177")); + templateMessage.addData(new WxMpTemplateData("remark", "感谢您的支持!", "#173177")); + + return sendTemplateMessage(templateMessage); + } + + @Override + public String sendAuditResultMessage(String openId, String result, String reason) { + WxMpTemplateMessage templateMessage = WxMpTemplateMessage.builder() + .toUser(openId) + .templateId("AUDIT_TEMPLATE_ID") // 需要配置实际的模板ID + .build(); + + // 添加模板数据 + templateMessage.addData(new WxMpTemplateData("first", "审核结果通知", "#173177")); + templateMessage.addData(new WxMpTemplateData("keyword1", result, "#173177")); + templateMessage.addData(new WxMpTemplateData("keyword2", reason, "#173177")); + templateMessage.addData(new WxMpTemplateData("remark", "如有疑问,请联系客服。", "#173177")); + + return sendTemplateMessage(templateMessage); + } + + @Override + public String sendActivityNotificationMessage(String openId, String activityName, String startTime, String endTime) { + WxMpTemplateMessage templateMessage = WxMpTemplateMessage.builder() + .toUser(openId) + .templateId("ACTIVITY_TEMPLATE_ID") // 需要配置实际的模板ID + .build(); + + // 添加模板数据 + templateMessage.addData(new WxMpTemplateData("first", "活动通知", "#173177")); + templateMessage.addData(new WxMpTemplateData("keyword1", activityName, "#173177")); + templateMessage.addData(new WxMpTemplateData("keyword2", startTime, "#173177")); + templateMessage.addData(new WxMpTemplateData("keyword3", endTime, "#173177")); + templateMessage.addData(new WxMpTemplateData("remark", "点击查看活动详情", "#173177")); + + return sendTemplateMessage(templateMessage); + } + + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpUserServiceImpl.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpUserServiceImpl.java new file mode 100644 index 0000000..e8ebb2d --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/service/impl/WxMpUserServiceImpl.java @@ -0,0 +1,199 @@ +package org.dromara.mp.service.impl; + +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.mp.api.WxMpService; +import me.chanjar.weixin.mp.bean.result.WxMpUser; +import me.chanjar.weixin.mp.bean.result.WxMpUserList; +import me.chanjar.weixin.mp.bean.tag.WxTagListUser; +import me.chanjar.weixin.mp.bean.tag.WxUserTag; +import org.dromara.mp.service.WxMpUserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 微信公众号用户管理服务实现类 + * + * @author pengles + */ +@Service +public class WxMpUserServiceImpl implements WxMpUserService { + + private static final Logger logger = LoggerFactory.getLogger(WxMpUserServiceImpl.class); + + @Autowired + private WxMpService wxMpService; + + @Override + public WxMpUser getUserInfo(String openId) { + try { + WxMpUser user = wxMpService.getUserService().userInfo(openId); + logger.info("获取用户信息成功: {}", openId); + return user; + } catch (WxErrorException e) { + logger.error("获取用户信息失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxMpUserList getUserList(String nextOpenId) { + try { + WxMpUserList userList = wxMpService.getUserService().userList(nextOpenId); + logger.info("获取用户列表成功,数量: {}", userList.getCount()); + return userList; + } catch (WxErrorException e) { + logger.error("获取用户列表失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public List getAllUsers() { + try { + List allUsers = wxMpService.getUserService().userList().getOpenids(); + logger.info("获取所有用户成功,数量: {}", allUsers.size()); + return allUsers; + } catch (WxErrorException e) { + logger.error("获取所有用户失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxUserTag createTag(String tagName) { + try { + WxUserTag tag = wxMpService.getUserTagService().tagCreate(tagName); + logger.info("创建标签成功: {}", tagName); + return tag; + } catch (WxErrorException e) { + logger.error("创建标签失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public List getTags() { + try { + List tags = wxMpService.getUserTagService().tagGet(); + logger.info("获取标签列表成功,数量: {}", tags.size()); + return tags; + } catch (WxErrorException e) { + logger.error("获取标签列表失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public boolean updateTag(Long tagId, String tagName) { + try { + boolean result = wxMpService.getUserTagService().tagUpdate(tagId, tagName); + logger.info("更新标签成功: {} -> {}", tagId, tagName); + return result; + } catch (WxErrorException e) { + logger.error("更新标签失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public boolean deleteTag(Long tagId) { + try { + boolean result = wxMpService.getUserTagService().tagDelete(tagId); + logger.info("删除标签成功: {}", tagId); + return result; + } catch (WxErrorException e) { + logger.error("删除标签失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public boolean tagUsers(Long tagId, String[] openIds) { + try { + boolean result = wxMpService.getUserTagService().batchTagging(tagId, openIds); + logger.info("批量打标签成功: {} 用户数: {}", tagId, openIds.length); + return result; + } catch (WxErrorException e) { + logger.error("批量打标签失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public boolean untagUsers(Long tagId, String[] openIds) { + try { + boolean result = wxMpService.getUserTagService().batchUntagging(tagId, openIds); + logger.info("批量取消标签成功: {} 用户数: {}", tagId, openIds.length); + return result; + } catch (WxErrorException e) { + logger.error("批量取消标签失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public List getUserTags(String openId) { + try { + List tags = wxMpService.getUserTagService().userTagList(openId); + logger.info("获取用户标签成功: {} 标签数: {}", openId, tags.size()); + return tags; + } catch (WxErrorException e) { + logger.error("获取用户标签失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public WxTagListUser getUsersByTag(Long tagId, String nextOpenId) { + try { + WxTagListUser userList = wxMpService.getUserTagService().tagListUser(tagId, nextOpenId); + logger.info("获取标签下用户列表成功: {} 用户数: {}", tagId, userList.getCount()); + return userList; + } catch (WxErrorException e) { + logger.error("获取标签下用户列表失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public List getTagUsers(Long tagId, String nextOpenId) { + try { + WxTagListUser userList = wxMpService.getUserTagService().tagListUser(tagId, nextOpenId); + logger.info("获取标签下用户openId列表成功: {} 用户数: {}", tagId, userList.getCount()); + return userList.getData().getOpenidList(); + } catch (WxErrorException e) { + logger.error("获取标签下用户openId列表失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public boolean batchTagging(Long tagId, List openidList) { + try { + String[] openIds = openidList.toArray(new String[0]); + boolean result = wxMpService.getUserTagService().batchTagging(tagId, openIds); + logger.info("批量打标签成功: {} 用户数: {}", tagId, openIds.length); + return result; + } catch (WxErrorException e) { + logger.error("批量打标签失败: {}", e.getMessage(), e); + return false; + } + } + + @Override + public boolean batchUntagging(Long tagId, List openidList) { + try { + String[] openIds = openidList.toArray(new String[0]); + boolean result = wxMpService.getUserTagService().batchUntagging(tagId, openIds); + logger.info("批量取消标签成功: {} 用户数: {}", tagId, openIds.length); + return result; + } catch (WxErrorException e) { + logger.error("批量取消标签失败: {}", e.getMessage(), e); + return false; + } + } +} diff --git a/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/utils/WxMpUtils.java b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/utils/WxMpUtils.java new file mode 100644 index 0000000..b93ba8f --- /dev/null +++ b/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/utils/WxMpUtils.java @@ -0,0 +1,365 @@ +package org.dromara.mp.utils; + +import me.chanjar.weixin.common.api.WxConsts; +import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage; +import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.regex.Pattern; + +/** + * 微信公众号工具类 + * + * @author pengles + */ +public class WxMpUtils { + + private static final Logger logger = LoggerFactory.getLogger(WxMpUtils.class); + + /** + * 手机号正则表达式 + */ + private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); + + /** + * 邮箱正则表达式 + */ + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + + /** + * 身份证号正则表达式 + */ + private static final Pattern ID_CARD_PATTERN = Pattern.compile("^[1-9]\\d{5}(18|19|20)\\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\\d{3}[0-9Xx]$"); + + /** + * 验证微信签名 + * + * @param token 微信公众号token + * @param signature 微信签名 + * @param timestamp 时间戳 + * @param nonce 随机数 + * @return 验证结果 + */ + public static boolean checkSignature(String token, String signature, String timestamp, String nonce) { + String[] arr = new String[]{token, timestamp, nonce}; + Arrays.sort(arr); + StringBuilder content = new StringBuilder(); + for (String s : arr) { + content.append(s); + } + + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(content.toString().getBytes()); + StringBuilder hexStr = new StringBuilder(); + for (byte b : digest) { + String shaHex = Integer.toHexString(b & 0xFF); + if (shaHex.length() < 2) { + hexStr.append(0); + } + hexStr.append(shaHex); + } + return hexStr.toString().equals(signature); + } catch (NoSuchAlgorithmException e) { + logger.error("SHA-1算法不存在", e); + return false; + } + } + + /** + * 构建文本回复消息 + * + * @param content 回复内容 + * @param wxMessage 接收到的消息 + * @return 回复消息 + */ + public static WxMpXmlOutMessage buildTextMessage(String content, WxMpXmlMessage wxMessage) { + return WxMpXmlOutMessage.TEXT() + .content(content) + .fromUser(wxMessage.getToUser()) + .toUser(wxMessage.getFromUser()) + .build(); + } + + /** + * 构建图片回复消息 + * + * @param mediaId 图片媒体ID + * @param wxMessage 接收到的消息 + * @return 回复消息 + */ + public static WxMpXmlOutMessage buildImageMessage(String mediaId, WxMpXmlMessage wxMessage) { + return WxMpXmlOutMessage.IMAGE() + .mediaId(mediaId) + .fromUser(wxMessage.getToUser()) + .toUser(wxMessage.getFromUser()) + .build(); + } + + /** + * 构建语音回复消息 + * + * @param mediaId 语音媒体ID + * @param wxMessage 接收到的消息 + * @return 回复消息 + */ + public static WxMpXmlOutMessage buildVoiceMessage(String mediaId, WxMpXmlMessage wxMessage) { + return WxMpXmlOutMessage.VOICE() + .mediaId(mediaId) + .fromUser(wxMessage.getToUser()) + .toUser(wxMessage.getFromUser()) + .build(); + } + + /** + * 构建视频回复消息 + * + * @param mediaId 视频媒体ID + * @param title 视频标题 + * @param description 视频描述 + * @param wxMessage 接收到的消息 + * @return 回复消息 + */ + public static WxMpXmlOutMessage buildVideoMessage(String mediaId, String title, String description, WxMpXmlMessage wxMessage) { + return WxMpXmlOutMessage.VIDEO() + .mediaId(mediaId) + .title(title) + .description(description) + .fromUser(wxMessage.getToUser()) + .toUser(wxMessage.getFromUser()) + .build(); + } + + /** + * 构建音乐回复消息 + * + * @param title 音乐标题 + * @param description 音乐描述 + * @param musicUrl 音乐链接 + * @param hqMusicUrl 高质量音乐链接 + * @param thumbMediaId 缩略图媒体ID + * @param wxMessage 接收到的消息 + * @return 回复消息 + */ + public static WxMpXmlOutMessage buildMusicMessage(String title, String description, String musicUrl, + String hqMusicUrl, String thumbMediaId, WxMpXmlMessage wxMessage) { + return WxMpXmlOutMessage.MUSIC() + .title(title) + .description(description) + .musicUrl(musicUrl) + .hqMusicUrl(hqMusicUrl) + .thumbMediaId(thumbMediaId) + .fromUser(wxMessage.getToUser()) + .toUser(wxMessage.getFromUser()) + .build(); + } + + /** + * 判断是否为文本消息 + * + * @param wxMessage 微信消息 + * @return 是否为文本消息 + */ + public static boolean isTextMessage(WxMpXmlMessage wxMessage) { + return WxConsts.XmlMsgType.TEXT.equals(wxMessage.getMsgType()); + } + + /** + * 判断是否为图片消息 + * + * @param wxMessage 微信消息 + * @return 是否为图片消息 + */ + public static boolean isImageMessage(WxMpXmlMessage wxMessage) { + return WxConsts.XmlMsgType.IMAGE.equals(wxMessage.getMsgType()); + } + + /** + * 判断是否为语音消息 + * + * @param wxMessage 微信消息 + * @return 是否为语音消息 + */ + public static boolean isVoiceMessage(WxMpXmlMessage wxMessage) { + return WxConsts.XmlMsgType.VOICE.equals(wxMessage.getMsgType()); + } + + /** + * 判断是否为视频消息 + * + * @param wxMessage 微信消息 + * @return 是否为视频消息 + */ + public static boolean isVideoMessage(WxMpXmlMessage wxMessage) { + return WxConsts.XmlMsgType.VIDEO.equals(wxMessage.getMsgType()) || + WxConsts.XmlMsgType.SHORTVIDEO.equals(wxMessage.getMsgType()); + } + + /** + * 判断是否为地理位置消息 + * + * @param wxMessage 微信消息 + * @return 是否为地理位置消息 + */ + public static boolean isLocationMessage(WxMpXmlMessage wxMessage) { + return WxConsts.XmlMsgType.LOCATION.equals(wxMessage.getMsgType()); + } + + /** + * 判断是否为链接消息 + * + * @param wxMessage 微信消息 + * @return 是否为链接消息 + */ + public static boolean isLinkMessage(WxMpXmlMessage wxMessage) { + return WxConsts.XmlMsgType.LINK.equals(wxMessage.getMsgType()); + } + + /** + * 判断是否为事件消息 + * + * @param wxMessage 微信消息 + * @return 是否为事件消息 + */ + public static boolean isEventMessage(WxMpXmlMessage wxMessage) { + return WxConsts.XmlMsgType.EVENT.equals(wxMessage.getMsgType()); + } + + /** + * 判断是否为关注事件 + * + * @param wxMessage 微信消息 + * @return 是否为关注事件 + */ + public static boolean isSubscribeEvent(WxMpXmlMessage wxMessage) { + return isEventMessage(wxMessage) && WxConsts.EventType.SUBSCRIBE.equals(wxMessage.getEvent()); + } + + /** + * 判断是否为取消关注事件 + * + * @param wxMessage 微信消息 + * @return 是否为取消关注事件 + */ + public static boolean isUnsubscribeEvent(WxMpXmlMessage wxMessage) { + return isEventMessage(wxMessage) && WxConsts.EventType.UNSUBSCRIBE.equals(wxMessage.getEvent()); + } + + /** + * 判断是否为菜单点击事件 + * + * @param wxMessage 微信消息 + * @return 是否为菜单点击事件 + */ + public static boolean isMenuClickEvent(WxMpXmlMessage wxMessage) { + return isEventMessage(wxMessage) && WxConsts.EventType.CLICK.equals(wxMessage.getEvent()); + } + + /** + * 判断是否为扫码事件 + * + * @param wxMessage 微信消息 + * @return 是否为扫码事件 + */ + public static boolean isScanEvent(WxMpXmlMessage wxMessage) { + return isEventMessage(wxMessage) && + (WxConsts.EventType.SCAN.equals(wxMessage.getEvent()) || + (WxConsts.EventType.SUBSCRIBE.equals(wxMessage.getEvent()) && wxMessage.getEventKey() != null && wxMessage.getEventKey().startsWith("qrscene_"))); + } + + /** + * 验证手机号格式 + * + * @param mobile 手机号 + * @return 是否有效 + */ + public static boolean isValidMobile(String mobile) { + return mobile != null && MOBILE_PATTERN.matcher(mobile).matches(); + } + + /** + * 验证邮箱格式 + * + * @param email 邮箱 + * @return 是否有效 + */ + public static boolean isValidEmail(String email) { + return email != null && EMAIL_PATTERN.matcher(email).matches(); + } + + /** + * 验证身份证号格式 + * + * @param idCard 身份证号 + * @return 是否有效 + */ + public static boolean isValidIdCard(String idCard) { + return idCard != null && ID_CARD_PATTERN.matcher(idCard).matches(); + } + + /** + * 获取扫码事件的场景值 + * + * @param wxMessage 微信消息 + * @return 场景值 + */ + public static String getScanSceneValue(WxMpXmlMessage wxMessage) { + if (!isScanEvent(wxMessage)) { + return null; + } + + String eventKey = wxMessage.getEventKey(); + if (eventKey == null) { + return null; + } + + // 如果是关注时扫码,eventKey格式为qrscene_场景值 + if (eventKey.startsWith("qrscene_")) { + return eventKey.substring(8); + } + + // 如果是已关注用户扫码,eventKey就是场景值 + return eventKey; + } + + /** + * 过滤敏感词 + * + * @param content 原始内容 + * @return 过滤后的内容 + */ + public static String filterSensitiveWords(String content) { + if (content == null || content.trim().isEmpty()) { + return content; + } + + // 这里可以接入敏感词过滤服务 + // 简单示例:替换一些常见敏感词 + String filtered = content; + String[] sensitiveWords = {"fuck", "shit", "damn"}; + + for (String word : sensitiveWords) { + filtered = filtered.replaceAll("(?i)" + word, "***"); + } + + return filtered; + } + + /** + * 截取字符串,超出长度用省略号表示 + * + * @param str 原始字符串 + * @param maxLength 最大长度 + * @return 截取后的字符串 + */ + public static String truncateString(String str, int maxLength) { + if (str == null || str.length() <= maxLength) { + return str; + } + return str.substring(0, maxLength - 3) + "..."; + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-pay/README.md b/ruoyi-modules/ruoyi-pay/README.md new file mode 100644 index 0000000..401390c --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/README.md @@ -0,0 +1,912 @@ +# 支付中心 (RuoYi-Pay) + +## 模块简介 + +`ruoyi-pay` 是 PoJie 项目的**聚合支付中台模块**,提供多支付渠道的统一接入、订单管理、退款管理、回调通知等功能。 + +### 定位 + +`ruoyi-pay` 是一个独立于业务系统的**聚合支付中台**。它向下屏蔽微信、支付宝、银联等渠道的协议差异,向上为电商、会员、充值等业务提供统一的支付、退款、查单接口。 + +### 核心能力 + +- ✅ **多商户/多应用**: 支持多个业务线(如商城App、门店小程序)配置独立的支付参数 +- ✅ **多渠道支持**: 微信支付 (Native/JSAPI/App/H5/小程序)、支付宝 (PC/Wap/App/QR) +- ✅ **统一下单**: 一套接口适配所有渠道,支持收银台模式 +- ✅ **可靠回调**: 包含验签、防重、重试机制,确保业务方能收到通知 +- ✅ **防掉单机制**: 自动轮询"支付中"订单,主动同步三方状态 +- ✅ **退款管理**: 支持退款申请、退款查询、退款回调处理 +- ⏳ **钱包系统**: 支持用户余额充值、提现、消费(待实现) + +## 功能特性 + +### 1. 支付应用管理 ✅ + +- ✅ 支付应用CRUD操作 +- ✅ 应用状态管理(开启/关闭) +- ✅ 回调地址配置(支付回调和退款回调) + +### 2. 支付渠道管理 ✅ + +- ✅ 支付渠道CRUD操作 +- ✅ 渠道配置管理(JSON格式,支持动态配置) +- ✅ 渠道状态管理 +- ✅ 渠道费率配置 +- ✅ 支持支付宝、微信支付等多种渠道 + +### 3. 支付订单管理 ✅ + +- ✅ 支付订单创建 +- ✅ 订单状态查询 +- ✅ 订单同步(防掉单) +- ✅ 订单过期处理 +- ✅ 订单关闭 +- ✅ 商户订单号唯一性校验 + +### 4. 退款管理 ✅ + +- ✅ 退款申请 +- ✅ 退款状态查询 +- ✅ 退款回调处理 +- ✅ 部分退款支持 +- ✅ 退款金额校验 + +### 5. 回调通知 ✅ + +- ✅ 支付回调处理 +- ✅ 退款回调处理 +- ✅ 异步通知重试机制 +- ✅ 指数退避重试策略(15s → 30s → 1m → 2m → 5m) +- ✅ 回调验签和幂等性处理 + +### 6. 定时任务 ✅ + +- ✅ 支付订单过期处理(每分钟) +- ✅ 支付订单状态同步(每2分钟,防掉单) +- ✅ 支付回调通知重试(每30秒) + +## 支持的支付渠道 + +### 支付宝 + +| 渠道编码 | 名称 | 说明 | 状态 | +|---------|------|------|------| +| `alipay_pc` | 支付宝 PC 支付 | 电脑网站支付 | ✅ | +| `alipay_wap` | 支付宝 Wap 支付 | 手机网站支付 | ✅ | +| `alipay_app` | 支付宝 App 支付 | 手机 App 支付 | ✅ | +| `alipay_qr` | 支付宝扫码支付 | 当面付 | ✅ | + +### 微信支付 + +| 渠道编码 | 名称 | 说明 | 状态 | +|---------|------|------|------| +| `wx_pub` | 微信 JSAPI 支付 | 公众号、小程序支付 | ✅ | +| `wx_lite` | 微信小程序支付 | 独立小程序支付 | ✅ | +| `wx_app` | 微信 App 支付 | 手机 App 支付 | ✅ | +| `wx_native` | 微信 Native 支付 | 扫码支付 | ✅ | +| `wx_h5` | 微信 H5 支付 | 手机浏览器支付 | ✅ | + +### 其他渠道 + +| 渠道编码 | 名称 | 说明 | 状态 | +|---------|------|------|------| +| `mock` | 模拟支付 | 开发测试用 | ⏳ 待实现 | +| `wallet` | 钱包支付 | 余额支付 | ⏳ 待实现 | + +## 核心架构设计 + +### 实体关系图 (ERD) + +``` +┌──────────┐ +│ PayApp │ (支付应用) +└────┬─────┘ + │ 1 + │ + │ N +┌────▼──────────┐ +│ PayChannel │ (支付渠道) +└────┬──────────┘ + │ + │ N +┌────▼──────────┐ +│ PayOrder │ (支付订单) +└────┬──────────┘ + │ 1 + │ + │ N +┌────▼──────────┐ ┌──────────────┐ +│ PayRefund │ │ PayNotifyTask│ (回调任务) +└───────────────┘ └──────────────┘ +``` + +### 核心流程 + +1. **商户入驻**: 在管理后台创建 `应用(App)`,并在该应用下配置 `渠道(Channel)` 参数(如微信AppID、支付宝公钥) +2. **业务下单**: 业务方调用 `pay/order/submit`,传入 `appId` 和 `channelCode` +3. **支付路由**: 支付中心根据 `appId + channelCode` 读取动态配置,实例化对应的支付客户端策略 + +### 设计模式 + +#### 策略模式 (Strategy Pattern) + +使用策略模式实现不同支付渠道的统一接口: + +- `PayStrategy` - 支付策略接口,定义统一的操作方法 +- `AlipayStrategy` - 支付宝策略实现 +- `WechatPayStrategy` - 微信支付策略实现 +- `PayStrategyFactory` - 策略工厂,负责策略的注册和获取 + +#### 事件驱动 (Event-Driven) + +- `PaySuccessEvent` - 支付成功事件 +- `PayOrderListener` - 监听支付成功事件,创建回调任务 + +## 数据库设计 + +### 1. 支付应用表 (`pay_app`) + +用于隔离不同业务线的支付配置。 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGINT | 应用ID(主键) | +| name | VARCHAR(64) | 应用名 | +| status | TINYINT | 状态(0开启 1关闭) | +| remark | VARCHAR(255) | 备注 | +| pay_notify_url | VARCHAR(1024) | 支付结果回调地址 | +| refund_notify_url | VARCHAR(1024) | 退款结果回调地址 | +| create_by | VARCHAR(64) | 创建者 | +| create_time | DATETIME | 创建时间 | +| update_by | VARCHAR(64) | 更新者 | +| update_time | DATETIME | 更新时间 | + +**索引**: `PRIMARY KEY (id)` + +### 2. 支付渠道表 (`pay_channel`) + +存储具体的支付参数(JSON格式),实现无需重启即可修改密钥。 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGINT | 渠道ID(主键) | +| code | VARCHAR(32) | 渠道编码(alipay_pc, wx_pub) | +| status | TINYINT | 状态(0开启 1关闭) | +| remark | VARCHAR(255) | 备注 | +| fee_rate | DOUBLE | 渠道费率(%) | +| app_id | BIGINT | 应用ID | +| config | TEXT | 支付渠道配置(JSON) | +| create_by | VARCHAR(64) | 创建者 | +| create_time | DATETIME | 创建时间 | +| update_by | VARCHAR(64) | 更新者 | +| update_time | DATETIME | 更新时间 | + +**索引**: `PRIMARY KEY (id)` + +### 3. 支付订单表 (`pay_order`) + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGINT | 订单ID(主键) | +| app_id | BIGINT | 应用ID | +| channel_id | BIGINT | 渠道ID | +| channel_code | VARCHAR(32) | 渠道编码 | +| merchant_order_id | VARCHAR(64) | 商户订单编号 | +| subject | VARCHAR(32) | 商品标题 | +| body | VARCHAR(128) | 商品描述 | +| notify_url | VARCHAR(1024) | 异步通知地址 | +| price | INT | 支付金额(分) | +| channel_fee_rate | DOUBLE | 渠道费率 | +| channel_fee_price | INT | 渠道手续费(分) | +| status | TINYINT | 状态(0未支付 10已支付 20已退款 30已关闭) | +| user_ip | VARCHAR(50) | 用户IP | +| expire_time | DATETIME | 订单失效时间 | +| success_time | DATETIME | 支付成功时间 | +| extension_id | BIGINT | 拓展业务ID | +| no | VARCHAR(32) | 支付单号(内部生成) | +| channel_order_no | VARCHAR(64) | 渠道订单号(微信/支付宝) | +| channel_user_id | VARCHAR(64) | 渠道用户OpenId | +| channel_extras | VARCHAR(1024) | 渠道额外参数(JSON) | +| create_by | VARCHAR(64) | 创建者 | +| create_time | DATETIME | 创建时间 | +| update_by | VARCHAR(64) | 更新者 | +| update_time | DATETIME | 更新时间 | + +**索引**: +- `PRIMARY KEY (id)` +- `KEY idx_merchant_order_id (merchant_order_id)` + +### 4. 退款订单表 (`pay_refund`) + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGINT | 退款ID(主键) | +| no | VARCHAR(32) | 退款单号 | +| app_id | BIGINT | 应用ID | +| channel_id | BIGINT | 渠道ID | +| channel_code | VARCHAR(32) | 渠道编码 | +| order_id | BIGINT | 支付订单ID | +| order_no | VARCHAR(32) | 支付订单no | +| merchant_order_id | VARCHAR(64) | 商户订单编号 | +| merchant_refund_id | VARCHAR(64) | 商户退款编号 | +| notify_url | VARCHAR(1024) | 回调地址 | +| status | TINYINT | 状态(0等待 10成功 20失败) | +| pay_price | INT | 支付金额(分) | +| refund_price | INT | 退款金额(分) | +| reason | VARCHAR(256) | 退款原因 | +| user_ip | VARCHAR(50) | 用户IP | +| channel_order_no | VARCHAR(64) | 渠道订单号 | +| channel_refund_no | VARCHAR(64) | 渠道退款单号 | +| success_time | DATETIME | 退款成功时间 | +| error_code | VARCHAR(32) | 渠道错误码 | +| error_msg | VARCHAR(128) | 渠道错误描述 | +| create_by | VARCHAR(64) | 创建者 | +| create_time | DATETIME | 创建时间 | +| update_by | VARCHAR(64) | 更新者 | +| update_time | DATETIME | 更新时间 | + +**索引**: `PRIMARY KEY (id)` + +### 5. 支付通知任务表 (`pay_notify_task`) + +用于记录支付/退款回调通知任务,支持重试机制。 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGINT | 任务ID(主键) | +| order_id | BIGINT | 订单ID | +| order_no | VARCHAR(32) | 订单号 | +| notify_url | VARCHAR(1024) | 通知地址 | +| notify_body | TEXT | 通知内容(JSON) | +| retry_count | INT | 重试次数(默认0) | +| max_retry | INT | 最大重试次数(默认5) | +| status | TINYINT | 状态(0进行中 1成功 2失败) | +| next_notify_time | DATETIME | 下次通知时间 | +| create_by | VARCHAR(64) | 创建者 | +| create_time | DATETIME | 创建时间 | +| update_by | VARCHAR(64) | 更新者 | +| update_time | DATETIME | 更新时间 | + +**索引**: +- `PRIMARY KEY (id)` +- `KEY idx_next_notify_time (next_notify_time)` + +### 6. 支付通知日志表 (`pay_notify_log`) + +用于记录回调通知的请求和响应日志(可选)。 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | BIGINT | 日志ID(主键) | +| app_id | BIGINT | 应用ID | +| channel_id | BIGINT | 渠道ID | +| order_id | BIGINT | 支付订单ID | +| notify_type | VARCHAR(32) | 通知类型(pay, refund) | +| status | TINYINT | 处理状态(0处理中 10成功 20失败) | +| request_params | TEXT | 请求参数 | +| response_body | TEXT | 响应结果 | +| create_time | DATETIME | 创建时间 | + +**索引**: +- `PRIMARY KEY (id)` +- `KEY idx_order_id (order_id)` + +**数据库脚本位置**: `script/sql/ry_pay.sql` + +## 关键枚举定义 + +### 渠道编码 (PayChannelEnum) + +见 [支持的支付渠道](#支持的支付渠道) 章节。 + +### 订单状态 (PayOrderStatusEnum) + +| 状态码 | 描述 | +|:---|:---| +| `0` | **WAITING** (未支付) | +| `10` | **SUCCESS** (支付成功) | +| `20` | **REFUND** (已退款) | +| `30` | **CLOSED** (已关闭) | + +### 退款状态 (RefundStatusEnum) + +| 状态码 | 描述 | +|:---|:---| +| `0` | **WAITING** (等待处理) | +| `10` | **SUCCESS** (退款成功) | +| `20` | **FAIL** (退款失败) | + +## 模块结构 + +``` +org.dromara.pay/ +├── config/ # 配置类 +│ └── PayProperties.java +├── controller/ # 控制器 +│ ├── admin/ # 管理端 +│ │ ├── PayAppController.java +│ │ ├── PayChannelController.java +│ │ ├── PayOrderController.java +│ │ └── PayRefundController.java +│ ├── app/ # 移动端 +│ │ ├── AppPayOrderController.java +│ │ └── AppPayRefundController.java +│ └── PayNotifyController.java # 回调接口 +├── core/ # 核心策略 +│ ├── PayStrategy.java +│ ├── PayStrategyFactory.java +│ └── impl/ +│ ├── AlipayStrategy.java +│ ├── WechatPayStrategy.java +│ └── WechatPayUtil.java +├── domain/ # 领域模型 +│ ├── PayApp.java +│ ├── PayChannel.java +│ ├── PayOrder.java +│ ├── PayRefund.java +│ ├── PayNotifyTask.java +│ ├── bo/ # 业务对象 +│ └── vo/ # 视图对象 +├── enums/ # 枚举 +│ ├── PayChannelEnum.java +│ ├── PayOrderStatusEnum.java +│ └── RefundStatusEnum.java +├── event/ # 事件 +│ └── PaySuccessEvent.java +├── job/ # 定时任务 +│ ├── PayNotifyJob.java +│ ├── PayOrderExpireJob.java +│ └── PayOrderSyncJob.java +├── listener/ # 监听器 +│ └── PayOrderListener.java +├── mapper/ # 数据访问 +│ ├── PayAppMapper.java +│ ├── PayChannelMapper.java +│ ├── PayOrderMapper.java +│ ├── PayRefundMapper.java +│ └── PayNotifyTaskMapper.java +├── service/ # 服务层 +│ ├── PayAppService.java +│ ├── PayChannelService.java +│ ├── PayOrderService.java +│ ├── PayRefundService.java +│ ├── PayNotifyService.java +│ └── impl/ +└── util/ # 工具类 + └── WechatPayUtil.java +``` + +## 核心业务流程 + +### 1. 统一下单流程 + +``` +业务方 → 支付中心 → 策略工厂 → 三方渠道 + ↓ ↓ ↓ ↓ +提交订单 创建订单 获取策略 调用下单 + ↑ ↑ ↑ ↑ +返回参数 返回参数 返回参数 返回参数 +``` + +**详细流程**: + +1. 业务方调用 `/app/pay/order/submit` 提交支付订单 +2. 支付中心校验 App 状态和参数 +3. 创建 `pay_order` 记录(状态为 WAITING) +4. 通过策略工厂获取对应的支付策略(Alipay/Wechat) +5. 读取 `pay_channel` 配置,实例化支付客户端 +6. 调用三方渠道的下单接口 +7. 封装标准响应 `PayOrderSubmitRespVO` 返回给业务方 +8. 业务方使用返回的支付参数调起支付 + +### 2. 支付回调流程 + +``` +三方渠道 → 支付中心 → 业务方 + ↓ ↓ ↓ +异步回调 验签更新 接收通知 + ↑ ↑ ↑ +返回成功 创建任务 返回success +``` + +**详细流程**: + +1. 支付成功后,三方渠道异步回调 `/pay/notify/{channelCode}/{channelId}` +2. 支付中心解析参数并进行验签 +3. 加分布式锁(`lock:order:{id}`)确保幂等性 +4. 幂等校验(若已支付则忽略) +5. 更新 `pay_order` 状态为 SUCCESS +6. 发布支付成功事件(`PaySuccessEvent`) +7. 监听器创建 `pay_notify_task` 回调任务 +8. 立即执行一次 HTTP 回调通知业务方 +9. 业务方返回 `"success"` 或 `{"code": 200}` +10. 如果失败,定时任务按指数退避策略重试 + +### 3. 退款流程 + +``` +业务方 → 支付中心 → 三方渠道 + ↓ ↓ ↓ +申请退款 创建退款 调用退款 + ↑ ↑ ↑ +回调通知 更新状态 返回结果 +``` + +**详细流程**: + +1. 业务方调用 `/app/pay/refund/apply` 申请退款 +2. 支付中心校验订单(是否已支付、金额是否足够) +3. 创建 `pay_refund` 记录(状态为 WAITING) +4. 调用三方渠道的退款接口 +5. **如果三方同步返回退款成功**: + - 更新 `pay_refund` 状态为 SUCCESS + - 更新 `pay_order` 状态(REFUND/部分退款) + - 发送退款回调通知 +6. **如果三方异步处理中**: + - 保持 WAITING 状态等待回调 + - 接收三方异步退款回调 + - 更新 `pay_refund` 和 `pay_order` 状态 + - 发送退款回调通知 + +### 4. 防掉单轮询流程 + +``` +定时任务 → 支付中心 → 三方渠道 → 业务方 + ↓ ↓ ↓ ↓ +扫描订单 查询状态 返回状态 补发通知 + ↓ ↓ +更新状态 触发逻辑 +``` + +**详细流程**: + +1. 定时任务(每2分钟)扫描状态为 `WAITING`、创建时间超过2分钟、未过期的订单 +2. 调用三方渠道的查单接口 +3. **如果已支付(掉单补救)**: + - 更新 `pay_order` 状态为 SUCCESS + - 触发支付成功逻辑 + - 补发支付成功通知给业务方 +4. **如果支付关闭/超时**: + - 更新 `pay_order` 状态为 CLOSED + - (可选)调用三方关单接口 +5. **如果等待支付**: + - 不做处理,继续等待 + +## API 接口文档 + +### 管理端接口 + +#### 支付应用管理 (`/pay/app`) + +| 方法 | 路径 | 说明 | 权限 | +|:---|:---|:---|:---| +| GET | `/pay/app/list` | 查询支付应用列表 | `pay:app:list` | +| GET | `/pay/app/{id}` | 查询支付应用详情 | `pay:app:query` | +| POST | `/pay/app` | 新增支付应用 | `pay:app:add` | +| PUT | `/pay/app` | 修改支付应用 | `pay:app:edit` | +| DELETE | `/pay/app/{ids}` | 删除支付应用 | `pay:app:remove` | +| POST | `/pay/app/export` | 导出支付应用列表 | `pay:app:export` | + +#### 支付渠道管理 (`/pay/channel`) + +| 方法 | 路径 | 说明 | 权限 | +|:---|:---|:---|:---| +| GET | `/pay/channel/list` | 查询支付渠道列表 | `pay:channel:list` | +| GET | `/pay/channel/{id}` | 查询支付渠道详情 | `pay:channel:query` | +| POST | `/pay/channel` | 新增支付渠道 | `pay:channel:add` | +| PUT | `/pay/channel` | 修改支付渠道 | `pay:channel:edit` | +| DELETE | `/pay/channel/{ids}` | 删除支付渠道 | `pay:channel:remove` | +| POST | `/pay/channel/export` | 导出支付渠道列表 | `pay:channel:export` | + +#### 支付订单管理 (`/pay/order`) + +| 方法 | 路径 | 说明 | 权限 | +|:---|:---|:---|:---| +| GET | `/pay/order/list` | 查询支付订单列表 | `pay:order:list` | +| GET | `/pay/order/{id}` | 查询支付订单详情 | `pay:order:query` | +| POST | `/pay/order` | 新增支付订单 | `pay:order:add` | +| POST | `/pay/order/submit` | 提交支付订单 | `pay:order:submit` | +| PUT | `/pay/order` | 修改支付订单 | `pay:order:edit` | +| DELETE | `/pay/order/{ids}` | 删除支付订单 | `pay:order:remove` | +| POST | `/pay/order/export` | 导出支付订单列表 | `pay:order:export` | + +#### 退款订单管理 (`/pay/refund`) + +| 方法 | 路径 | 说明 | 权限 | +|:---|:---|:---|:---| +| GET | `/pay/refund/list` | 查询退款订单列表 | `pay:refund:list` | +| GET | `/pay/refund/{id}` | 查询退款订单详情 | `pay:refund:query` | +| POST | `/pay/refund` | 新增退款订单 | `pay:refund:add` | +| POST | `/pay/refund/apply` | 申请退款 | `pay:refund:apply` | +| PUT | `/pay/refund` | 修改退款订单 | `pay:refund:edit` | +| DELETE | `/pay/refund/{ids}` | 删除退款订单 | `pay:refund:remove` | +| POST | `/pay/refund/export` | 导出退款订单列表 | `pay:refund:export` | + +### 移动端接口 + +#### 支付订单 (`/app/pay/order`) + +| 方法 | 路径 | 说明 | +|:---|:---|:---| +| POST | `/app/pay/order/submit` | 提交支付订单 | +| GET | `/app/pay/order/list` | 查询支付订单列表 | +| GET | `/app/pay/order/{id}` | 查询支付订单详情 | +| GET | `/app/pay/order/query` | 根据商户订单号查询 | + +**提交支付订单请求示例**: +```json +{ + "appId": 1, + "channelCode": "wx_pub", + "channelId": 1, + "merchantOrderId": "ORDER20240101001", + "subject": "商品订单", + "body": "商品订单-ORDER20240101001", + "price": 10000, + "notifyUrl": "https://your-domain.com/app/mall/pay/notify", + "userIp": "127.0.0.1", + "channelUserId": "openid_xxx" +} +``` + +**响应示例**: +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "payOrderNo": "PAY20240101001", + "payData": "{\"appId\":\"wx123\",\"timeStamp\":\"1234567890\",\"nonceStr\":\"abc\",\"package\":\"prepay_id=xxx\",\"signType\":\"RSA\",\"paySign\":\"xxx\"}", + "status": 0 + } +} +``` + +#### 退款 (`/app/pay/refund`) + +| 方法 | 路径 | 说明 | +|:---|:---|:---| +| POST | `/app/pay/refund/apply` | 申请退款 | +| GET | `/app/pay/refund/list` | 查询退款订单列表 | +| GET | `/app/pay/refund/{id}` | 查询退款订单详情 | +| GET | `/app/pay/refund/query` | 根据商户退款编号查询 | + +**申请退款请求示例**: +```json +{ + "orderNo": "PAY20240101001", + "merchantRefundId": "REFUND20240101001", + "refundPrice": 10000, + "reason": "用户申请退款", + "notifyUrl": "https://your-domain.com/app/mall/refund/notify", + "userIp": "127.0.0.1" +} +``` + +### 回调接口 + +#### 支付回调 (`/pay/notify`) + +| 方法 | 路径 | 说明 | +|:---|:---|:---| +| POST | `/pay/notify/{channelCode}/{channelId}` | 接收支付回调 | + +**回调通知内容**: +```json +{ + "orderNo": "PAY20240101001", + "merchantOrderId": "ORDER20240101001", + "status": 10, + "price": 10000, + "channelUserId": "openid_xxx", + "successTime": "2024-01-01 12:00:00", + "channelOrderNo": "wx20240101001" +} +``` + +**业务方响应**: 返回 `"success"` 或 `{"code": 200}` 表示成功 + +#### 退款回调 (`/pay/notify/refund`) + +| 方法 | 路径 | 说明 | +|:---|:---|:---| +| POST | `/pay/notify/refund/{channelCode}/{channelId}` | 接收退款回调 | + +## 配置说明 + +### 支付应用配置 + +```json +{ + "name": "电商应用", + "status": 0, + "remark": "电商业务支付应用", + "payNotifyUrl": "https://your-domain.com/pay/notify", + "refundNotifyUrl": "https://your-domain.com/pay/notify/refund" +} +``` + +### 支付宝渠道配置 + +```json +{ + "serverUrl": "https://openapi.alipay.com/gateway.do", + "appId": "your_app_id", + "privateKey": "your_private_key", + "publicKey": "your_public_key", + "signType": "RSA2" +} +``` + +**说明**: +- `serverUrl`: 支付宝网关地址(生产环境/沙箱环境) +- `appId`: 支付宝应用ID +- `privateKey`: 应用私钥(RSA2格式) +- `publicKey`: 支付宝公钥 +- `signType`: 签名类型,固定为 `RSA2` + +### 微信支付渠道配置 + +```json +{ + "appId": "your_app_id", + "mchId": "your_mch_id", + "privateKey": "your_private_key", + "mchSerialNo": "your_serial_no", + "apiV3Key": "your_api_v3_key" +} +``` + +**说明**: +- `appId`: 微信应用ID(公众号/小程序/App) +- `mchId`: 商户号 +- `privateKey`: 商户私钥(PEM格式) +- `mchSerialNo`: 商户证书序列号 +- `apiV3Key`: APIv3密钥 + +## 定时任务 + +### 支付订单过期处理 (PayOrderExpireJob) + +- **执行频率**: 每分钟 (`0 0/1 * * * ?`) +- **功能**: + - 扫描状态为 `WAITING` 且已过期的订单 + - 调用支付渠道的关单接口 + - 更新订单状态为 `CLOSED` +- **限制**: 每次处理最多100条,防止积压 + +### 支付订单状态同步 (PayOrderSyncJob) + +- **执行频率**: 每2分钟 (`0 0/2 * * * ?`) +- **功能**: + - 扫描状态为 `WAITING`、创建时间超过2分钟、未过期的订单 + - 调用支付渠道的查单接口 + - 如果已支付,更新订单状态并触发支付成功逻辑 + - 如果已关闭,更新订单状态 +- **目的**: 防止掉单,主动同步三方状态 + +### 支付回调通知重试 (PayNotifyJob) + +- **执行频率**: 每30秒 (`0/30 * * * * ?`) +- **功能**: + - 扫描状态为 `进行中` 且到达通知时间的任务 + - 执行HTTP回调通知 + - 根据响应判断成功或失败 + - 失败时按指数退避策略计算下次通知时间 +- **重试策略**: 15s → 30s → 1m → 2m → 5m +- **最大重试次数**: 5次 + +## 数据校验规则 + +### 支付订单校验 + +- ✅ 商户订单号唯一性校验 +- ✅ 支付单号唯一性校验 +- ✅ 删除时校验:已支付的订单不能删除 + +### 退款订单校验 + +- ✅ 退款单号唯一性校验 +- ✅ 商户退款编号唯一性校验 +- ✅ 退款金额不能超过支付金额 +- ✅ 删除时校验:处理中的退款不能删除 + +### 支付应用校验 + +- ✅ 应用名称不能为空 +- ✅ 回调地址格式校验(必须以 http:// 或 https:// 开头) + +### 支付渠道校验 + +- ✅ 渠道编码不能为空 +- ✅ 同一应用下渠道编码唯一性校验 +- ✅ 渠道配置JSON格式校验 + +## 接入指南 + +### Maven 依赖 + +```xml + + org.dromara + ruoyi-pay + ${revision} + +``` + +### 快速开始 + +#### 步骤1: 创建支付应用 + +在管理后台创建支付应用,配置回调地址。 + +#### 步骤2: 配置支付渠道 + +在应用下配置支付渠道,填写渠道参数(JSON格式)。 + +#### 步骤3: 调用统一下单接口 + +```java +PayOrderBo bo = new PayOrderBo(); +bo.setAppId(1L); +bo.setChannelCode("wx_pub"); +bo.setMerchantOrderId("ORDER001"); +bo.setSubject("商品订单"); +bo.setPrice(10000); +bo.setNotifyUrl("https://your-domain.com/notify"); +PayOrderSubmitRespVO resp = payOrderService.submitOrder(bo); +``` + +#### 步骤4: 处理支付回调 + +实现回调接口,接收支付成功通知: +```java +@PostMapping("/notify") +public String notify(@RequestBody Map data) { + String orderNo = (String) data.get("merchantOrderId"); + // 处理业务逻辑 + return "success"; +} +``` + +## 扩展开发 + +### 添加新的支付渠道 + +1. 实现 `PayStrategy` 接口 +2. 在 `@PostConstruct` 方法中注册到 `PayStrategyFactory` +3. 在 `PayChannelEnum` 中添加新的渠道枚举 +4. 配置渠道参数 + +示例: +```java +@Component +@RequiredArgsConstructor +public class NewPayStrategy implements PayStrategy { + private final PayStrategyFactory payStrategyFactory; + + @PostConstruct + public void init() { + payStrategyFactory.register("new_pay", this); + } + + @Override + public PayOrderSubmitRespVO submitOrder(PayOrderBo bo, PayChannel channel) { + // 实现下单逻辑 + } + + // 实现其他接口方法... +} +``` + +## 错误码定义 + +| 错误信息 | 说明 | +|:---|:---| +| `应用不存在或已禁用` | 支付应用不存在或状态为关闭 | +| `支付渠道不存在或已禁用` | 支付渠道不存在或状态为关闭 | +| `商户订单号已存在` | 商户订单号重复 | +| `支付单号已存在` | 支付单号重复 | +| `退款单号已存在` | 退款单号重复 | +| `商户退款编号已存在` | 商户退款编号重复 | +| `退款金额不能超过支付金额` | 退款金额校验失败 | +| `订单不存在` | 支付订单不存在 | +| `订单未支付或已退款` | 订单状态不允许退款 | +| `不支持的支付渠道` | 渠道编码不在支持列表中 | +| `未找到支付策略` | 策略未注册 | + +## 扩展功能规划 + +### 收银台模式 ⏳ + +- 提供统一的 H5 收银台页面 +- 业务方只需传入订单号,跳转至收银台 +- 用户在收银台选择微信/支付宝/余额支付 +- **状态**: ⏳ 待实现 + +### 钱包系统 (Wallet) ⏳ + +- **充值**: 创建充值订单 -> 调起支付 -> 回调增加余额 +- **消费**: 余额扣减 -> 生成消费记录 +- **提现**: 申请提现 -> 管理员审核 -> 企业付款到零钱/银行卡 +- **状态**: ⏳ 待实现 + +### 模拟支付 (Mock) ⏳ + +- 开发环境专用 +- 下单直接返回成功,不调用真实三方接口 +- 用于前端联调和流程测试 +- **状态**: ⏳ 待实现 + +### 对账功能 ⏳ + +- 每日对账任务,对比支付中心订单与三方渠道订单 +- 生成对账报表,标记差异订单 +- **状态**: ⏳ 待实现 + +### 支付统计报表 ⏳ + +- 按日/月统计支付金额、订单数、成功率等 +- 按渠道统计支付数据 +- **状态**: ⏳ 待实现 + +## 注意事项 + +### 幂等性 + +- 支付回调需要保证幂等处理,避免重复处理 +- 通过订单状态判断,已支付的订单直接返回成功 +- 使用分布式锁确保并发安全 + +### 安全性 + +- 回调接口需要验签,确保请求来自支付渠道 +- 使用 HTTPS 传输敏感信息 +- 渠道配置中的密钥需要妥善保管 +- 不要在日志中输出敏感信息(密钥、密码等) + +### 超时处理 + +- 支付订单默认30分钟过期,可通过配置调整 +- 过期订单会自动关闭,调用渠道关单接口 +- 业务方需要及时处理订单,避免过期 + +### 重试机制 + +- 回调通知采用指数退避策略,最多重试5次 +- 重试间隔:15s → 30s → 1m → 2m → 5m +- 业务方需要快速响应回调,返回 `"success"` 或 `{"code": 200}` +- 回调响应时间建议控制在3秒内 + +### 防掉单 + +- 定时任务主动查询订单状态,防止掉单 +- 查询创建时间超过2分钟且未过期的待支付订单 +- 如果发现已支付但未收到回调,主动触发支付成功逻辑 + +### 金额单位 + +- 所有金额以"分"为单位存储和传递 +- 前端展示时需要转换为"元" +- 计算时注意单位转换,避免精度问题 + +### 回调地址 + +- 回调地址必须是公网可访问的 HTTPS 地址 +- 回调地址需要支持 POST 请求 +- 回调响应需要快速返回,避免超时 +- 建议使用内网地址 + 反向代理的方式处理回调 + + +## 依赖模块 + +- `ruoyi-common-core` - 核心工具类 +- `ruoyi-common-mybatis` - 数据库操作 +- `ruoyi-common-web` - Web框架支持 +- `ruoyi-common-security` - 安全认证(Sa-Token) +- `ruoyi-common-log` - 日志记录 +- `ruoyi-common-idempotent` - 幂等性控制 +- `ruoyi-common-redis` - Redis缓存 +- `alipay-sdk-java` - 支付宝SDK +- `wechatpay-java` - 微信支付SDK +- `weixin-java-mp` - 微信公众号SDK(用于JSAPI支付) diff --git a/ruoyi-modules/ruoyi-pay/pom.xml b/ruoyi-modules/ruoyi-pay/pom.xml new file mode 100644 index 0000000..d5351fd --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/pom.xml @@ -0,0 +1,78 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + + ruoyi-pay + + + ruoyi-pay支付模块 + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-log + + + + org.dromara + ruoyi-common-idempotent + + + + org.dromara + ruoyi-common-redis + + + + + com.alipay.sdk + alipay-sdk-java + + + + com.github.wechatpay-apiv3 + wechatpay-java + + + + com.github.binarywang + weixin-java-mp + + + + + diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/config/PayProperties.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/config/PayProperties.java new file mode 100644 index 0000000..5b00228 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/config/PayProperties.java @@ -0,0 +1,131 @@ +package org.dromara.pay.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +/** + * 支付配置属性 + * + * @author pengles + */ +@Data +@Component +@Validated +@ConfigurationProperties(prefix = "pay") +public class PayProperties { + + /** + * 支付宝配置 + */ + @Valid + private AlipayProperties alipay = new AlipayProperties(); + + /** + * 微信支付配置 + */ + @Valid + private WechatProperties wechat = new WechatProperties(); + + @Data + public static class AlipayProperties { + /** + * 应用ID + */ + @NotBlank(message = "支付宝AppId不能为空") + private String appId; + + /** + * 商户号 + */ + @NotBlank(message = "支付宝商户号不能为空") + private String mchId; + + /** + * 支付宝网关地址 + */ + @NotBlank(message = "支付宝网关地址不能为空") + private String serverUrl; + + /** + * 支付宝公钥 + */ + @NotBlank(message = "支付宝公钥不能为空") + private String alipayPublicKey; + + /** + * 应用私钥 + */ + @NotBlank(message = "应用私钥不能为空") + private String privateKey; + + /** + * 异步通知地址 + */ + @NotBlank(message = "支付宝异步通知地址不能为空") + private String notifyUrl; + + /** + * 同步跳转地址 + */ + @NotBlank(message = "支付宝同步跳转地址不能为空") + private String returnUrl; + + /** + * 签名类型 + */ + private String signType = "RSA2"; + + /** + * 字符编码 + */ + private String charset = "UTF-8"; + } + + @Data + public static class WechatProperties { + /** + * 公众号AppId 1 + */ + @NotBlank(message = "微信公众号AppId不能为空") + private String appMp1Id; + + /** + * 公众号AppId 2 + */ + private String appMp2Id; + + /** + * 商户号 + */ + @NotBlank(message = "微信商户号不能为空") + private String mchId; + + /** + * 商户证书序列号 + */ + @NotBlank(message = "微信商户证书序列号不能为空") + private String mchSerialNo; + + /** + * 商户API私钥 + */ + @NotBlank(message = "微信商户API私钥不能为空") + private String privateKey; + + /** + * API V3 密钥 + */ + @NotBlank(message = "微信API V3密钥不能为空") + private String apiV3Key; + + /** + * 异步通知地址 + */ + @NotBlank(message = "微信异步通知地址不能为空") + private String notifyUrl; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/PayNotifyController.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/PayNotifyController.java new file mode 100644 index 0000000..1359048 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/PayNotifyController.java @@ -0,0 +1,136 @@ +package org.dromara.pay.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.pay.core.PayStrategy; +import org.dromara.pay.core.PayStrategyFactory; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.vo.PayNotifyResult; +import org.dromara.pay.domain.vo.PayChannelVo; +import org.dromara.pay.service.PayChannelService; +import org.dromara.pay.service.PayOrderService; +import org.springframework.web.bind.annotation.*; + +/** + * 支付回调通知 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/pay/notify") +public class PayNotifyController { + + private final PayOrderService payOrderService; + private final PayChannelService payChannelService; + private final PayStrategyFactory payStrategyFactory; + private final org.dromara.pay.service.PayRefundService payRefundService; + + /** + * 接收支付回调 + * + * @param channelCode 渠道编码 + * @param channelId 渠道ID + * @param request 请求对象 + */ + @SaIgnore + @PostMapping("/{channelCode}/{channelId}") + public String notify(@PathVariable String channelCode, @PathVariable Long channelId, HttpServletRequest request) { + log.info("收到支付回调: channelCode={}, channelId={}", channelCode, channelId); + + // 1. 获取渠道配置 + PayChannelVo channelVo = payChannelService.queryById(channelId); + if (channelVo == null) { + log.error("支付回调失败,渠道不存在: {}", channelId); + return "failure"; + } + if (!channelVo.getCode().equals(channelCode)) { + log.error("支付回调失败,渠道编码不匹配: {} != {}", channelVo.getCode(), channelCode); + return "failure"; + } + PayChannel channel = MapstructUtils.convert(channelVo, PayChannel.class); + + // 2. 获取策略处理回调 + try { + PayStrategy strategy = payStrategyFactory.getStrategy(channelCode); + PayNotifyResult result = strategy.processNotify(request, channel); + + if (result.isSuccess()) { + // 3. 更新订单状态 + // 这里需要根据 orderNo 查询订单,确保存在且未支付 + // 暂时假设 PayOrderService 有方法处理状态更新,或者我们直接在这里处理 + // 由于 PayOrderService 主要是 CRUD,这里我们可能需要扩展它或直接操作 + + // 简单实现:查询订单 -> 更新状态 + // 注意:需要幂等处理 (result.isSuccess() 只是验签通过,业务状态可能已经更新) + handleOrderSuccess(result.getOrderNo(), result.getTradeNo(), channelCode); + + return result.getResponseBody(); + } else { + log.error("支付回调验签失败: {}", channelCode); + return result.getResponseBody(); // 通常是 failure + } + } catch (Exception e) { + log.error("支付回调处理异常", e); + return "failure"; + } + } + + private void handleOrderSuccess(String orderNo, String channelOrderNo, String channelCode) { + payOrderService.notifyOrderSuccess(orderNo, channelOrderNo); + } + + /** + * 接收退款回调 + * + * @param channelCode 渠道编码 + * @param channelId 渠道ID + * @param request 请求对象 + */ + @SaIgnore + @PostMapping("/refund/{channelCode}/{channelId}") + public String refundNotify(@PathVariable String channelCode, @PathVariable Long channelId, HttpServletRequest request) { + log.info("收到退款回调: channelCode={}, channelId={}", channelCode, channelId); + + // 1. 获取渠道配置 + PayChannelVo channelVo = payChannelService.queryById(channelId); + if (channelVo == null) { + log.error("退款回调失败,渠道不存在: {}", channelId); + return "failure"; + } + if (!channelVo.getCode().equals(channelCode)) { + log.error("退款回调失败,渠道编码不匹配: {} != {}", channelVo.getCode(), channelCode); + return "failure"; + } + PayChannel channel = MapstructUtils.convert(channelVo, PayChannel.class); + + // 2. 获取策略处理回调(退款回调通常与支付回调类似,但需要解析退款单号) + try { + PayStrategy strategy = payStrategyFactory.getStrategy(channelCode); + PayNotifyResult result = strategy.processNotify(request, channel); + + if (result.isSuccess()) { + // 3. 更新退款状态 + // 注意:退款回调中,orderNo可能是退款单号,需要根据实际情况调整 + // 这里假设result.getOrderNo()是退款单号 + handleRefundSuccess(result.getOrderNo(), result.getTradeNo(), channelCode); + + return result.getResponseBody(); + } else { + log.error("退款回调验签失败: {}", channelCode); + return result.getResponseBody(); + } + } catch (Exception e) { + log.error("退款回调处理异常", e); + return "failure"; + } + } + + private void handleRefundSuccess(String refundNo, String channelRefundNo, String channelCode) { + payRefundService.notifyRefundSuccess(refundNo, channelRefundNo); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayAppController.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayAppController.java new file mode 100644 index 0000000..e870028 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayAppController.java @@ -0,0 +1,84 @@ +package org.dromara.pay.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.pay.domain.bo.PayAppBo; +import org.dromara.pay.domain.vo.PayAppVo; +import org.dromara.pay.service.PayAppService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Arrays; + +/** + * 支付应用 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/pay/app") +public class PayAppController extends BaseController { + + private final PayAppService payAppService; + + /** + * 查询支付应用列表 + */ + @SaCheckPermission("pay:app:list") + @GetMapping("/list") + public TableDataInfo list(PayAppBo bo, PageQuery pageQuery) { + return payAppService.queryPageList(bo, pageQuery); + } + + /** + * 获取支付应用详细信息 + * + * @param id 主键 + */ + @SaCheckPermission("pay:app:query") + @GetMapping("/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(payAppService.queryById(id)); + } + + /** + * 新增支付应用 + */ + @SaCheckPermission("pay:app:add") + @Log(title = "支付应用", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody PayAppBo bo) { + return toAjax(payAppService.insertByBo(bo)); + } + + /** + * 修改支付应用 + */ + @SaCheckPermission("pay:app:edit") + @Log(title = "支付应用", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody PayAppBo bo) { + return toAjax(payAppService.updateByBo(bo)); + } + + /** + * 删除支付应用 + * + * @param ids 主键串 + */ + @SaCheckPermission("pay:app:remove") + @Log(title = "支付应用", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@PathVariable Long[] ids) { + return toAjax(payAppService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayChannelController.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayChannelController.java new file mode 100644 index 0000000..186a258 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayChannelController.java @@ -0,0 +1,84 @@ +package org.dromara.pay.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.pay.domain.bo.PayChannelBo; +import org.dromara.pay.domain.vo.PayChannelVo; +import org.dromara.pay.service.PayChannelService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 支付渠道 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/pay/channel") +public class PayChannelController extends BaseController { + + private final PayChannelService payChannelService; + + /** + * 查询支付渠道列表 + */ + @SaCheckPermission("pay:channel:list") + @GetMapping("/list") + public TableDataInfo list(PayChannelBo bo, PageQuery pageQuery) { + return payChannelService.queryPageList(bo, pageQuery); + } + + /** + * 获取支付渠道详细信息 + * + * @param id 主键 + */ + @SaCheckPermission("pay:channel:query") + @GetMapping("/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(payChannelService.queryById(id)); + } + + /** + * 新增支付渠道 + */ + @SaCheckPermission("pay:channel:add") + @Log(title = "支付渠道", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody PayChannelBo bo) { + return toAjax(payChannelService.insertByBo(bo)); + } + + /** + * 修改支付渠道 + */ + @SaCheckPermission("pay:channel:edit") + @Log(title = "支付渠道", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody PayChannelBo bo) { + return toAjax(payChannelService.updateByBo(bo)); + } + + /** + * 删除支付渠道 + * + * @param ids 主键串 + */ + @SaCheckPermission("pay:channel:remove") + @Log(title = "支付渠道", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@PathVariable Long[] ids) { + return toAjax(payChannelService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayOrderController.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayOrderController.java new file mode 100644 index 0000000..68812fb --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayOrderController.java @@ -0,0 +1,95 @@ +package org.dromara.pay.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.pay.domain.bo.PayOrderBo; +import org.dromara.pay.domain.vo.PayOrderSubmitRespVO; +import org.dromara.pay.domain.vo.PayOrderVo; +import org.dromara.pay.service.PayOrderService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 支付订单 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/pay/order") +public class PayOrderController extends BaseController { + + private final PayOrderService payOrderService; + + /** + * 查询支付订单列表 + */ + @SaCheckPermission("pay:order:list") + @GetMapping("/list") + public TableDataInfo list(PayOrderBo bo, PageQuery pageQuery) { + return payOrderService.queryPageList(bo, pageQuery); + } + + /** + * 获取支付订单详细信息 + * + * @param id 主键 + */ + @SaCheckPermission("pay:order:query") + @GetMapping("/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(payOrderService.queryById(id)); + } + + /** + * 新增支付订单 + */ + @SaCheckPermission("pay:order:add") + @Log(title = "支付订单", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody PayOrderBo bo) { + return toAjax(payOrderService.insertByBo(bo)); + } + + /** + * 提交支付订单 + */ + @SaCheckPermission("pay:order:submit") + @Log(title = "支付订单", businessType = BusinessType.OTHER) + @PostMapping("/submit") + public R submit(@Validated @RequestBody PayOrderBo bo) { + return R.ok(payOrderService.submitOrder(bo)); + } + + /** + * 修改支付订单 + */ + @SaCheckPermission("pay:order:edit") + @Log(title = "支付订单", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody PayOrderBo bo) { + return toAjax(payOrderService.updateByBo(bo)); + } + + /** + * 删除支付订单 + * + * @param ids 主键串 + */ + @SaCheckPermission("pay:order:remove") + @Log(title = "支付订单", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@PathVariable Long[] ids) { + return toAjax(payOrderService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayRefundController.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayRefundController.java new file mode 100644 index 0000000..b033929 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/admin/PayRefundController.java @@ -0,0 +1,84 @@ +package org.dromara.pay.controller.admin; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.pay.domain.bo.PayRefundBo; +import org.dromara.pay.domain.vo.PayRefundVo; +import org.dromara.pay.service.PayRefundService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 退款订单 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/pay/refund") +public class PayRefundController extends BaseController { + + private final PayRefundService payRefundService; + + /** + * 查询退款订单列表 + */ + @SaCheckPermission("pay:refund:list") + @GetMapping("/list") + public TableDataInfo list(PayRefundBo bo, PageQuery pageQuery) { + return payRefundService.queryPageList(bo, pageQuery); + } + + /** + * 获取退款订单详细信息 + * + * @param id 主键 + */ + @SaCheckPermission("pay:refund:query") + @GetMapping("/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(payRefundService.queryById(id)); + } + + /** + * 新增退款订单 + */ + @SaCheckPermission("pay:refund:add") + @Log(title = "退款订单", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody PayRefundBo bo) { + return toAjax(payRefundService.insertByBo(bo)); + } + + /** + * 修改退款订单 + */ + @SaCheckPermission("pay:refund:edit") + @Log(title = "退款订单", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody PayRefundBo bo) { + return toAjax(payRefundService.updateByBo(bo)); + } + + /** + * 删除退款订单 + * + * @param ids 主键串 + */ + @SaCheckPermission("pay:refund:remove") + @Log(title = "退款订单", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@PathVariable Long[] ids) { + return toAjax(payRefundService.deleteWithValidByIds(Arrays.asList(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/app/AppPayOrderController.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/app/AppPayOrderController.java new file mode 100644 index 0000000..c3315ce --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/app/AppPayOrderController.java @@ -0,0 +1,69 @@ +package org.dromara.pay.controller.app; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.pay.domain.bo.PayOrderBo; +import org.dromara.pay.domain.vo.PayOrderSubmitRespVO; +import org.dromara.pay.domain.vo.PayOrderVo; +import org.dromara.pay.service.PayOrderService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 移动端支付订单Controller + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/pay/order") +public class AppPayOrderController extends BaseController { + + private final PayOrderService payOrderService; + + /** + * 提交支付订单 + */ + @PostMapping("/submit") + public R submitOrder(@Validated @RequestBody PayOrderBo bo) { + PayOrderSubmitRespVO resp = payOrderService.submitOrder(bo); + return R.ok(resp); + } + + /** + * 查询支付订单列表 + */ + @GetMapping("/list") + public TableDataInfo list(PayOrderBo bo, PageQuery pageQuery) { + // 可以根据业务需要过滤当前用户的订单 + return payOrderService.queryPageList(bo, pageQuery); + } + + /** + * 查询支付订单详情 + */ + @GetMapping("/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(payOrderService.queryById(id)); + } + + /** + * 根据商户订单号查询支付订单 + */ + @GetMapping("/query") + public R queryByMerchantOrderId(@RequestParam String merchantOrderId) { + PayOrderBo bo = new PayOrderBo(); + bo.setMerchantOrderId(merchantOrderId); + var list = payOrderService.queryList(bo); + if (list.isEmpty()) { + return R.fail("支付订单不存在"); + } + return R.ok(list.get(0)); + } + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/app/AppPayRefundController.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/app/AppPayRefundController.java new file mode 100644 index 0000000..c8b2ae1 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/controller/app/AppPayRefundController.java @@ -0,0 +1,70 @@ +package org.dromara.pay.controller.app; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.pay.domain.bo.PayRefundBo; +import org.dromara.pay.domain.vo.PayRefundVo; +import org.dromara.pay.service.PayRefundService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 移动端退款Controller + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/pay/refund") +public class AppPayRefundController extends BaseController { + + private final PayRefundService refundService; + + /** + * 申请退款 + */ + @PostMapping("/apply") + @Log(title = "申请退款", businessType = BusinessType.INSERT) + public R applyRefund(@Validated @RequestBody PayRefundBo bo) { + return toAjax(refundService.createRefund(bo)); + } + + /** + * 查询退款订单列表 + */ + @GetMapping("/list") + public TableDataInfo list(PayRefundBo bo, PageQuery pageQuery) { + return refundService.queryPageList(bo, pageQuery); + } + + /** + * 查询退款订单详情 + */ + @GetMapping("/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(refundService.queryById(id)); + } + + /** + * 根据商户退款编号查询 + */ + @GetMapping("/query") + public R queryByMerchantRefundId(@RequestParam String merchantRefundId) { + PayRefundBo bo = new PayRefundBo(); + bo.setMerchantRefundId(merchantRefundId); + List list = refundService.queryList(bo); + if (list.isEmpty()) { + return R.fail("退款订单不存在"); + } + return R.ok(list.get(0)); + } + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/PayStrategy.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/PayStrategy.java new file mode 100644 index 0000000..88c3d94 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/PayStrategy.java @@ -0,0 +1,70 @@ +package org.dromara.pay.core; + +import jakarta.servlet.http.HttpServletRequest; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.domain.PayRefund; +import org.dromara.pay.domain.vo.PayNotifyResult; +import org.dromara.pay.domain.vo.PayOrderSubmitRespVO; + +/** + * 支付策略接口 + * + * @author pengles + */ +public interface PayStrategy { + + /** + * 统一下单 + * + * @param order 支付订单 + * @param channel 支付渠道 + * @return 提交响应 + */ + PayOrderSubmitRespVO unifiedOrder(PayOrder order, PayChannel channel); + + /** + * 退款 + * + * @param refund 退款订单 + * @param channel 支付渠道 + * @return 是否提交成功 + */ + Boolean refund(PayRefund refund, PayChannel channel); + + /** + * 查询订单状态 + * + * @param order 支付订单 + * @param channel 支付渠道 + * @return 渠道订单状态 (映射到 PayOrderStatusEnum) + */ + Integer queryOrder(PayOrder order, PayChannel channel); + + /** + * 关闭订单 + * + * @param order 支付订单 + * @param channel 支付渠道 + * @return 是否关闭成功 + */ + Boolean closeOrder(PayOrder order, PayChannel channel); + + /** + * 查询退款状态 + * + * @param refund 退款订单 + * @param channel 支付渠道 + * @return 退款状态 + */ + Integer queryRefund(PayRefund refund, PayChannel channel); + + /** + * 处理回调通知 + * + * @param request HTTP请求 + * @param channel 支付渠道 + * @return 处理结果 + */ + PayNotifyResult processNotify(HttpServletRequest request, PayChannel channel); +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/PayStrategyFactory.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/PayStrategyFactory.java new file mode 100644 index 0000000..2858399 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/PayStrategyFactory.java @@ -0,0 +1,60 @@ +package org.dromara.pay.core; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.pay.enums.PayChannelEnum; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 支付策略工厂 + * + * @author pengles + */ +@Slf4j +@Component +public class PayStrategyFactory { + + private final Map strategyMap = new ConcurrentHashMap<>(); + + /** + * 注册策略 + */ + public void register(String channelCode, PayStrategy strategy) { + strategyMap.put(channelCode, strategy); + } + + /** + * 获取策略 + */ + public PayStrategy getStrategy(String channelCode) { + PayChannelEnum channelEnum = PayChannelEnum.getByCode(channelCode); + if (channelEnum == null) { + throw new ServiceException("不支持的支付渠道: " + channelCode); + } + // 简单映射:假设 code 前缀决定策略,例如 wx_pub -> WechatPayStrategy + // 或者直接根据 code 获取 bean + // 这里为了简单,假设实现类自己注册,或者通过 BeanName 获取 + // 比如 WechatPayStrategy 处理所有 wx_ 开头的 + + // 更好的方式是策略实现类在 init 时注册自己 + PayStrategy strategy = strategyMap.get(channelCode); + if (strategy == null) { + // 尝试根据前缀查找 (例如 wx_pub, wx_lite 都用 WechatPayStrategy) + if (channelCode.startsWith("wx_")) { + strategy = strategyMap.get("wx"); + } else if (channelCode.startsWith("alipay_")) { + strategy = strategyMap.get("alipay"); + } + } + + if (strategy == null) { + throw new ServiceException("未找到支付策略: " + channelCode); + } + return strategy; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/AlipayStrategy.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/AlipayStrategy.java new file mode 100644 index 0000000..0bb01a1 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/AlipayStrategy.java @@ -0,0 +1,254 @@ +package org.dromara.pay.core.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.alipay.api.AlipayApiException; +import com.alipay.api.AlipayClient; +import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.domain.*; +import com.alipay.api.internal.util.AlipaySignature; +import com.alipay.api.request.*; +import com.alipay.api.response.*; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.pay.core.PayStrategy; +import org.dromara.pay.core.PayStrategyFactory; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.domain.PayRefund; +import org.dromara.pay.domain.vo.PayNotifyResult; +import org.dromara.pay.domain.vo.PayOrderSubmitRespVO; +import org.dromara.pay.enums.PayChannelEnum; +import org.dromara.pay.enums.PayOrderStatusEnum; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * 支付宝支付策略 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class AlipayStrategy implements PayStrategy { + + private final PayStrategyFactory payStrategyFactory; + + @PostConstruct + public void init() { + payStrategyFactory.register(PayChannelEnum.ALIPAY_PC.getCode(), this); + payStrategyFactory.register(PayChannelEnum.ALIPAY_WAP.getCode(), this); + payStrategyFactory.register(PayChannelEnum.ALIPAY_APP.getCode(), this); + payStrategyFactory.register(PayChannelEnum.ALIPAY_QR.getCode(), this); + } + + @Override + public PayOrderSubmitRespVO unifiedOrder(PayOrder order, PayChannel channel) { + AlipayClient client = getClient(channel.getConfig()); + String code = channel.getCode(); + PayOrderSubmitRespVO resp = new PayOrderSubmitRespVO(); + resp.setPayOrderNo(order.getNo()); + resp.setStatus(0); // WAITING + + try { + if (PayChannelEnum.ALIPAY_PC.getCode().equals(code)) { + AlipayTradePagePayRequest request = new AlipayTradePagePayRequest(); + request.setNotifyUrl(order.getNotifyUrl()); + request.setReturnUrl(order.getNotifyUrl()); // 暂时复用 + AlipayTradePagePayModel model = new AlipayTradePagePayModel(); + model.setOutTradeNo(order.getNo()); + model.setSubject(order.getSubject()); + model.setTotalAmount(String.valueOf(order.getPrice() / 100.0)); + model.setProductCode("FAST_INSTANT_TRADE_PAY"); + request.setBizModel(model); + AlipayTradePagePayResponse response = client.pageExecute(request); + resp.setPayData(response.getBody()); + } else if (PayChannelEnum.ALIPAY_WAP.getCode().equals(code)) { + AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest(); + request.setNotifyUrl(order.getNotifyUrl()); + AlipayTradeWapPayModel model = new AlipayTradeWapPayModel(); + model.setOutTradeNo(order.getNo()); + model.setSubject(order.getSubject()); + model.setTotalAmount(String.valueOf(order.getPrice() / 100.0)); + model.setProductCode("QUICK_WAP_WAY"); + request.setBizModel(model); + AlipayTradeWapPayResponse response = client.pageExecute(request); + resp.setPayData(response.getBody()); + } else if (PayChannelEnum.ALIPAY_APP.getCode().equals(code)) { + AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest(); + request.setNotifyUrl(order.getNotifyUrl()); + AlipayTradeAppPayModel model = new AlipayTradeAppPayModel(); + model.setOutTradeNo(order.getNo()); + model.setSubject(order.getSubject()); + model.setTotalAmount(String.valueOf(order.getPrice() / 100.0)); + model.setProductCode("QUICK_MSECURITY_PAY"); + request.setBizModel(model); + AlipayTradeAppPayResponse response = client.sdkExecute(request); + resp.setPayData(response.getBody()); + } else if (PayChannelEnum.ALIPAY_QR.getCode().equals(code)) { + AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest(); + request.setNotifyUrl(order.getNotifyUrl()); + AlipayTradePrecreateModel model = new AlipayTradePrecreateModel(); + model.setOutTradeNo(order.getNo()); + model.setSubject(order.getSubject()); + model.setTotalAmount(String.valueOf(order.getPrice() / 100.0)); + request.setBizModel(model); + AlipayTradePrecreateResponse response = client.execute(request); + resp.setPayData(response.getQrCode()); + } + } catch (AlipayApiException e) { + log.error("支付宝下单失败", e); + throw new ServiceException("支付宝下单失败: " + e.getMessage()); + } + return resp; + } + + @Override + public Boolean refund(PayRefund refund, PayChannel channel) { + AlipayClient client = getClient(channel.getConfig()); + AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); + AlipayTradeRefundModel model = new AlipayTradeRefundModel(); + model.setOutTradeNo(refund.getOrderNo()); + model.setRefundAmount(String.valueOf(refund.getRefundPrice() / 100.0)); + model.setRefundReason(refund.getReason()); + model.setOutRequestNo(refund.getNo()); + request.setBizModel(model); + try { + AlipayTradeRefundResponse response = client.execute(request); + if (response.isSuccess()) { + refund.setChannelRefundNo(response.getTradeNo()); // 支付宝可能不返回退款单号,用TradeNo暂代 + return true; + } else { + refund.setErrorMsg(response.getSubMsg()); + refund.setErrorCode(response.getSubCode()); + return false; + } + } catch (AlipayApiException e) { + log.error("支付宝退款失败", e); + throw new ServiceException("支付宝退款失败: " + e.getMessage()); + } + } + + @Override + public Integer queryOrder(PayOrder order, PayChannel channel) { + AlipayClient client = getClient(channel.getConfig()); + AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); + AlipayTradeQueryModel model = new AlipayTradeQueryModel(); + model.setOutTradeNo(order.getNo()); + request.setBizModel(model); + try { + AlipayTradeQueryResponse response = client.execute(request); + if (response.isSuccess()) { + if ("TRADE_SUCCESS".equals(response.getTradeStatus()) || "TRADE_FINISHED".equals(response.getTradeStatus())) { + return PayOrderStatusEnum.SUCCESS.getStatus(); + } else if ("TRADE_CLOSED".equals(response.getTradeStatus())) { + return PayOrderStatusEnum.CLOSED.getStatus(); + } + } + } catch (AlipayApiException e) { + log.error("支付宝查单失败", e); + } + return PayOrderStatusEnum.WAITING.getStatus(); + } + + @Override + public Boolean closeOrder(PayOrder order, PayChannel channel) { + AlipayClient client = getClient(channel.getConfig()); + AlipayTradeCloseRequest request = new AlipayTradeCloseRequest(); + AlipayTradeCloseModel model = new AlipayTradeCloseModel(); + model.setOutTradeNo(order.getNo()); + request.setBizModel(model); + try { + AlipayTradeCloseResponse response = client.execute(request); + if (response.isSuccess()) { + log.info("支付宝关单成功: {}", order.getNo()); + return true; + } else { + log.error("支付宝关单失败: {}", response.getSubMsg()); + return false; + } + } catch (AlipayApiException e) { + log.error("支付宝关单异常", e); + return false; + } + } + + @Override + public Integer queryRefund(PayRefund refund, PayChannel channel) { + AlipayClient client = getClient(channel.getConfig()); + AlipayTradeFastpayRefundQueryRequest request = new AlipayTradeFastpayRefundQueryRequest(); + AlipayTradeFastpayRefundQueryModel model = new AlipayTradeFastpayRefundQueryModel(); + model.setOutTradeNo(refund.getOrderNo()); + model.setOutRequestNo(refund.getNo()); + request.setBizModel(model); + try { + AlipayTradeFastpayRefundQueryResponse response = client.execute(request); + if (response.isSuccess()) { + // 支付宝查询退款成功,一般表示退款成功,因为如果退款不存在会报错或返回空 + if (StrUtil.isNotBlank(response.getRefundAmount())) { + return 10; // SUCCESS + } + } + } catch (AlipayApiException e) { + log.error("支付宝退款查询失败", e); + } + return 0; // WAITING or UNKNOWN + } + + @Override + public PayNotifyResult processNotify(HttpServletRequest request, PayChannel channel) { + PayNotifyResult result = new PayNotifyResult(); + Map params = new HashMap<>(); + Map requestParams = request.getParameterMap(); + for (String name : requestParams.keySet()) { + String[] values = requestParams.get(name); + String valueStr = ""; + for (int i = 0; i < values.length; i++) { + valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ","; + } + params.put(name, valueStr); + } + + Map config = JsonUtils.parseMap(channel.getConfig()); + String alipayPublicKey = (String) config.get("publicKey"); + String signType = (String) config.get("signType"); + String charset = "UTF-8"; // 默认UTF-8 + + try { + boolean verify = AlipaySignature.rsaCheckV1(params, alipayPublicKey, charset, signType); + result.setSuccess(verify); + if (verify) { + result.setOrderNo(params.get("out_trade_no")); + result.setTradeNo(params.get("trade_no")); + result.setResponseBody("success"); + } else { + result.setResponseBody("failure"); + } + } catch (AlipayApiException e) { + log.error("支付宝回调验签异常", e); + result.setSuccess(false); + result.setResponseBody("failure"); + } + return result; + } + + private AlipayClient getClient(String configJson) { + Map config = JsonUtils.parseMap(configJson); + return new DefaultAlipayClient( + (String) config.get("serverUrl"), + (String) config.get("appId"), + (String) config.get("privateKey"), + "json", + "UTF-8", + (String) config.get("publicKey"), + (String) config.get("signType") + ); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/WechatPayStrategy.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/WechatPayStrategy.java new file mode 100644 index 0000000..29d84f4 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/WechatPayStrategy.java @@ -0,0 +1,319 @@ +package org.dromara.pay.core.impl; + +import cn.hutool.core.util.StrUtil; +import com.wechat.pay.java.core.Config; +import com.wechat.pay.java.core.RSAAutoCertificateConfig; +import com.wechat.pay.java.core.notification.NotificationConfig; +import com.wechat.pay.java.core.notification.NotificationParser; +import com.wechat.pay.java.core.notification.RequestParam; +import com.wechat.pay.java.core.util.PemUtil; +import com.wechat.pay.java.service.payments.app.AppService; +import com.wechat.pay.java.service.payments.app.model.PrepayRequest; +import com.wechat.pay.java.service.payments.app.model.PrepayResponse; +import com.wechat.pay.java.service.payments.h5.H5Service; +import com.wechat.pay.java.service.payments.jsapi.JsapiService; +import com.wechat.pay.java.service.payments.jsapi.model.CloseOrderRequest; +import com.wechat.pay.java.service.payments.jsapi.model.Payer; +import com.wechat.pay.java.service.payments.nativepay.NativePayService; +import com.wechat.pay.java.service.payments.model.Transaction; +import com.wechat.pay.java.service.refund.RefundService; +import com.wechat.pay.java.service.refund.model.AmountReq; +import com.wechat.pay.java.service.refund.model.CreateRequest; +import com.wechat.pay.java.service.refund.model.QueryByOutRefundNoRequest; +import com.wechat.pay.java.service.refund.model.Refund; +import com.wechat.pay.java.service.refund.model.Status; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.pay.core.PayStrategy; +import org.dromara.pay.core.PayStrategyFactory; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.domain.PayRefund; +import org.dromara.pay.domain.vo.PayNotifyResult; +import org.dromara.pay.domain.vo.PayOrderSubmitRespVO; +import org.dromara.pay.enums.PayChannelEnum; +import org.dromara.pay.enums.PayOrderStatusEnum; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.security.PrivateKey; +import java.util.Map; + +import static com.wechat.pay.java.core.http.Constant.*; + +/** + * 微信支付策略 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class WechatPayStrategy implements PayStrategy { + + private final PayStrategyFactory payStrategyFactory; + + @PostConstruct + public void init() { + payStrategyFactory.register(PayChannelEnum.WX_PUB.getCode(), this); + payStrategyFactory.register(PayChannelEnum.WX_LITE.getCode(), this); + payStrategyFactory.register(PayChannelEnum.WX_APP.getCode(), this); + payStrategyFactory.register(PayChannelEnum.WX_NATIVE.getCode(), this); + payStrategyFactory.register(PayChannelEnum.WX_H5.getCode(), this); + } + + @Override + public PayOrderSubmitRespVO unifiedOrder(PayOrder order, PayChannel channel) { + Config config = getConfig(channel.getConfig()); + String code = channel.getCode(); + PayOrderSubmitRespVO resp = new PayOrderSubmitRespVO(); + resp.setPayOrderNo(order.getNo()); + resp.setStatus(0); // WAITING + + Map channelConfig = JsonUtils.parseMap(channel.getConfig()); + String appId = (String) channelConfig.get("appId"); + String mchId = (String) channelConfig.get("mchId"); + String privateKeyStr = (String) channelConfig.get("privateKey"); + String mchSerialNo = (String) channelConfig.get("mchSerialNo"); + + try { + if (PayChannelEnum.WX_NATIVE.getCode().equals(code)) { + NativePayService service = new NativePayService.Builder().config(config).build(); + com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest request = new com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest(); + request.setAppid(appId); + request.setMchid(mchId); + request.setDescription(order.getSubject()); + request.setOutTradeNo(order.getNo()); + request.setNotifyUrl(order.getNotifyUrl()); + com.wechat.pay.java.service.payments.nativepay.model.Amount amount = new com.wechat.pay.java.service.payments.nativepay.model.Amount(); + amount.setTotal(order.getPrice()); + request.setAmount(amount); + + com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse response = service.prepay(request); + resp.setPayData(response.getCodeUrl()); + + } else if (PayChannelEnum.WX_APP.getCode().equals(code)) { + AppService service = new AppService.Builder().config(config).build(); + PrepayRequest request = new PrepayRequest(); + request.setAppid(appId); + request.setMchid(mchId); + request.setDescription(order.getSubject()); + request.setOutTradeNo(order.getNo()); + request.setNotifyUrl(order.getNotifyUrl()); + com.wechat.pay.java.service.payments.app.model.Amount amount = new com.wechat.pay.java.service.payments.app.model.Amount(); + amount.setTotal(order.getPrice()); + request.setAmount(amount); + + PrepayResponse response = service.prepay(request); + + // 二次签名 + PrivateKey privateKey = PemUtil.loadPrivateKeyFromString(privateKeyStr); + Map signMap = WechatPayUtil.signApp(appId, mchId, response.getPrepayId(), privateKey, mchSerialNo); + resp.setPayData(JsonUtils.toJsonString(signMap)); + + } else if (PayChannelEnum.WX_PUB.getCode().equals(code) || PayChannelEnum.WX_LITE.getCode().equals(code)) { + JsapiService service = new JsapiService.Builder().config(config).build(); + com.wechat.pay.java.service.payments.jsapi.model.PrepayRequest request = new com.wechat.pay.java.service.payments.jsapi.model.PrepayRequest(); + request.setAppid(appId); + request.setMchid(mchId); + request.setDescription(order.getSubject()); + request.setOutTradeNo(order.getNo()); + request.setNotifyUrl(order.getNotifyUrl()); + com.wechat.pay.java.service.payments.jsapi.model.Amount amount = new com.wechat.pay.java.service.payments.jsapi.model.Amount(); + amount.setTotal(order.getPrice()); + request.setAmount(amount); + + Payer payer = new Payer(); + payer.setOpenid(order.getChannelUserId()); // 必须传入 openid + request.setPayer(payer); + + com.wechat.pay.java.service.payments.jsapi.model.PrepayResponse response = service.prepay(request); + + // 二次签名 + PrivateKey privateKey = PemUtil.loadPrivateKeyFromString(privateKeyStr); + Map signMap = WechatPayUtil.signJsapi(appId, response.getPrepayId(), privateKey, mchSerialNo); + resp.setPayData(JsonUtils.toJsonString(signMap)); + + } else if (PayChannelEnum.WX_H5.getCode().equals(code)) { + H5Service service = new H5Service.Builder().config(config).build(); + com.wechat.pay.java.service.payments.h5.model.PrepayRequest request = new com.wechat.pay.java.service.payments.h5.model.PrepayRequest(); + request.setAppid(appId); + request.setMchid(mchId); + request.setDescription(order.getSubject()); + request.setOutTradeNo(order.getNo()); + request.setNotifyUrl(order.getNotifyUrl()); + com.wechat.pay.java.service.payments.h5.model.Amount amount = new com.wechat.pay.java.service.payments.h5.model.Amount(); + amount.setTotal(order.getPrice()); + request.setAmount(amount); + + com.wechat.pay.java.service.payments.h5.model.PrepayResponse response = service.prepay(request); + resp.setPayData(response.getH5Url()); + } + } catch (Exception e) { + log.error("微信下单失败", e); + throw new ServiceException("微信下单失败: " + e.getMessage()); + } + return resp; + } + + @Override + public Boolean refund(PayRefund refund, PayChannel channel) { + Config config = getConfig(channel.getConfig()); + RefundService service = new RefundService.Builder().config(config).build(); + + CreateRequest request = new CreateRequest(); + request.setOutTradeNo(refund.getOrderNo()); + request.setOutRefundNo(refund.getNo()); + request.setReason(refund.getReason()); + request.setNotifyUrl(refund.getNotifyUrl()); + + AmountReq amount = new AmountReq(); + amount.setRefund(Long.valueOf(refund.getRefundPrice())); + amount.setTotal(Long.valueOf(refund.getPayPrice())); + amount.setCurrency("CNY"); + request.setAmount(amount); + + try { + Refund response = service.create(request); + if (response.getStatus() == Status.SUCCESS || response.getStatus() == Status.PROCESSING) { + refund.setChannelRefundNo(response.getRefundId()); + return true; + } else { + refund.setErrorMsg(response.getStatus().name()); + return false; + } + } catch (Exception e) { + log.error("微信退款失败", e); + throw new ServiceException("微信退款失败: " + e.getMessage()); + } + } + + @Override + public Integer queryOrder(PayOrder order, PayChannel channel) { + Config config = getConfig(channel.getConfig()); + JsapiService service = new JsapiService.Builder().config(config).build(); // Query is generic across services usually, using JsapiService for access + + com.wechat.pay.java.service.payments.jsapi.model.QueryOrderByOutTradeNoRequest request = new com.wechat.pay.java.service.payments.jsapi.model.QueryOrderByOutTradeNoRequest(); + request.setMchid(getConfigMap(channel.getConfig()).get("mchId").toString()); + request.setOutTradeNo(order.getNo()); + + try { + Transaction transaction = service.queryOrderByOutTradeNo(request); + if (transaction.getTradeState() == Transaction.TradeStateEnum.SUCCESS) { + return PayOrderStatusEnum.SUCCESS.getStatus(); + } else if (transaction.getTradeState() == Transaction.TradeStateEnum.CLOSED || transaction.getTradeState() == Transaction.TradeStateEnum.REVOKED) { + return PayOrderStatusEnum.CLOSED.getStatus(); + } else if (transaction.getTradeState() == Transaction.TradeStateEnum.REFUND) { + return PayOrderStatusEnum.REFUND.getStatus(); + } + } catch (Exception e) { + log.error("微信查单失败", e); + } + return PayOrderStatusEnum.WAITING.getStatus(); + } + + @Override + public Boolean closeOrder(PayOrder order, PayChannel channel) { + Config config = getConfig(channel.getConfig()); + JsapiService service = new JsapiService.Builder().config(config).build(); + CloseOrderRequest request = new CloseOrderRequest(); + request.setMchid(getConfigMap(channel.getConfig()).get("mchId").toString()); + request.setOutTradeNo(order.getNo()); + try { + service.closeOrder(request); + return true; + } catch (Exception e) { + log.error("微信关单失败", e); + return false; + } + } + + @Override + public Integer queryRefund(PayRefund refund, PayChannel channel) { + Config config = getConfig(channel.getConfig()); + RefundService service = new RefundService.Builder().config(config).build(); + QueryByOutRefundNoRequest request = new QueryByOutRefundNoRequest(); + request.setOutRefundNo(refund.getNo()); + try { + Refund response = service.queryByOutRefundNo(request); + if (response.getStatus() == Status.SUCCESS) { + return 10; // SUCCESS + } + } catch (Exception e) { + log.error("微信退款查询失败", e); + } + return 0; + } + + @Override + public PayNotifyResult processNotify(HttpServletRequest request, PayChannel channel) { + PayNotifyResult result = new PayNotifyResult(); + try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(request.getInputStream()))) { + StringBuilder stringBuffer = new StringBuilder(); + String line; + while ((line = bufferedReader.readLine()) != null) { + stringBuffer.append(line); + } + String requestBody = stringBuffer.toString(); + + String timestamp = request.getHeader(WECHAT_PAY_TIMESTAMP); + String nonce = request.getHeader(WECHAT_PAY_NONCE); + String serialNo = request.getHeader(WECHAT_PAY_SERIAL); + String signature = request.getHeader(WECHAT_PAY_SIGNATURE); + + RequestParam requestParam = new RequestParam.Builder() + .serialNumber(serialNo) + .nonce(nonce) + .signature(signature) + .timestamp(timestamp) + .body(requestBody) + .build(); + + // Config config = getConfig(channel.getConfig()); + // NotificationParser parser = new NotificationParser((NotificationConfig) config); // RSAAutoCertificateConfig implements NotificationConfig + + // Re-create config here or cast + NotificationConfig config = (NotificationConfig) getConfig(channel.getConfig()); + NotificationParser parser = new NotificationParser(config); + + Transaction transaction = parser.parse(requestParam, Transaction.class); + if (transaction != null && transaction.getTradeState() == Transaction.TradeStateEnum.SUCCESS) { + result.setSuccess(true); + result.setOrderNo(transaction.getOutTradeNo()); + result.setTradeNo(transaction.getTransactionId()); + // 微信支付V3回调成功需要返回 HTTP 200 或 204,无特定body内容,但建议返回空JSON或success + // Spring MVC controller usually returns body. + // We should return a structure that controller can map to ResponseEntity + result.setResponseBody("{\"code\": \"SUCCESS\", \"message\": \"SUCCESS\"}"); + } else { + result.setSuccess(false); + result.setResponseBody("{\"code\": \"FAIL\", \"message\": \"FAIL\"}"); + } + } catch (Exception e) { + log.error("微信回调处理异常", e); + result.setSuccess(false); + result.setResponseBody("{\"code\": \"FAIL\", \"message\": \"EXCEPTION\"}"); + } + return result; + } + + private Config getConfig(String configJson) { + Map configMap = JsonUtils.parseMap(configJson); + return new RSAAutoCertificateConfig.Builder() + .merchantId((String) configMap.get("mchId")) + .privateKey((String) configMap.get("privateKey")) + .merchantSerialNumber((String) configMap.get("mchSerialNo")) + .apiV3Key((String) configMap.get("apiV3Key")) + .build(); + } + + private Map getConfigMap(String configJson) { + return JsonUtils.parseMap(configJson); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/WechatPayUtil.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/WechatPayUtil.java new file mode 100644 index 0000000..90acdbd --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/core/impl/WechatPayUtil.java @@ -0,0 +1,53 @@ +package org.dromara.pay.core.impl; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import com.wechat.pay.java.core.cipher.RSASigner; +import java.security.PrivateKey; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * 微信支付工具类 + */ +public class WechatPayUtil { + + public static Map signApp(String appId, String mchId, String prepayId, PrivateKey privateKey, String certificateSerialNo) { + long timestamp = Instant.now().getEpochSecond(); + String nonceStr = RandomUtil.randomString(32); + String message = appId + "\n" + timestamp + "\n" + nonceStr + "\n" + prepayId + "\n"; + + RSASigner signer = new RSASigner(certificateSerialNo, privateKey); + String sign = signer.sign(message).getSign(); + + Map map = new HashMap<>(); + map.put("appid", appId); + map.put("partnerid", mchId); + map.put("prepayid", prepayId); + map.put("package", "Sign=WXPay"); + map.put("noncestr", nonceStr); + map.put("timestamp", String.valueOf(timestamp)); + map.put("sign", sign); + return map; + } + + public static Map signJsapi(String appId, String prepayId, PrivateKey privateKey, String certificateSerialNo) { + long timestamp = Instant.now().getEpochSecond(); + String nonceStr = RandomUtil.randomString(32); + String packageStr = "prepay_id=" + prepayId; + String message = appId + "\n" + timestamp + "\n" + nonceStr + "\n" + packageStr + "\n"; + + RSASigner signer = new RSASigner(certificateSerialNo, privateKey); + String sign = signer.sign(message).getSign(); + + Map map = new HashMap<>(); + map.put("appId", appId); + map.put("timeStamp", String.valueOf(timestamp)); + map.put("nonceStr", nonceStr); + map.put("package", packageStr); + map.put("signType", "RSA"); + map.put("paySign", sign); + return map; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayApp.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayApp.java new file mode 100644 index 0000000..15fe393 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayApp.java @@ -0,0 +1,77 @@ +package org.dromara.pay.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 支付应用对象 pay_app + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("pay_app") +public class PayApp { + + /** + * 应用ID + */ + @TableId + private Long id; + + /** + * 应用名 + */ + private String name; + + /** + * 状态(0开启 1关闭) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 支付结果回调地址 + */ + private String payNotifyUrl; + + /** + * 退款结果回调地址 + */ + private String refundNotifyUrl; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayChannel.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayChannel.java new file mode 100644 index 0000000..11ab3c7 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayChannel.java @@ -0,0 +1,82 @@ +package org.dromara.pay.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 支付渠道对象 pay_channel + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("pay_channel") +public class PayChannel { + + /** + * 渠道ID + */ + @TableId + private Long id; + + /** + * 渠道编码(alipay_pc, wx_pub) + */ + private String code; + + /** + * 状态(0开启 1关闭) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 渠道费率(%) + */ + private Double feeRate; + + /** + * 应用ID + */ + private Long appId; + + /** + * 支付渠道配置(JSON) + */ + private String config; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayNotifyTask.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayNotifyTask.java new file mode 100644 index 0000000..e74ed35 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayNotifyTask.java @@ -0,0 +1,92 @@ +package org.dromara.pay.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 支付通知任务对象 pay_notify_task + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("pay_notify_task") +public class PayNotifyTask { + + /** + * 任务ID + */ + @TableId + private Long id; + + /** + * 订单ID + */ + private Long orderId; + + /** + * 订单号 + */ + private String orderNo; + + /** + * 通知地址 + */ + private String notifyUrl; + + /** + * 通知内容 + */ + private String notifyBody; + + /** + * 重试次数 + */ + private Integer retryCount; + + /** + * 最大重试次数 + */ + private Integer maxRetry; + + /** + * 状态(0进行中 1成功 2失败) + */ + private Integer status; + + /** + * 下次通知时间 + */ + private Date nextNotifyTime; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayOrder.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayOrder.java new file mode 100644 index 0000000..3b0dc02 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayOrder.java @@ -0,0 +1,149 @@ +package org.dromara.pay.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.util.Date; +import java.util.Map; + +/** + * 支付订单对象 pay_order + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName(value = "pay_order", autoResultMap = true) +public class PayOrder { + + /** + * 订单ID + */ + @TableId + private Long id; + + /** + * 应用ID + */ + private Long appId; + + /** + * 渠道ID + */ + private Long channelId; + + /** + * 渠道编码 + */ + private String channelCode; + + /** + * 商户订单编号 + */ + private String merchantOrderId; + + /** + * 商品标题 + */ + private String subject; + + /** + * 商品描述 + */ + private String body; + + /** + * 异步通知地址 + */ + private String notifyUrl; + + /** + * 支付金额(分) + */ + private Integer price; + + /** + * 渠道费率 + */ + private Double channelFeeRate; + + /** + * 渠道手续费(分) + */ + private Integer channelFeePrice; + + /** + * 状态(0未支付 10已支付 20已退款 30已关闭) + */ + private Integer status; + + /** + * 用户IP + */ + private String userIp; + + /** + * 订单失效时间 + */ + private Date expireTime; + + /** + * 支付成功时间 + */ + private Date successTime; + + /** + * 拓展业务ID (如扫码支付的记录ID) + */ + private Long extensionId; + + /** + * 支付单号(内部生成) + */ + private String no; + + /** + * 渠道订单号(微信/支付宝) + */ + private String channelOrderNo; + + /** + * 渠道用户OpenId + */ + private String channelUserId; + + /** + * 渠道额外参数 (存储微信openid, 支付宝buyer_id等) + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private Map channelExtras; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayRefund.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayRefund.java new file mode 100644 index 0000000..189d2ff --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/PayRefund.java @@ -0,0 +1,146 @@ +package org.dromara.pay.domain; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.util.Date; + +/** + * 退款订单对象 pay_refund + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@TableName("pay_refund") +public class PayRefund { + + /** + * 退款ID + */ + @TableId + private Long id; + + /** + * 退款单号 + */ + private String no; + + /** + * 应用ID + */ + private Long appId; + + /** + * 渠道ID + */ + private Long channelId; + + /** + * 渠道编码 + */ + private String channelCode; + + /** + * 支付订单ID + */ + private Long orderId; + + /** + * 支付订单no + */ + private String orderNo; + + /** + * 商户订单编号 + */ + private String merchantOrderId; + + /** + * 商户退款编号 + */ + private String merchantRefundId; + + /** + * 回调地址 + */ + private String notifyUrl; + + /** + * 状态(0等待 10成功 20失败) + */ + private Integer status; + + /** + * 支付金额(分) + */ + private Integer payPrice; + + /** + * 退款金额(分) + */ + private Integer refundPrice; + + /** + * 退款原因 + */ + private String reason; + + /** + * 用户IP + */ + private String userIp; + + /** + * 渠道订单号 + */ + private String channelOrderNo; + + /** + * 渠道退款单号 + */ + private String channelRefundNo; + + /** + * 退款成功时间 + */ + private Date successTime; + + /** + * 渠道错误码 + */ + private String errorCode; + + /** + * 渠道错误描述 + */ + private String errorMsg; + + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayAppBo.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayAppBo.java new file mode 100644 index 0000000..91b880b --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayAppBo.java @@ -0,0 +1,54 @@ +package org.dromara.pay.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.pay.domain.PayApp; + +/** + * 支付应用业务对象 pay_app + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@AutoMapper(target = PayApp.class, reverseConvertGenerate = false) +public class PayAppBo { + + /** + * 应用ID + */ + private Long id; + + /** + * 应用名 + */ + @NotBlank(message = "应用名不能为空") + private String name; + + /** + * 状态(0开启 1关闭) + */ + @NotNull(message = "状态不能为空") + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 支付结果回调地址 + */ + @NotBlank(message = "支付结果回调地址不能为空") + private String payNotifyUrl; + + /** + * 退款结果回调地址 + */ + @NotBlank(message = "退款结果回调地址不能为空") + private String refundNotifyUrl; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayChannelBo.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayChannelBo.java new file mode 100644 index 0000000..9939f5d --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayChannelBo.java @@ -0,0 +1,60 @@ +package org.dromara.pay.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.pay.domain.PayChannel; + +/** + * 支付渠道业务对象 pay_channel + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@AutoMapper(target = PayChannel.class, reverseConvertGenerate = false) +public class PayChannelBo { + + /** + * 渠道ID + */ + private Long id; + + /** + * 渠道编码(alipay_pc, wx_pub) + */ + @NotBlank(message = "渠道编码不能为空") + private String code; + + /** + * 状态(0开启 1关闭) + */ + @NotNull(message = "状态不能为空") + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 渠道费率(%) + */ + @NotNull(message = "渠道费率不能为空") + private Double feeRate; + + /** + * 应用ID + */ + @NotNull(message = "应用ID不能为空") + private Long appId; + + /** + * 支付渠道配置(JSON) + */ + @NotBlank(message = "支付渠道配置不能为空") + private String config; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayOrderBo.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayOrderBo.java new file mode 100644 index 0000000..9e08a07 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayOrderBo.java @@ -0,0 +1,117 @@ +package org.dromara.pay.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.pay.domain.PayOrder; +import java.util.Date; + +/** + * 支付订单业务对象 pay_order + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@AutoMapper(target = PayOrder.class, reverseConvertGenerate = false) +public class PayOrderBo { + + /** + * 订单ID + */ + private Long id; + + /** + * 应用ID + */ + @NotNull(message = "应用ID不能为空") + private Long appId; + + /** + * 渠道ID + */ + private Long channelId; + + /** + * 渠道编码 + */ + private String channelCode; + + /** + * 商户订单编号 + */ + private String merchantOrderId; + + /** + * 商品标题 + */ + private String subject; + + /** + * 商品描述 + */ + private String body; + + /** + * 异步通知地址 + */ + private String notifyUrl; + + /** + * 支付金额(分) + */ + @NotNull(message = "支付金额不能为空") + private Integer price; + + /** + * 渠道费率 + */ + private Double channelFeeRate; + + /** + * 渠道手续费(分) + */ + private Integer channelFeePrice; + + /** + * 状态(0未支付 10已支付 20已退款 30已关闭) + */ + private Integer status; + + /** + * 用户IP + */ + private String userIp; + + /** + * 订单失效时间 + */ + private Date expireTime; + + /** + * 支付成功时间 + */ + private Date successTime; + + /** + * 拓展业务ID (如扫码支付的记录ID) + */ + private Long extensionId; + + /** + * 支付单号(内部生成) + */ + private String no; + + /** + * 渠道订单号(微信/支付宝) + */ + private String channelOrderNo; + + /** + * 渠道用户OpenId + */ + private String channelUserId; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayRefundBo.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayRefundBo.java new file mode 100644 index 0000000..52641e5 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/bo/PayRefundBo.java @@ -0,0 +1,123 @@ +package org.dromara.pay.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.pay.domain.PayRefund; +import java.util.Date; + +/** + * 退款订单业务对象 pay_refund + * + * @author pengles + */ +@Data +@EqualsAndHashCode +@AutoMapper(target = PayRefund.class, reverseConvertGenerate = false) +public class PayRefundBo { + + /** + * 退款ID + */ + private Long id; + + /** + * 退款单号 + */ + private String no; + + /** + * 应用ID + */ + @NotNull(message = "应用ID不能为空") + private Long appId; + + /** + * 渠道ID + */ + private Long channelId; + + /** + * 渠道编码 + */ + private String channelCode; + + /** + * 支付订单ID + */ + @NotNull(message = "支付订单ID不能为空") + private Long orderId; + + /** + * 支付订单no + */ + private String orderNo; + + /** + * 商户订单编号 + */ + private String merchantOrderId; + + /** + * 商户退款编号 + */ + private String merchantRefundId; + + /** + * 回调地址 + */ + private String notifyUrl; + + /** + * 状态(0等待 10成功 20失败) + */ + private Integer status; + + /** + * 支付金额(分) + */ + private Integer payPrice; + + /** + * 退款金额(分) + */ + @NotNull(message = "退款金额不能为空") + private Integer refundPrice; + + /** + * 退款原因 + */ + private String reason; + + /** + * 用户IP + */ + private String userIp; + + /** + * 渠道订单号 + */ + private String channelOrderNo; + + /** + * 渠道退款单号 + */ + private String channelRefundNo; + + /** + * 退款成功时间 + */ + private Date successTime; + + /** + * 渠道错误码 + */ + private String errorCode; + + /** + * 渠道错误描述 + */ + private String errorMsg; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayAppVo.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayAppVo.java new file mode 100644 index 0000000..e09046e --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayAppVo.java @@ -0,0 +1,61 @@ +package org.dromara.pay.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.pay.domain.PayApp; +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 支付应用视图对象 pay_app + * + * @author pengles + */ +@Data +@AutoMapper(target = PayApp.class) +public class PayAppVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 应用ID + */ + private Long id; + + /** + * 应用名 + */ + private String name; + + /** + * 状态(0开启 1关闭) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 支付结果回调地址 + */ + private String payNotifyUrl; + + /** + * 退款结果回调地址 + */ + private String refundNotifyUrl; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayChannelVo.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayChannelVo.java new file mode 100644 index 0000000..edabed2 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayChannelVo.java @@ -0,0 +1,66 @@ +package org.dromara.pay.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.pay.domain.PayChannel; +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 支付渠道视图对象 pay_channel + * + * @author pengles + */ +@Data +@AutoMapper(target = PayChannel.class) +public class PayChannelVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 渠道ID + */ + private Long id; + + /** + * 渠道编码(alipay_pc, wx_pub) + */ + private String code; + + /** + * 状态(0开启 1关闭) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 渠道费率(%) + */ + private Double feeRate; + + /** + * 应用ID + */ + private Long appId; + + /** + * 支付渠道配置(JSON) + */ + private String config; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayNotifyResult.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayNotifyResult.java new file mode 100644 index 0000000..a8d2816 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayNotifyResult.java @@ -0,0 +1,31 @@ +package org.dromara.pay.domain.vo; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 支付回调处理结果 + */ +@Data +@Accessors(chain = true) +public class PayNotifyResult { + /** + * 验签是否通过 + */ + private boolean success; + + /** + * 系统内部订单号 + */ + private String orderNo; + + /** + * 渠道交易单号 + */ + private String tradeNo; + + /** + * 返回给渠道的响应内容 (例如 "success" 或 XML) + */ + private String responseBody; +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayOrderSubmitRespVO.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayOrderSubmitRespVO.java new file mode 100644 index 0000000..5d65828 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayOrderSubmitRespVO.java @@ -0,0 +1,33 @@ +package org.dromara.pay.domain.vo; + +import lombok.Data; +import java.io.Serializable; + +/** + * 支付订单提交响应 VO + * + * @author pengles + */ +@Data +public class PayOrderSubmitRespVO implements Serializable { + + /** + * 支付单号 + */ + private String payOrderNo; + + /** + * 调起支付的参数 (JSON格式,或URL) + */ + private String payData; + + /** + * 支付状态 (10: 成功, 0: 等待) + */ + private Integer status; + + /** + * 渠道返回的原始数据 + */ + private Object rawData; +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayOrderVo.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayOrderVo.java new file mode 100644 index 0000000..b8e3818 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayOrderVo.java @@ -0,0 +1,126 @@ +package org.dromara.pay.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.pay.domain.PayOrder; +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 支付订单视图对象 pay_order + * + * @author pengles + */ +@Data +@AutoMapper(target = PayOrder.class) +public class PayOrderVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 订单ID + */ + private Long id; + + /** + * 应用ID + */ + private Long appId; + + /** + * 渠道ID + */ + private Long channelId; + + /** + * 渠道编码 + */ + private String channelCode; + + /** + * 商户订单编号 + */ + private String merchantOrderId; + + /** + * 商品标题 + */ + private String subject; + + /** + * 商品描述 + */ + private String body; + + /** + * 异步通知地址 + */ + private String notifyUrl; + + /** + * 支付金额(分) + */ + private Integer price; + + /** + * 渠道费率 + */ + private Double channelFeeRate; + + /** + * 渠道手续费(分) + */ + private Integer channelFeePrice; + + /** + * 状态(0未支付 10已支付 20已退款 30已关闭) + */ + private Integer status; + + /** + * 用户IP + */ + private String userIp; + + /** + * 订单失效时间 + */ + private Date expireTime; + + /** + * 支付成功时间 + */ + private Date successTime; + + /** + * 拓展业务ID (如扫码支付的记录ID) + */ + private Long extensionId; + + /** + * 支付单号(内部生成) + */ + private String no; + + /** + * 渠道订单号(微信/支付宝) + */ + private String channelOrderNo; + + /** + * 渠道用户OpenId + */ + private String channelUserId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayRefundVo.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayRefundVo.java new file mode 100644 index 0000000..e79aa21 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/domain/vo/PayRefundVo.java @@ -0,0 +1,131 @@ +package org.dromara.pay.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.pay.domain.PayRefund; +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 退款订单视图对象 pay_refund + * + * @author pengles + */ +@Data +@AutoMapper(target = PayRefund.class) +public class PayRefundVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 退款ID + */ + private Long id; + + /** + * 退款单号 + */ + private String no; + + /** + * 应用ID + */ + private Long appId; + + /** + * 渠道ID + */ + private Long channelId; + + /** + * 渠道编码 + */ + private String channelCode; + + /** + * 支付订单ID + */ + private Long orderId; + + /** + * 支付订单no + */ + private String orderNo; + + /** + * 商户订单编号 + */ + private String merchantOrderId; + + /** + * 商户退款编号 + */ + private String merchantRefundId; + + /** + * 回调地址 + */ + private String notifyUrl; + + /** + * 状态(0等待 10成功 20失败) + */ + private Integer status; + + /** + * 支付金额(分) + */ + private Integer payPrice; + + /** + * 退款金额(分) + */ + private Integer refundPrice; + + /** + * 退款原因 + */ + private String reason; + + /** + * 用户IP + */ + private String userIp; + + /** + * 渠道订单号 + */ + private String channelOrderNo; + + /** + * 渠道退款单号 + */ + private String channelRefundNo; + + /** + * 退款成功时间 + */ + private Date successTime; + + /** + * 渠道错误码 + */ + private String errorCode; + + /** + * 渠道错误描述 + */ + private String errorMsg; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/PayChannelEnum.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/PayChannelEnum.java new file mode 100644 index 0000000..93c5d1c --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/PayChannelEnum.java @@ -0,0 +1,38 @@ +package org.dromara.pay.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 支付渠道枚举 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum PayChannelEnum { + + WX_PUB("wx_pub", "微信 JSAPI 支付"), + WX_LITE("wx_lite", "微信小程序支付"), + WX_APP("wx_app", "微信 App 支付"), + WX_NATIVE("wx_native", "微信 Native 支付"), + WX_H5("wx_h5", "微信 H5 支付"), + ALIPAY_PC("alipay_pc", "支付宝 PC 支付"), + ALIPAY_WAP("alipay_wap", "支付宝 Wap 支付"), + ALIPAY_APP("alipay_app", "支付宝 App 支付"), + ALIPAY_QR("alipay_qr", "支付宝扫码支付"), + MOCK("mock", "模拟支付"), + WALLET("wallet", "钱包支付"); + + private final String code; + private final String name; + + public static PayChannelEnum getByCode(String code) { + for (PayChannelEnum value : values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return null; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/PayOrderStatusEnum.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/PayOrderStatusEnum.java new file mode 100644 index 0000000..ca21960 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/PayOrderStatusEnum.java @@ -0,0 +1,23 @@ +package org.dromara.pay.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 支付订单状态枚举 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum PayOrderStatusEnum { + + WAITING(0, "未支付"), + SUCCESS(10, "支付成功"), + REFUND(20, "已退款"), + CLOSED(30, "已关闭"); + + private final Integer status; + private final String name; + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/RefundStatusEnum.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/RefundStatusEnum.java new file mode 100644 index 0000000..c64475a --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/enums/RefundStatusEnum.java @@ -0,0 +1,30 @@ +package org.dromara.pay.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 退款状态枚举 + * + * @author pengles + */ +@Getter +@AllArgsConstructor +public enum RefundStatusEnum { + + WAITING(0, "等待"), + SUCCESS(10, "成功"), + FAIL(20, "失败"); + + private final Integer code; + private final String name; + + public static RefundStatusEnum getByCode(Integer code) { + for (RefundStatusEnum value : values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return null; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/event/PaySuccessEvent.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/event/PaySuccessEvent.java new file mode 100644 index 0000000..b1caf37 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/event/PaySuccessEvent.java @@ -0,0 +1,21 @@ +package org.dromara.pay.event; + +import lombok.Getter; +import org.dromara.pay.domain.PayOrder; +import org.springframework.context.ApplicationEvent; + +/** + * 支付成功事件 + * + * @author pengles + */ +@Getter +public class PaySuccessEvent extends ApplicationEvent { + + private final PayOrder order; + + public PaySuccessEvent(Object source, PayOrder order) { + super(source); + this.order = order; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayNotifyJob.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayNotifyJob.java new file mode 100644 index 0000000..80a36d5 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayNotifyJob.java @@ -0,0 +1,38 @@ +package org.dromara.pay.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.pay.domain.PayNotifyTask; +import org.dromara.pay.service.PayNotifyService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 支付通知重试任务 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PayNotifyJob { + + private final PayNotifyService payNotifyService; + + /** + * 每30秒检查一次待通知任务 + */ + @Scheduled(cron = "0/30 * * * * ?") + public void retryNotify() { + List tasks = payNotifyService.getPendingTasks(); + if (tasks.isEmpty()) { + return; + } + log.info("扫描到待执行回调任务: {}个", tasks.size()); + for (PayNotifyTask task : tasks) { + payNotifyService.executeTask(task); + } + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayOrderExpireJob.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayOrderExpireJob.java new file mode 100644 index 0000000..673767e --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayOrderExpireJob.java @@ -0,0 +1,63 @@ +package org.dromara.pay.job; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.pay.core.PayStrategy; +import org.dromara.pay.core.PayStrategyFactory; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.enums.PayOrderStatusEnum; +import org.dromara.pay.mapper.PayChannelMapper; +import org.dromara.pay.mapper.PayOrderMapper; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.List; + +/** + * 支付订单过期处理任务 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PayOrderExpireJob { + + private final PayOrderMapper payOrderMapper; + private final PayChannelMapper payChannelMapper; + private final PayStrategyFactory payStrategyFactory; + + /** + * 每分钟检查一次过期订单 + */ + @Scheduled(cron = "0 0/1 * * * ?") + public void expireOrder() { + // 查询未支付且已过期的订单 + Date now = new Date(); + List expiredOrders = payOrderMapper.selectList(new LambdaQueryWrapper() + .eq(PayOrder::getStatus, PayOrderStatusEnum.WAITING.getStatus()) + .lt(PayOrder::getExpireTime, now) + .last("LIMIT 100")); // 每次处理100条,防止积压 + + for (PayOrder order : expiredOrders) { + try { + log.info("处理过期订单: {}", order.getNo()); + PayChannel channel = payChannelMapper.selectById(order.getChannelId()); + if (channel != null) { + PayStrategy strategy = payStrategyFactory.getStrategy(channel.getCode()); + // 调用三方关单 + strategy.closeOrder(order, channel); + } + + // 更新本地状态为已关闭 + order.setStatus(PayOrderStatusEnum.CLOSED.getStatus()); + payOrderMapper.updateById(order); + } catch (Exception e) { + log.error("处理过期订单异常: {}", order.getNo(), e); + } + } + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayOrderSyncJob.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayOrderSyncJob.java new file mode 100644 index 0000000..3bd3fd8 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/job/PayOrderSyncJob.java @@ -0,0 +1,77 @@ +package org.dromara.pay.job; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.pay.core.PayStrategy; +import org.dromara.pay.core.PayStrategyFactory; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.enums.PayOrderStatusEnum; +import org.dromara.pay.mapper.PayChannelMapper; +import org.dromara.pay.mapper.PayOrderMapper; +import org.dromara.pay.service.PayOrderService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.List; + +/** + * 支付订单同步任务 (防掉单) + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PayOrderSyncJob { + + private final PayOrderMapper payOrderMapper; + private final PayChannelMapper payChannelMapper; + private final PayStrategyFactory payStrategyFactory; + private final PayOrderService payOrderService; + + /** + * 每2分钟检查一次"支付中"的订单 (防掉单) + * 针对创建时间超过2分钟,且未过期的订单 + */ + @Scheduled(cron = "0 0/2 * * * ?") + public void syncOrder() { + Date now = new Date(); + Date twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000); + + // 查询 WAITING 状态,创建时间 < 2分钟前,过期时间 > 现在的订单 + List waitingOrders = payOrderMapper.selectList(new LambdaQueryWrapper() + .eq(PayOrder::getStatus, PayOrderStatusEnum.WAITING.getStatus()) + .lt(PayOrder::getCreateTime, twoMinutesAgo) + .gt(PayOrder::getExpireTime, now) + .last("LIMIT 100")); + + for (PayOrder order : waitingOrders) { + try { + log.info("同步支付订单状态: {}", order.getNo()); + PayChannel channel = payChannelMapper.selectById(order.getChannelId()); + if (channel == null) { + continue; + } + + PayStrategy strategy = payStrategyFactory.getStrategy(channel.getCode()); + Integer status = strategy.queryOrder(order, channel); + + if (PayOrderStatusEnum.SUCCESS.getStatus().equals(status)) { + log.info("同步发现订单已支付: {}", order.getNo()); + // 触发支付成功逻辑 + payOrderService.notifyOrderSuccess(order.getNo(), null); + } else if (PayOrderStatusEnum.CLOSED.getStatus().equals(status)) { + log.info("同步发现订单已关闭: {}", order.getNo()); + order.setStatus(PayOrderStatusEnum.CLOSED.getStatus()); + payOrderMapper.updateById(order); + } + + } catch (Exception e) { + log.error("同步订单异常: {}", order.getNo(), e); + } + } + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/listener/PayOrderListener.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/listener/PayOrderListener.java new file mode 100644 index 0000000..1a88eb9 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/listener/PayOrderListener.java @@ -0,0 +1,36 @@ +package org.dromara.pay.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.pay.event.PaySuccessEvent; +import org.dromara.pay.service.PayNotifyService; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** + * 支付订单事件监听 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PayOrderListener { + + private final PayNotifyService payNotifyService; + + /** + * 监听支付成功事件,创建回调任务 + */ + @Async + @EventListener + public void onPaySuccess(PaySuccessEvent event) { + log.info("监听到支付成功事件: orderNo={}", event.getOrder().getNo()); + try { + payNotifyService.createTask(event.getOrder()); + } catch (Exception e) { + log.error("创建支付回调任务失败", e); + } + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayAppMapper.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayAppMapper.java new file mode 100644 index 0000000..2109853 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayAppMapper.java @@ -0,0 +1,16 @@ +package org.dromara.pay.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.pay.domain.PayApp; +import org.dromara.pay.domain.vo.PayAppVo; + +/** + * 支付应用Mapper接口 + * + * @author pengles + */ +@Mapper +public interface PayAppMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayChannelMapper.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayChannelMapper.java new file mode 100644 index 0000000..606338c --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayChannelMapper.java @@ -0,0 +1,16 @@ +package org.dromara.pay.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.vo.PayChannelVo; + +/** + * 支付渠道Mapper接口 + * + * @author pengles + */ +@Mapper +public interface PayChannelMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayNotifyTaskMapper.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayNotifyTaskMapper.java new file mode 100644 index 0000000..31c8a58 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayNotifyTaskMapper.java @@ -0,0 +1,13 @@ +package org.dromara.pay.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.pay.domain.PayNotifyTask; + +/** + * 支付通知任务Mapper接口 + * + * @author pengles + */ +public interface PayNotifyTaskMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayOrderMapper.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayOrderMapper.java new file mode 100644 index 0000000..50f21cb --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayOrderMapper.java @@ -0,0 +1,16 @@ +package org.dromara.pay.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.domain.vo.PayOrderVo; + +/** + * 支付订单Mapper接口 + * + * @author pengles + */ +@Mapper +public interface PayOrderMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayRefundMapper.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayRefundMapper.java new file mode 100644 index 0000000..fd5f2ea --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/mapper/PayRefundMapper.java @@ -0,0 +1,16 @@ +package org.dromara.pay.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.pay.domain.PayRefund; +import org.dromara.pay.domain.vo.PayRefundVo; + +/** + * 退款订单Mapper接口 + * + * @author pengles + */ +@Mapper +public interface PayRefundMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayAppService.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayAppService.java new file mode 100644 index 0000000..cf29f74 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayAppService.java @@ -0,0 +1,46 @@ +package org.dromara.pay.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.pay.domain.bo.PayAppBo; +import org.dromara.pay.domain.vo.PayAppVo; +import java.util.Collection; +import java.util.List; + +/** + * 支付应用Service接口 + * + * @author pengles + */ +public interface PayAppService { + + /** + * 查询支付应用 + */ + PayAppVo queryById(Long id); + + /** + * 查询支付应用列表 + */ + TableDataInfo queryPageList(PayAppBo bo, PageQuery pageQuery); + + /** + * 查询支付应用列表 + */ + List queryList(PayAppBo bo); + + /** + * 新增支付应用 + */ + Boolean insertByBo(PayAppBo bo); + + /** + * 修改支付应用 + */ + Boolean updateByBo(PayAppBo bo); + + /** + * 校验并批量删除支付应用信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayChannelService.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayChannelService.java new file mode 100644 index 0000000..21ba1e0 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayChannelService.java @@ -0,0 +1,46 @@ +package org.dromara.pay.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.pay.domain.bo.PayChannelBo; +import org.dromara.pay.domain.vo.PayChannelVo; +import java.util.Collection; +import java.util.List; + +/** + * 支付渠道Service接口 + * + * @author pengles + */ +public interface PayChannelService { + + /** + * 查询支付渠道 + */ + PayChannelVo queryById(Long id); + + /** + * 查询支付渠道列表 + */ + TableDataInfo queryPageList(PayChannelBo bo, PageQuery pageQuery); + + /** + * 查询支付渠道列表 + */ + List queryList(PayChannelBo bo); + + /** + * 新增支付渠道 + */ + Boolean insertByBo(PayChannelBo bo); + + /** + * 修改支付渠道 + */ + Boolean updateByBo(PayChannelBo bo); + + /** + * 校验并批量删除支付渠道信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayNotifyService.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayNotifyService.java new file mode 100644 index 0000000..4a60113 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayNotifyService.java @@ -0,0 +1,131 @@ +package org.dromara.pay.service; + +import cn.hutool.http.HttpUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.pay.domain.PayNotifyTask; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.mapper.PayNotifyTaskMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 支付通知服务 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class PayNotifyService { + + private final PayNotifyTaskMapper baseMapper; + + /** + * 创建通知任务 + */ + @Transactional(rollbackFor = Exception.class) + public void createTask(PayOrder order) { + if (StringUtils.isBlank(order.getNotifyUrl())) { + return; + } + PayNotifyTask task = new PayNotifyTask(); + task.setOrderId(order.getId()); + task.setOrderNo(order.getNo()); + task.setNotifyUrl(order.getNotifyUrl()); + + Map body = new HashMap<>(); + body.put("orderNo", order.getNo()); + body.put("merchantOrderId", order.getMerchantOrderId()); + body.put("status", order.getStatus()); + body.put("price", order.getPrice()); + body.put("channelUserId", order.getChannelUserId()); + body.put("successTime", order.getSuccessTime()); + body.put("extensionId", order.getExtensionId()); + body.put("channelExtras", order.getChannelExtras()); + + task.setNotifyBody(JsonUtils.toJsonString(body)); + task.setRetryCount(0); + task.setMaxRetry(5); + task.setStatus(0); // 进行中 + task.setNextNotifyTime(new Date()); // 立即执行 + + baseMapper.insert(task); + + // 尝试立即执行一次 + executeTask(task); + } + + /** + * 执行通知任务 + */ + public void executeTask(PayNotifyTask task) { + try { + log.info("开始执行支付回调通知: taskId={}, url={}", task.getId(), task.getNotifyUrl()); + String result = HttpUtil.post(task.getNotifyUrl(), task.getNotifyBody(), 5000); + log.info("支付回调通知响应: taskId={}, result={}", task.getId(), result); + + // 判断成功:返回 "success" 或 JSON格式 {"code": 200} + boolean success = false; + if (StringUtils.isNotBlank(result)) { + String trimmed = result.trim(); + if ("success".equalsIgnoreCase(trimmed)) { + success = true; + } else { + // 尝试解析JSON + try { + Map jsonResult = JsonUtils.parseMap(trimmed); + Object code = jsonResult.get("code"); + if (code != null) { + if (code instanceof Number && ((Number) code).intValue() == 200) { + success = true; + } else if ("200".equals(String.valueOf(code))) { + success = true; + } + } + } catch (Exception e) { + // 不是JSON格式,忽略 + } + } + } + + if (success) { + task.setStatus(1); // 成功 + baseMapper.updateById(task); + return; + } + } catch (Exception e) { + log.error("支付回调通知异常: taskId={}", task.getId(), e); + } + + // 失败处理 + task.setRetryCount(task.getRetryCount() + 1); + if (task.getRetryCount() >= task.getMaxRetry()) { + task.setStatus(2); // 失败 + } else { + // 计算下次重试时间:指数退避 + // 15s, 30s, 1m, 2m, 5m + int delaySeconds = 15 * (1 << (task.getRetryCount() - 1)); + task.setNextNotifyTime(new Date(System.currentTimeMillis() + delaySeconds * 1000L)); + } + baseMapper.updateById(task); + } + + /** + * 获取待执行的任务 + */ + public List getPendingTasks() { + return baseMapper.selectList(new LambdaQueryWrapper() + .eq(PayNotifyTask::getStatus, 0) + .le(PayNotifyTask::getNextNotifyTime, new Date()) + .last("LIMIT 100")); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayOrderService.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayOrderService.java new file mode 100644 index 0000000..398541b --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayOrderService.java @@ -0,0 +1,57 @@ +package org.dromara.pay.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.pay.domain.bo.PayOrderBo; +import org.dromara.pay.domain.vo.PayOrderSubmitRespVO; +import org.dromara.pay.domain.vo.PayOrderVo; +import java.util.Collection; +import java.util.List; + +/** + * 支付订单Service接口 + * + * @author pengles + */ +public interface PayOrderService { + + /** + * 查询支付订单 + */ + PayOrderVo queryById(Long id); + + /** + * 查询支付订单列表 + */ + TableDataInfo queryPageList(PayOrderBo bo, PageQuery pageQuery); + + /** + * 查询支付订单列表 + */ + List queryList(PayOrderBo bo); + + /** + * 新增支付订单 + */ + Boolean insertByBo(PayOrderBo bo); + + /** + * 修改支付订单 + */ + Boolean updateByBo(PayOrderBo bo); + + /** + * 校验并批量删除支付订单信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 提交支付订单 + */ + PayOrderSubmitRespVO submitOrder(PayOrderBo bo); + + /** + * 支付成功回调通知 + */ + void notifyOrderSuccess(String orderNo, String channelOrderNo); +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayRefundService.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayRefundService.java new file mode 100644 index 0000000..8beac79 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/PayRefundService.java @@ -0,0 +1,56 @@ +package org.dromara.pay.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.pay.domain.bo.PayRefundBo; +import org.dromara.pay.domain.vo.PayRefundVo; +import java.util.Collection; +import java.util.List; + +/** + * 退款订单Service接口 + * + * @author pengles + */ +public interface PayRefundService { + + /** + * 查询退款订单 + */ + PayRefundVo queryById(Long id); + + /** + * 查询退款订单列表 + */ + TableDataInfo queryPageList(PayRefundBo bo, PageQuery pageQuery); + + /** + * 查询退款订单列表 + */ + List queryList(PayRefundBo bo); + + /** + * 新增退款订单 + */ + Boolean insertByBo(PayRefundBo bo); + + /** + * 修改退款订单 + */ + Boolean updateByBo(PayRefundBo bo); + + /** + * 校验并批量删除退款订单信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 创建并执行退款 + */ + Boolean createRefund(PayRefundBo bo); + + /** + * 处理退款回调成功 + */ + void notifyRefundSuccess(String refundNo, String channelRefundNo); +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayAppServiceImpl.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayAppServiceImpl.java new file mode 100644 index 0000000..0b10450 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayAppServiceImpl.java @@ -0,0 +1,132 @@ +package org.dromara.pay.service.impl; + +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.pay.domain.PayApp; +import org.dromara.pay.domain.bo.PayAppBo; +import org.dromara.pay.domain.vo.PayAppVo; +import org.dromara.pay.mapper.PayAppMapper; +import org.dromara.pay.service.PayAppService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 支付应用Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class PayAppServiceImpl implements PayAppService { + + private final PayAppMapper baseMapper; + + /** + * 查询支付应用 + */ + @Override + public PayAppVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询支付应用列表 + */ + @Override + public TableDataInfo queryPageList(PayAppBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询支付应用列表 + */ + @Override + public List queryList(PayAppBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(PayAppBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.like(StringUtils.isNotBlank(bo.getName()), PayApp::getName, bo.getName()); + lqw.eq(bo.getStatus() != null, PayApp::getStatus, bo.getStatus()); + return lqw; + } + + /** + * 新增支付应用 + */ + @Override + public Boolean insertByBo(PayAppBo bo) { + PayApp add = MapstructUtils.convert(bo, PayApp.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改支付应用 + */ + @Override + public Boolean updateByBo(PayAppBo bo) { + PayApp update = MapstructUtils.convert(bo, PayApp.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(PayApp entity){ + // 校验应用名称不能为空 + if (StringUtils.isBlank(entity.getName())) { + throw new ServiceException("应用名称不能为空"); + } + // 校验回调地址格式 + if (StringUtils.isNotBlank(entity.getPayNotifyUrl())) { + if (!entity.getPayNotifyUrl().startsWith("http://") && !entity.getPayNotifyUrl().startsWith("https://")) { + throw new ServiceException("支付回调地址格式不正确"); + } + } + if (StringUtils.isNotBlank(entity.getRefundNotifyUrl())) { + if (!entity.getRefundNotifyUrl().startsWith("http://") && !entity.getRefundNotifyUrl().startsWith("https://")) { + throw new ServiceException("退款回调地址格式不正确"); + } + } + } + + /** + * 批量删除支付应用 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if(isValid){ + // 校验应用是否有关联的支付渠道 + for (Long id : ids) { + // 这里可以添加检查是否有关联的支付渠道或订单 + // 暂时只检查应用是否存在 + PayApp app = baseMapper.selectById(id); + if (app == null) { + throw new ServiceException("支付应用不存在"); + } + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayChannelServiceImpl.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayChannelServiceImpl.java new file mode 100644 index 0000000..ce27f1b --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayChannelServiceImpl.java @@ -0,0 +1,141 @@ +package org.dromara.pay.service.impl; + +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.bo.PayChannelBo; +import org.dromara.pay.domain.vo.PayChannelVo; +import org.dromara.pay.mapper.PayChannelMapper; +import org.dromara.pay.service.PayChannelService; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 支付渠道Service业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class PayChannelServiceImpl implements PayChannelService { + + private final PayChannelMapper baseMapper; + + /** + * 查询支付渠道 + */ + @Override + public PayChannelVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询支付渠道列表 + */ + @Override + public TableDataInfo queryPageList(PayChannelBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询支付渠道列表 + */ + @Override + public List queryList(PayChannelBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(PayChannelBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(StringUtils.isNotBlank(bo.getCode()), PayChannel::getCode, bo.getCode()); + lqw.eq(bo.getStatus() != null, PayChannel::getStatus, bo.getStatus()); + lqw.eq(bo.getAppId() != null, PayChannel::getAppId, bo.getAppId()); + return lqw; + } + + /** + * 新增支付渠道 + */ + @Override + public Boolean insertByBo(PayChannelBo bo) { + PayChannel add = MapstructUtils.convert(bo, PayChannel.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改支付渠道 + */ + @Override + public Boolean updateByBo(PayChannelBo bo) { + PayChannel update = MapstructUtils.convert(bo, PayChannel.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(PayChannel entity){ + // 校验渠道编码不能为空 + if (StringUtils.isBlank(entity.getCode())) { + throw new ServiceException("渠道编码不能为空"); + } + // 校验渠道编码唯一性(同一应用下) + if (entity.getAppId() != null) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PayChannel::getAppId, entity.getAppId()) + .eq(PayChannel::getCode, entity.getCode()); + if (entity.getId() != null) { + wrapper.ne(PayChannel::getId, entity.getId()); + } + Long count = baseMapper.selectCount(wrapper); + if (count > 0) { + throw new ServiceException("该应用下渠道编码已存在"); + } + } + // 校验配置JSON格式 + if (StringUtils.isNotBlank(entity.getConfig())) { + try { + org.dromara.common.json.utils.JsonUtils.parseMap(entity.getConfig()); + } catch (Exception e) { + throw new ServiceException("渠道配置JSON格式不正确"); + } + } + } + + /** + * 批量删除支付渠道 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if(isValid){ + // 校验渠道是否存在 + for (Long id : ids) { + PayChannel channel = baseMapper.selectById(id); + if (channel == null) { + throw new ServiceException("支付渠道不存在"); + } + // 可以添加检查是否有关联的订单 + } + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayOrderServiceImpl.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayOrderServiceImpl.java new file mode 100644 index 0000000..8324449 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayOrderServiceImpl.java @@ -0,0 +1,223 @@ +package org.dromara.pay.service.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.pay.core.PayStrategy; +import org.dromara.pay.core.PayStrategyFactory; +import org.dromara.pay.domain.PayApp; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.domain.bo.PayOrderBo; +import org.dromara.pay.domain.vo.PayOrderSubmitRespVO; +import org.dromara.pay.domain.vo.PayOrderVo; +import org.dromara.pay.enums.PayOrderStatusEnum; +import org.dromara.pay.event.PaySuccessEvent; +import org.dromara.pay.mapper.PayAppMapper; +import org.dromara.pay.mapper.PayChannelMapper; +import org.dromara.pay.mapper.PayOrderMapper; +import org.dromara.pay.service.PayOrderService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 支付订单Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class PayOrderServiceImpl implements PayOrderService { + + private final PayOrderMapper baseMapper; + private final PayAppMapper payAppMapper; + private final PayChannelMapper payChannelMapper; + private final PayStrategyFactory payStrategyFactory; + private final ApplicationEventPublisher eventPublisher; + + /** + * 查询支付订单 + */ + @Override + public PayOrderVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询支付订单列表 + */ + @Override + public TableDataInfo queryPageList(PayOrderBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询支付订单列表 + */ + @Override + public List queryList(PayOrderBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(PayOrderBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(bo.getAppId() != null, PayOrder::getAppId, bo.getAppId()); + lqw.eq(bo.getChannelId() != null, PayOrder::getChannelId, bo.getChannelId()); + lqw.eq(StringUtils.isNotBlank(bo.getChannelCode()), PayOrder::getChannelCode, bo.getChannelCode()); + lqw.eq(StringUtils.isNotBlank(bo.getMerchantOrderId()), PayOrder::getMerchantOrderId, bo.getMerchantOrderId()); + lqw.eq(bo.getStatus() != null, PayOrder::getStatus, bo.getStatus()); + return lqw; + } + + /** + * 新增支付订单 + */ + @Override + public Boolean insertByBo(PayOrderBo bo) { + PayOrder add = MapstructUtils.convert(bo, PayOrder.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改支付订单 + */ + @Override + public Boolean updateByBo(PayOrderBo bo) { + PayOrder update = MapstructUtils.convert(bo, PayOrder.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(PayOrder entity){ + // 校验商户订单号唯一性(如果已存在) + if (StringUtils.isNotBlank(entity.getMerchantOrderId())) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PayOrder::getMerchantOrderId, entity.getMerchantOrderId()); + if (entity.getId() != null) { + wrapper.ne(PayOrder::getId, entity.getId()); + } + Long count = baseMapper.selectCount(wrapper); + if (count > 0) { + throw new ServiceException("商户订单号已存在"); + } + } + // 校验支付单号唯一性 + if (StringUtils.isNotBlank(entity.getNo())) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PayOrder::getNo, entity.getNo()); + if (entity.getId() != null) { + wrapper.ne(PayOrder::getId, entity.getId()); + } + Long count = baseMapper.selectCount(wrapper); + if (count > 0) { + throw new ServiceException("支付单号已存在"); + } + } + } + + /** + * 批量删除支付订单 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if(isValid){ + // 校验订单是否可以删除(已支付的订单不能删除) + for (Long id : ids) { + PayOrder order = baseMapper.selectById(id); + if (order != null && PayOrderStatusEnum.SUCCESS.getStatus().equals(order.getStatus())) { + throw new ServiceException("已支付的订单不能删除"); + } + } + } + return baseMapper.deleteByIds(ids) > 0; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PayOrderSubmitRespVO submitOrder(PayOrderBo bo) { + // 1. 校验 App + PayApp app = payAppMapper.selectById(bo.getAppId()); + if (app == null || app.getStatus() == 1) { // 假设 1 为关闭 + throw new ServiceException("应用不存在或已禁用"); + } + + // 2. 校验 Channel + PayChannel channel = payChannelMapper.selectOne(new LambdaQueryWrapper() + .eq(PayChannel::getAppId, bo.getAppId()) + .eq(PayChannel::getCode, bo.getChannelCode())); + if (channel == null || channel.getStatus() == 1) { + throw new ServiceException("支付渠道不存在或已禁用"); + } + + // 3. 创建订单 + PayOrder order = MapstructUtils.convert(bo, PayOrder.class); + order.setChannelId(channel.getId()); + order.setNo(IdUtil.getSnowflakeNextIdStr()); // 生成内部单号 + order.setStatus(PayOrderStatusEnum.WAITING.getStatus()); + order.setExpireTime(new Date(System.currentTimeMillis() + 30 * 60 * 1000)); // 30分钟过期 + // 补全 NotifyUrl + if (StringUtils.isBlank(order.getNotifyUrl())) { + String baseUrl = app.getPayNotifyUrl(); + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + order.setNotifyUrl(baseUrl + "/" + channel.getCode() + "/" + channel.getId()); + } + + baseMapper.insert(order); + + // 4. 调用策略 + PayStrategy strategy = payStrategyFactory.getStrategy(channel.getCode()); + PayOrderSubmitRespVO resp = strategy.unifiedOrder(order, channel); + + return resp; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void notifyOrderSuccess(String orderNo, String channelOrderNo) { + PayOrder order = baseMapper.selectOne(new LambdaQueryWrapper().eq(PayOrder::getNo, orderNo)); + if (order == null) { + log.error("支付回调订单不存在: {}", orderNo); + return; + } + if (PayOrderStatusEnum.SUCCESS.getStatus().equals(order.getStatus())) { + log.info("订单已支付,忽略回调: {}", orderNo); + return; + } + + order.setStatus(PayOrderStatusEnum.SUCCESS.getStatus()); + order.setSuccessTime(new Date()); + order.setChannelOrderNo(channelOrderNo); + baseMapper.updateById(order); + + log.info("订单支付成功: {}", orderNo); + // 发布支付成功事件 + eventPublisher.publishEvent(new PaySuccessEvent(this, order)); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayRefundServiceImpl.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayRefundServiceImpl.java new file mode 100644 index 0000000..ccafd45 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/service/impl/PayRefundServiceImpl.java @@ -0,0 +1,234 @@ +package org.dromara.pay.service.impl; + +import cn.hutool.core.util.IdUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.pay.core.PayStrategy; +import org.dromara.pay.core.PayStrategyFactory; +import org.dromara.pay.domain.PayChannel; +import org.dromara.pay.domain.PayOrder; +import org.dromara.pay.domain.PayRefund; +import org.dromara.pay.domain.bo.PayRefundBo; +import org.dromara.pay.domain.vo.PayRefundVo; +import org.dromara.pay.enums.PayOrderStatusEnum; +import org.dromara.pay.mapper.PayChannelMapper; +import org.dromara.pay.mapper.PayOrderMapper; +import org.dromara.pay.mapper.PayRefundMapper; +import org.dromara.pay.service.PayRefundService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +/** + * 退款订单Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class PayRefundServiceImpl implements PayRefundService { + + private final PayRefundMapper baseMapper; + private final PayOrderMapper payOrderMapper; + private final PayChannelMapper payChannelMapper; + private final PayStrategyFactory payStrategyFactory; + + /** + * 查询退款订单 + */ + @Override + public PayRefundVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询退款订单列表 + */ + @Override + public TableDataInfo queryPageList(PayRefundBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询退款订单列表 + */ + @Override + public List queryList(PayRefundBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(PayRefundBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper<>(); + lqw.eq(bo.getAppId() != null, PayRefund::getAppId, bo.getAppId()); + lqw.eq(bo.getChannelId() != null, PayRefund::getChannelId, bo.getChannelId()); + lqw.eq(StringUtils.isNotBlank(bo.getChannelCode()), PayRefund::getChannelCode, bo.getChannelCode()); + lqw.eq(StringUtils.isNotBlank(bo.getMerchantOrderId()), PayRefund::getMerchantOrderId, bo.getMerchantOrderId()); + lqw.eq(StringUtils.isNotBlank(bo.getMerchantRefundId()), PayRefund::getMerchantRefundId, bo.getMerchantRefundId()); + lqw.eq(bo.getStatus() != null, PayRefund::getStatus, bo.getStatus()); + return lqw; + } + + /** + * 新增退款订单 + */ + @Override + public Boolean insertByBo(PayRefundBo bo) { + PayRefund add = MapstructUtils.convert(bo, PayRefund.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改退款订单 + */ + @Override + public Boolean updateByBo(PayRefundBo bo) { + PayRefund update = MapstructUtils.convert(bo, PayRefund.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(PayRefund entity){ + // 校验退款单号唯一性 + if (StringUtils.isNotBlank(entity.getNo())) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PayRefund::getNo, entity.getNo()); + if (entity.getId() != null) { + wrapper.ne(PayRefund::getId, entity.getId()); + } + Long count = baseMapper.selectCount(wrapper); + if (count > 0) { + throw new ServiceException("退款单号已存在"); + } + } + // 校验商户退款编号唯一性 + if (StringUtils.isNotBlank(entity.getMerchantRefundId())) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PayRefund::getMerchantRefundId, entity.getMerchantRefundId()); + if (entity.getId() != null) { + wrapper.ne(PayRefund::getId, entity.getId()); + } + Long count = baseMapper.selectCount(wrapper); + if (count > 0) { + throw new ServiceException("商户退款编号已存在"); + } + } + // 校验退款金额不能超过支付金额 + if (entity.getRefundPrice() != null && entity.getPayPrice() != null) { + if (entity.getRefundPrice() > entity.getPayPrice()) { + throw new ServiceException("退款金额不能超过支付金额"); + } + } + } + + /** + * 批量删除退款订单 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if(isValid){ + // 校验退款订单是否可以删除(处理中的退款不能删除) + for (Long id : ids) { + PayRefund refund = baseMapper.selectById(id); + if (refund != null && refund.getStatus() == 0) { + throw new ServiceException("处理中的退款订单不能删除"); + } + } + } + return baseMapper.deleteByIds(ids) > 0; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean createRefund(PayRefundBo bo) { + // 1. 校验订单 + PayOrder order = payOrderMapper.selectOne(new LambdaQueryWrapper() + .eq(PayOrder::getNo, bo.getOrderNo())); + if (order == null) { + throw new ServiceException("订单不存在"); + } + if (!PayOrderStatusEnum.SUCCESS.getStatus().equals(order.getStatus())) { + throw new ServiceException("订单未支付或已退款"); + } + + // 2. 创建退款单 + PayRefund refund = MapstructUtils.convert(bo, PayRefund.class); + refund.setAppId(order.getAppId()); + refund.setChannelId(order.getChannelId()); + refund.setChannelCode(order.getChannelCode()); + refund.setMerchantOrderId(order.getMerchantOrderId()); + refund.setPayPrice(order.getPrice()); + refund.setNo(IdUtil.getSnowflakeNextIdStr()); // 生成退款单号 + refund.setStatus(0); // WAITING + // NotifyUrl 可以从 App 配置获取,或者 BO 传入 + + baseMapper.insert(refund); + + // 3. 执行退款策略 + PayChannel channel = payChannelMapper.selectById(order.getChannelId()); + PayStrategy strategy = payStrategyFactory.getStrategy(channel.getCode()); + Boolean success = strategy.refund(refund, channel); + + // 4. 更新状态 + if (success) { + refund.setStatus(10); // SUCCESS (假设 10 为成功) + refund.setSuccessTime(new Date()); + baseMapper.updateById(refund); + + // 更新订单状态 (如果是全额退款,可以标记为已退款) + // 简单处理:只要退款成功,订单状态改为已退款 (20) + order.setStatus(PayOrderStatusEnum.REFUND.getStatus()); + payOrderMapper.updateById(order); + } else { + refund.setStatus(20); // FAIL (假设 20 为失败) + baseMapper.updateById(refund); + } + + return success; + } + + /** + * 处理退款回调成功 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void notifyRefundSuccess(String refundNo, String channelRefundNo) { + PayRefund refund = baseMapper.selectOne(new LambdaQueryWrapper().eq(PayRefund::getNo, refundNo)); + if (refund == null) { + log.error("退款回调订单不存在: {}", refundNo); + return; + } + if (refund.getStatus() == 10) { // 已成功 + log.info("退款已成功,忽略回调: {}", refundNo); + return; + } + + refund.setStatus(10); // SUCCESS + refund.setSuccessTime(new Date()); + refund.setChannelRefundNo(channelRefundNo); + baseMapper.updateById(refund); + + log.info("退款成功: {}", refundNo); + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/util/WechatPayUtil.java b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/util/WechatPayUtil.java new file mode 100644 index 0000000..5866ce5 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/java/org/dromara/pay/util/WechatPayUtil.java @@ -0,0 +1,72 @@ +package org.dromara.pay.util; + +import com.wechat.pay.java.core.cipher.RSASigner; +import java.security.PrivateKey; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * 微信支付 V3 工具类 + * + * @author pengles + */ +public class WechatPayUtil { + + /** + * App 支付二次签名 + * + * @param appId 应用ID + * @param mchId 商户ID + * @param prepayId 预支付交易会话ID + * @param privateKey 商户私钥 + * @param mchSerialNo 商户API证书序列号 + * @return 返回给 App 的参数 + */ + public static Map signApp(String appId, String mchId, String prepayId, PrivateKey privateKey, String mchSerialNo) { + long timestamp = Instant.now().getEpochSecond(); + String nonceStr = UUID.randomUUID().toString().replace("-", ""); + String message = appId + "\n" + timestamp + "\n" + nonceStr + "\n" + prepayId + "\n"; + + RSASigner signer = new RSASigner(mchSerialNo, privateKey); + String sign = signer.sign(message).getSign(); + + Map map = new HashMap<>(); + map.put("appid", appId); + map.put("partnerid", mchId); + map.put("prepayid", prepayId); + map.put("package", "Sign=WXPay"); + map.put("noncestr", nonceStr); + map.put("timestamp", String.valueOf(timestamp)); + map.put("sign", sign); + return map; + } + + /** + * JSAPI/小程序 支付二次签名 + * + * @param appId 应用ID + * @param prepayId 预支付交易会话ID + * @param privateKey 商户私钥 + * @return 返回给 JSAPI/小程序的参数 + */ + public static Map signJsapi(String appId, String prepayId, PrivateKey privateKey, String mchSerialNo) { + long timestamp = Instant.now().getEpochSecond(); + String nonceStr = UUID.randomUUID().toString().replace("-", ""); + String packageStr = "prepay_id=" + prepayId; + String message = appId + "\n" + timestamp + "\n" + nonceStr + "\n" + packageStr + "\n"; + + RSASigner signer = new RSASigner(mchSerialNo, privateKey); + String sign = signer.sign(message).getSign(); + + Map map = new HashMap<>(); + map.put("appId", appId); + map.put("timeStamp", String.valueOf(timestamp)); + map.put("nonceStr", nonceStr); + map.put("package", packageStr); + map.put("signType", "RSA"); + map.put("paySign", sign); + return map; + } +} diff --git a/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayAppMapper.xml b/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayAppMapper.xml new file mode 100644 index 0000000..743d500 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayAppMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayChannelMapper.xml b/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayChannelMapper.xml new file mode 100644 index 0000000..bda4804 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayChannelMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayOrderMapper.xml b/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayOrderMapper.xml new file mode 100644 index 0000000..0e354df --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayOrderMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayRefundMapper.xml b/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayRefundMapper.xml new file mode 100644 index 0000000..fa04e79 --- /dev/null +++ b/ruoyi-modules/ruoyi-pay/src/main/resources/mapper/pay/PayRefundMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/pom.xml b/ruoyi-modules/ruoyi-system/pom.xml new file mode 100644 index 0000000..18711bc --- /dev/null +++ b/ruoyi-modules/ruoyi-system/pom.xml @@ -0,0 +1,100 @@ + + + + org.dromara + ruoyi-modules + ${revision} + + 4.0.0 + + ruoyi-system + + + system系统模块 + + + + + + org.dromara + ruoyi-common-core + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-common-translation + + + + + org.dromara + ruoyi-common-oss + + + + org.dromara + ruoyi-common-log + + + + + org.dromara + ruoyi-common-excel + + + + + org.dromara + ruoyi-common-sms + + + + org.dromara + ruoyi-common-security + + + + org.dromara + ruoyi-common-web + + + + org.dromara + ruoyi-common-idempotent + + + + org.dromara + ruoyi-common-sensitive + + + + org.dromara + ruoyi-common-encrypt + + + + org.dromara + ruoyi-common-websocket + + + + org.dromara + ruoyi-common-sse + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/CacheController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/CacheController.java new file mode 100644 index 0000000..a8fddbd --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/CacheController.java @@ -0,0 +1,59 @@ +package org.dromara.system.controller.monitor; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; +import org.redisson.spring.data.connection.RedissonConnectionFactory; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.core.RedisConnectionUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.*; + +/** + * 缓存监控 + * + * @author pengles + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/monitor/cache") +public class CacheController { + + private final RedissonConnectionFactory connectionFactory; + + /** + * 获取缓存监控列表 + */ + @SaCheckPermission("monitor:cache:list") + @GetMapping() + public R getInfo() throws Exception { + RedisConnection connection = connectionFactory.getConnection(); + try { + Properties commandStats = connection.commands().info("commandstats"); + List> pieList = new ArrayList<>(); + if (commandStats != null) { + commandStats.stringPropertyNames().forEach(key -> { + Map data = new HashMap<>(2); + String property = commandStats.getProperty(key); + data.put("name", StringUtils.removeStart(key, "cmdstat_")); + data.put("value", StringUtils.substringBetween(property, "calls=", ",usec")); + pieList.add(data); + }); + } + return R.ok(new CacheListInfoVo( + connection.commands().info(), + connection.commands().dbSize(), pieList)); + } finally { + // 归还连接给连接池 + RedisConnectionUtils.releaseConnection(connection, connectionFactory); + } + } + + public record CacheListInfoVo(Properties info, Long dbSize, List> commandStats) { + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysLogininforController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysLogininforController.java new file mode 100644 index 0000000..4c3e425 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysLogininforController.java @@ -0,0 +1,90 @@ +package org.dromara.system.controller.monitor; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysLogininforBo; +import org.dromara.system.domain.vo.SysLogininforVo; +import org.dromara.system.service.SysLogininforService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 系统访问记录 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/monitor/logininfor") +public class SysLogininforController extends BaseController { + + private final SysLogininforService logininforService; + + /** + * 获取系统访问记录列表 + */ + @SaCheckPermission("monitor:logininfor:list") + @GetMapping("/list") + public TableDataInfo list(SysLogininforBo logininfor, PageQuery pageQuery) { + return logininforService.selectPageLogininforList(logininfor, pageQuery); + } + + /** + * 导出系统访问记录列表 + */ + @Log(title = "登录日志", businessType = BusinessType.EXPORT) + @SaCheckPermission("monitor:logininfor:export") + @PostMapping("/export") + public void export(SysLogininforBo logininfor, HttpServletResponse response) { + List list = logininforService.selectLogininforList(logininfor); + ExcelUtil.exportExcel(list, "登录日志", SysLogininforVo.class, response); + } + + /** + * 批量删除登录日志 + * + * @param infoIds 日志ids + */ + @SaCheckPermission("monitor:logininfor:remove") + @Log(title = "登录日志", businessType = BusinessType.DELETE) + @DeleteMapping("/{infoIds}") + public R remove(@PathVariable Long[] infoIds) { + return toAjax(logininforService.deleteLogininforByIds(infoIds)); + } + + /** + * 清理系统访问记录 + */ + @SaCheckPermission("monitor:logininfor:remove") + @Log(title = "登录日志", businessType = BusinessType.CLEAN) + @DeleteMapping("/clean") + public R clean() { + logininforService.cleanLogininfor(); + return R.ok(); + } + + @SaCheckPermission("monitor:logininfor:unlock") + @Log(title = "账户解锁", businessType = BusinessType.OTHER) + @GetMapping("/unlock/{userName}") + public R unlock(@PathVariable("userName") String userName) { + String loginName = CacheConstants.PWD_ERR_CNT_KEY + userName; + if (RedisUtils.hasKey(loginName)) { + RedisUtils.deleteObject(loginName); + } + return R.ok(); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysOperlogController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysOperlogController.java new file mode 100644 index 0000000..0721374 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysOperlogController.java @@ -0,0 +1,76 @@ +package org.dromara.system.controller.monitor; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysOperLogBo; +import org.dromara.system.domain.vo.SysOperLogVo; +import org.dromara.system.service.SysOperLogService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 操作日志记录 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/monitor/operlog") +public class SysOperlogController extends BaseController { + + private final SysOperLogService operLogService; + + /** + * 获取操作日志记录列表 + */ + @SaCheckPermission("monitor:operlog:list") + @GetMapping("/list") + public TableDataInfo list(SysOperLogBo operLog, PageQuery pageQuery) { + return operLogService.selectPageOperLogList(operLog, pageQuery); + } + + /** + * 导出操作日志记录列表 + */ + @Log(title = "操作日志", businessType = BusinessType.EXPORT) + @SaCheckPermission("monitor:operlog:export") + @PostMapping("/export") + public void export(SysOperLogBo operLog, HttpServletResponse response) { + List list = operLogService.selectOperLogList(operLog); + ExcelUtil.exportExcel(list, "操作日志", SysOperLogVo.class, response); + } + + /** + * 批量删除操作日志记录 + * + * @param operIds 日志ids + */ + @Log(title = "操作日志", businessType = BusinessType.DELETE) + @SaCheckPermission("monitor:operlog:remove") + @DeleteMapping("/{operIds}") + public R remove(@PathVariable Long[] operIds) { + return toAjax(operLogService.deleteOperLogByIds(operIds)); + } + + /** + * 清理操作日志记录 + */ + @Log(title = "操作日志", businessType = BusinessType.CLEAN) + @SaCheckPermission("monitor:operlog:remove") + @DeleteMapping("/clean") + public R clean() { + operLogService.cleanOperLog(); + return R.ok(); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysUserOnlineController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysUserOnlineController.java new file mode 100644 index 0000000..d014ab6 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysUserOnlineController.java @@ -0,0 +1,131 @@ +package org.dromara.system.controller.monitor; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.bean.BeanUtil; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.dto.UserOnlineDTO; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.SysUserOnline; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 在线用户监控 + * + * @author pengles + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/monitor/online") +public class SysUserOnlineController extends BaseController { + + /** + * 获取在线用户监控列表 + * + * @param ipaddr IP地址 + * @param userName 用户名 + */ + @SaCheckPermission("monitor:online:list") + @GetMapping("/list") + public TableDataInfo list(String ipaddr, String userName) { + // 获取所有未过期的 token + Collection keys = RedisUtils.keys(CacheConstants.ONLINE_TOKEN_KEY + "*"); + List userOnlineDTOList = new ArrayList<>(); + for (String key : keys) { + String token = StringUtils.substringAfterLast(key, ":"); + // 如果已经过期则跳过 + if (StpUtil.stpLogic.getTokenActiveTimeoutByToken(token) < -1) { + continue; + } + userOnlineDTOList.add(RedisUtils.getCacheObject(CacheConstants.ONLINE_TOKEN_KEY + token)); + } + if (StringUtils.isNotEmpty(ipaddr) && StringUtils.isNotEmpty(userName)) { + userOnlineDTOList = StreamUtils.filter(userOnlineDTOList, userOnline -> + StringUtils.equals(ipaddr, userOnline.getIpaddr()) && + StringUtils.equals(userName, userOnline.getUserName()) + ); + } else if (StringUtils.isNotEmpty(ipaddr)) { + userOnlineDTOList = StreamUtils.filter(userOnlineDTOList, userOnline -> + StringUtils.equals(ipaddr, userOnline.getIpaddr()) + ); + } else if (StringUtils.isNotEmpty(userName)) { + userOnlineDTOList = StreamUtils.filter(userOnlineDTOList, userOnline -> + StringUtils.equals(userName, userOnline.getUserName()) + ); + } + Collections.reverse(userOnlineDTOList); + userOnlineDTOList.removeAll(Collections.singleton(null)); + List userOnlineList = BeanUtil.copyToList(userOnlineDTOList, SysUserOnline.class); + return TableDataInfo.build(userOnlineList); + } + + /** + * 强退用户 + * + * @param tokenId token值 + */ + @SaCheckPermission("monitor:online:forceLogout") + @Log(title = "在线用户", businessType = BusinessType.FORCE) + @DeleteMapping("/{tokenId}") + public R forceLogout(@PathVariable String tokenId) { + try { + StpUtil.kickoutByTokenValue(tokenId); + } catch (NotLoginException ignored) { + } + return R.ok(); + } + + /** + * 获取当前用户登录在线设备 + */ + @GetMapping() + public TableDataInfo getInfo() { + // 获取指定账号 id 的 token 集合 + List tokenIds = StpUtil.getTokenValueListByLoginId(StpUtil.getLoginIdAsString()); + List userOnlineDTOList = tokenIds.stream() + .filter(token -> StpUtil.stpLogic.getTokenActiveTimeoutByToken(token) >= -1) + .map(token -> (UserOnlineDTO) RedisUtils.getCacheObject(CacheConstants.ONLINE_TOKEN_KEY + token)) + .collect(Collectors.toList()); + //复制和处理 SysUserOnline 对象列表 + Collections.reverse(userOnlineDTOList); + userOnlineDTOList.removeAll(Collections.singleton(null)); + List userOnlineList = BeanUtil.copyToList(userOnlineDTOList, SysUserOnline.class); + return TableDataInfo.build(userOnlineList); + } + + /** + * 强退当前在线设备 + * + * @param tokenId token值 + */ + @Log(title = "在线设备", businessType = BusinessType.FORCE) + @DeleteMapping("/myself/{tokenId}") + public R remove(@PathVariable("tokenId") String tokenId) { + try { + // 获取指定账号 id 的 token 集合 + List keys = StpUtil.getTokenValueListByLoginId(StpUtil.getLoginIdAsString()); + keys.stream() + .filter(key -> key.equals(tokenId)) + .findFirst() + .ifPresent(key -> StpUtil.kickoutByTokenValue(tokenId)); + } catch (NotLoginException ignored) { + } + return R.ok(); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysClientController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysClientController.java new file mode 100644 index 0000000..8029701 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysClientController.java @@ -0,0 +1,116 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysClientBo; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.service.SysClientService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 客户端管理 + * + * @author pengles + * @date 2023-06-18 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/client") +public class SysClientController extends BaseController { + + private final SysClientService sysClientService; + + /** + * 查询客户端管理列表 + */ + @SaCheckPermission("system:client:list") + @GetMapping("/list") + public TableDataInfo list(SysClientBo bo, PageQuery pageQuery) { + return sysClientService.queryPageList(bo, pageQuery); + } + + /** + * 导出客户端管理列表 + */ + @SaCheckPermission("system:client:export") + @Log(title = "客户端管理", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(SysClientBo bo, HttpServletResponse response) { + List list = sysClientService.queryList(bo); + ExcelUtil.exportExcel(list, "客户端管理", SysClientVo.class, response); + } + + /** + * 获取客户端管理详细信息 + * + * @param id 主键 + */ + @SaCheckPermission("system:client:query") + @GetMapping("/{id}") + public R getInfo(@NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(sysClientService.queryById(id)); + } + + /** + * 新增客户端管理 + */ + @SaCheckPermission("system:client:add") + @Log(title = "客户端管理", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody SysClientBo bo) { + return toAjax(sysClientService.insertByBo(bo)); + } + + /** + * 修改客户端管理 + */ + @SaCheckPermission("system:client:edit") + @Log(title = "客户端管理", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody SysClientBo bo) { + return toAjax(sysClientService.updateByBo(bo)); + } + + /** + * 状态修改 + */ + @SaCheckPermission("system:client:edit") + @Log(title = "客户端管理", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus") + public R changeStatus(@RequestBody SysClientBo bo) { + return toAjax(sysClientService.updateClientStatus(bo.getClientId(), bo.getStatus())); + } + + /** + * 删除客户端管理 + * + * @param ids 主键串 + */ + @SaCheckPermission("system:client:remove") + @Log(title = "客户端管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(sysClientService.deleteWithValidByIds(List.of(ids), true)); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysConfigController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysConfigController.java new file mode 100644 index 0000000..d7ae2c4 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysConfigController.java @@ -0,0 +1,138 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysConfigBo; +import org.dromara.system.domain.vo.SysConfigVo; +import org.dromara.system.service.SysConfigService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 参数配置 信息操作处理 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/config") +public class SysConfigController extends BaseController { + + private final SysConfigService configService; + + /** + * 获取参数配置列表 + */ + @SaCheckPermission("system:config:list") + @GetMapping("/list") + public TableDataInfo list(SysConfigBo config, PageQuery pageQuery) { + return configService.selectPageConfigList(config, pageQuery); + } + + /** + * 导出参数配置列表 + */ + @Log(title = "参数管理", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:config:export") + @PostMapping("/export") + public void export(SysConfigBo config, HttpServletResponse response) { + List list = configService.selectConfigList(config); + ExcelUtil.exportExcel(list, "参数数据", SysConfigVo.class, response); + } + + /** + * 根据参数编号获取详细信息 + * + * @param configId 参数ID + */ + @SaCheckPermission("system:config:query") + @GetMapping(value = "/{configId}") + public R getInfo(@PathVariable Long configId) { + return R.ok(configService.selectConfigById(configId)); + } + + /** + * 根据参数键名查询参数值 + * + * @param configKey 参数Key + */ + @GetMapping(value = "/configKey/{configKey}") + public R getConfigKey(@PathVariable String configKey) { + return R.ok("操作成功", configService.selectConfigByKey(configKey)); + } + + /** + * 新增参数配置 + */ + @SaCheckPermission("system:config:add") + @Log(title = "参数管理", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysConfigBo config) { + if (!configService.checkConfigKeyUnique(config)) { + return R.fail("新增参数'" + config.getConfigName() + "'失败,参数键名已存在"); + } + configService.insertConfig(config); + return R.ok(); + } + + /** + * 修改参数配置 + */ + @SaCheckPermission("system:config:edit") + @Log(title = "参数管理", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysConfigBo config) { + if (!configService.checkConfigKeyUnique(config)) { + return R.fail("修改参数'" + config.getConfigName() + "'失败,参数键名已存在"); + } + configService.updateConfig(config); + return R.ok(); + } + + /** + * 根据参数键名修改参数配置 + */ + @SaCheckPermission("system:config:edit") + @Log(title = "参数管理", businessType = BusinessType.UPDATE) + @PutMapping("/updateByKey") + public R updateByKey(@RequestBody SysConfigBo config) { + configService.updateConfig(config); + return R.ok(); + } + + /** + * 删除参数配置 + * + * @param configIds 参数ID串 + */ + @SaCheckPermission("system:config:remove") + @Log(title = "参数管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{configIds}") + public R remove(@PathVariable Long[] configIds) { + configService.deleteConfigByIds(Arrays.asList(configIds)); + return R.ok(); + } + + /** + * 刷新参数缓存 + */ + @SaCheckPermission("system:config:remove") + @Log(title = "参数管理", businessType = BusinessType.CLEAN) + @DeleteMapping("/refreshCache") + public R refreshCache() { + configService.resetConfigCache(); + return R.ok(); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDeptController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDeptController.java new file mode 100644 index 0000000..8b4d276 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDeptController.java @@ -0,0 +1,143 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.convert.Convert; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysDeptBo; +import org.dromara.system.domain.vo.SysDeptVo; +import org.dromara.system.service.SysDeptService; +import org.dromara.system.service.SysPostService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 部门信息 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/dept") +public class SysDeptController extends BaseController { + + private final SysDeptService deptService; + private final SysPostService postService; + + /** + * 获取部门列表 + */ + @SaCheckPermission("system:dept:list") + @GetMapping("/list") + public R> list(SysDeptBo dept) { + List depts = deptService.selectDeptList(dept); + return R.ok(depts); + } + + /** + * 查询部门列表(排除节点) + * + * @param deptId 部门ID + */ + @SaCheckPermission("system:dept:list") + @GetMapping("/list/exclude/{deptId}") + public R> excludeChild(@PathVariable(value = "deptId", required = false) Long deptId) { + List depts = deptService.selectDeptList(new SysDeptBo()); + depts.removeIf(d -> d.getDeptId().equals(deptId) + || StringUtils.splitList(d.getAncestors()).contains(Convert.toStr(deptId))); + return R.ok(depts); + } + + /** + * 根据部门编号获取详细信息 + * + * @param deptId 部门ID + */ + @SaCheckPermission("system:dept:query") + @GetMapping(value = "/{deptId}") + public R getInfo(@PathVariable Long deptId) { + deptService.checkDeptDataScope(deptId); + return R.ok(deptService.selectDeptById(deptId)); + } + + /** + * 新增部门 + */ + @SaCheckPermission("system:dept:add") + @Log(title = "部门管理", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysDeptBo dept) { + if (!deptService.checkDeptNameUnique(dept)) { + return R.fail("新增部门'" + dept.getDeptName() + "'失败,部门名称已存在"); + } + return toAjax(deptService.insertDept(dept)); + } + + /** + * 修改部门 + */ + @SaCheckPermission("system:dept:edit") + @Log(title = "部门管理", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysDeptBo dept) { + Long deptId = dept.getDeptId(); + deptService.checkDeptDataScope(deptId); + if (!deptService.checkDeptNameUnique(dept)) { + return R.fail("修改部门'" + dept.getDeptName() + "'失败,部门名称已存在"); + } else if (dept.getParentId().equals(deptId)) { + return R.fail("修改部门'" + dept.getDeptName() + "'失败,上级部门不能是自己"); + } else if (StringUtils.equals(SystemConstants.DISABLE, dept.getStatus())) { + if (deptService.selectNormalChildrenDeptById(deptId) > 0) { + return R.fail("该部门包含未停用的子部门!"); + } else if (deptService.checkDeptExistUser(deptId)) { + return R.fail("该部门下存在已分配用户,不能禁用!"); + } + } + return toAjax(deptService.updateDept(dept)); + } + + /** + * 删除部门 + * + * @param deptId 部门ID + */ + @SaCheckPermission("system:dept:remove") + @Log(title = "部门管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{deptId}") + public R remove(@PathVariable Long deptId) { + if (SystemConstants.DEFAULT_DEPT_ID.equals(deptId)) { + return R.warn("默认部门,不允许删除"); + } + if (deptService.hasChildByDeptId(deptId)) { + return R.warn("存在下级部门,不允许删除"); + } + if (deptService.checkDeptExistUser(deptId)) { + return R.warn("部门存在用户,不允许删除"); + } + if (postService.countPostByDeptId(deptId) > 0) { + return R.warn("部门存在岗位,不允许删除"); + } + deptService.checkDeptDataScope(deptId); + return toAjax(deptService.deleteDeptById(deptId)); + } + + /** + * 获取部门选择框列表 + * + * @param deptIds 部门ID串 + */ + @SaCheckPermission("system:dept:query") + @GetMapping("/optionselect") + public R> optionselect(@RequestParam(required = false) Long[] deptIds) { + return R.ok(deptService.selectDeptByIds(deptIds == null ? null : List.of(deptIds))); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDictDataController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDictDataController.java new file mode 100644 index 0000000..1acdc23 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDictDataController.java @@ -0,0 +1,124 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.ObjectUtil; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysDictDataBo; +import org.dromara.system.domain.vo.SysDictDataVo; +import org.dromara.system.service.SysDictDataService; +import org.dromara.system.service.SysDictTypeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * 数据字典信息 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/dict/data") +public class SysDictDataController extends BaseController { + + private final SysDictDataService dictDataService; + private final SysDictTypeService dictTypeService; + + /** + * 查询字典数据列表 + */ + @SaCheckPermission("system:dict:list") + @GetMapping("/list") + public TableDataInfo list(SysDictDataBo dictData, PageQuery pageQuery) { + return dictDataService.selectPageDictDataList(dictData, pageQuery); + } + + /** + * 导出字典数据列表 + */ + @Log(title = "字典数据", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:dict:export") + @PostMapping("/export") + public void export(SysDictDataBo dictData, HttpServletResponse response) { + List list = dictDataService.selectDictDataList(dictData); + ExcelUtil.exportExcel(list, "字典数据", SysDictDataVo.class, response); + } + + /** + * 查询字典数据详细 + * + * @param dictCode 字典code + */ + @SaCheckPermission("system:dict:query") + @GetMapping(value = "/{dictCode}") + public R getInfo(@PathVariable Long dictCode) { + return R.ok(dictDataService.selectDictDataById(dictCode)); + } + + /** + * 根据字典类型查询字典数据信息 + * + * @param dictType 字典类型 + */ + @GetMapping(value = "/type/{dictType}") + public R> dictType(@PathVariable String dictType) { + List data = dictTypeService.selectDictDataByType(dictType); + if (ObjectUtil.isNull(data)) { + data = new ArrayList<>(); + } + return R.ok(data); + } + + /** + * 新增字典类型 + */ + @SaCheckPermission("system:dict:add") + @Log(title = "字典数据", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysDictDataBo dict) { + if (!dictDataService.checkDictDataUnique(dict)) { + return R.fail("新增字典数据'" + dict.getDictValue() + "'失败,字典键值已存在"); + } + dictDataService.insertDictData(dict); + return R.ok(); + } + + /** + * 修改保存字典类型 + */ + @SaCheckPermission("system:dict:edit") + @Log(title = "字典数据", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysDictDataBo dict) { + if (!dictDataService.checkDictDataUnique(dict)) { + return R.fail("修改字典数据'" + dict.getDictValue() + "'失败,字典键值已存在"); + } + dictDataService.updateDictData(dict); + return R.ok(); + } + + /** + * 删除字典类型 + * + * @param dictCodes 字典code串 + */ + @SaCheckPermission("system:dict:remove") + @Log(title = "字典类型", businessType = BusinessType.DELETE) + @DeleteMapping("/{dictCodes}") + public R remove(@PathVariable Long[] dictCodes) { + dictDataService.deleteDictDataByIds(Arrays.asList(dictCodes)); + return R.ok(); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDictTypeController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDictTypeController.java new file mode 100644 index 0000000..106792b --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysDictTypeController.java @@ -0,0 +1,126 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysDictTypeBo; +import org.dromara.system.domain.vo.SysDictTypeVo; +import org.dromara.system.service.SysDictTypeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 数据字典信息 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/dict/type") +public class SysDictTypeController extends BaseController { + + private final SysDictTypeService dictTypeService; + + /** + * 查询字典类型列表 + */ + @SaCheckPermission("system:dict:list") + @GetMapping("/list") + public TableDataInfo list(SysDictTypeBo dictType, PageQuery pageQuery) { + return dictTypeService.selectPageDictTypeList(dictType, pageQuery); + } + + /** + * 导出字典类型列表 + */ + @Log(title = "字典类型", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:dict:export") + @PostMapping("/export") + public void export(SysDictTypeBo dictType, HttpServletResponse response) { + List list = dictTypeService.selectDictTypeList(dictType); + ExcelUtil.exportExcel(list, "字典类型", SysDictTypeVo.class, response); + } + + /** + * 查询字典类型详细 + * + * @param dictId 字典ID + */ + @SaCheckPermission("system:dict:query") + @GetMapping(value = "/{dictId}") + public R getInfo(@PathVariable Long dictId) { + return R.ok(dictTypeService.selectDictTypeById(dictId)); + } + + /** + * 新增字典类型 + */ + @SaCheckPermission("system:dict:add") + @Log(title = "字典类型", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysDictTypeBo dict) { + if (!dictTypeService.checkDictTypeUnique(dict)) { + return R.fail("新增字典'" + dict.getDictName() + "'失败,字典类型已存在"); + } + dictTypeService.insertDictType(dict); + return R.ok(); + } + + /** + * 修改字典类型 + */ + @SaCheckPermission("system:dict:edit") + @Log(title = "字典类型", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysDictTypeBo dict) { + if (!dictTypeService.checkDictTypeUnique(dict)) { + return R.fail("修改字典'" + dict.getDictName() + "'失败,字典类型已存在"); + } + dictTypeService.updateDictType(dict); + return R.ok(); + } + + /** + * 删除字典类型 + * + * @param dictIds 字典ID串 + */ + @SaCheckPermission("system:dict:remove") + @Log(title = "字典类型", businessType = BusinessType.DELETE) + @DeleteMapping("/{dictIds}") + public R remove(@PathVariable Long[] dictIds) { + dictTypeService.deleteDictTypeByIds(Arrays.asList(dictIds)); + return R.ok(); + } + + /** + * 刷新字典缓存 + */ + @SaCheckPermission("system:dict:remove") + @Log(title = "字典类型", businessType = BusinessType.CLEAN) + @DeleteMapping("/refreshCache") + public R refreshCache() { + dictTypeService.resetDictCache(); + return R.ok(); + } + + /** + * 获取字典选择框列表 + */ + @GetMapping("/optionselect") + public R> optionselect() { + List dictTypes = dictTypeService.selectDictTypeAll(); + return R.ok(dictTypes); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysMenuController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysMenuController.java new file mode 100644 index 0000000..35fe036 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysMenuController.java @@ -0,0 +1,171 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaCheckRole; +import cn.dev33.satoken.annotation.SaMode; +import cn.hutool.core.lang.tree.Tree; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.SysMenu; +import org.dromara.system.domain.bo.SysMenuBo; +import org.dromara.system.domain.vo.RouterVo; +import org.dromara.system.domain.vo.SysMenuVo; +import org.dromara.system.service.SysMenuService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 菜单信息 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/menu") +public class SysMenuController extends BaseController { + + private final SysMenuService menuService; + + /** + * 获取路由信息 + * + * @return 路由信息 + */ + @GetMapping("/getRouters") + public R> getRouters() { + List menus = menuService.selectMenuTreeByUserId(LoginHelper.getUserId()); + return R.ok(menuService.buildMenus(menus)); + } + + /** + * 获取菜单列表 + */ + @SaCheckRole(value = {SystemConstants.SUPER_ADMIN_ROLE_KEY}, mode = SaMode.OR) + @SaCheckPermission("system:menu:list") + @GetMapping("/list") + public R> list(SysMenuBo menu) { + List menus = menuService.selectMenuList(menu, LoginHelper.getUserId()); + return R.ok(menus); + } + + /** + * 根据菜单编号获取详细信息 + * + * @param menuId 菜单ID + */ + @SaCheckRole(value = {SystemConstants.SUPER_ADMIN_ROLE_KEY}, mode = SaMode.OR) + @SaCheckPermission("system:menu:query") + @GetMapping(value = "/{menuId}") + public R getInfo(@PathVariable Long menuId) { + return R.ok(menuService.selectMenuById(menuId)); + } + + /** + * 获取菜单下拉树列表 + */ + @SaCheckPermission("system:menu:query") + @GetMapping("/treeselect") + public R>> treeselect(SysMenuBo menu) { + List menus = menuService.selectMenuList(menu, LoginHelper.getUserId()); + return R.ok(menuService.buildMenuTreeSelect(menus)); + } + + /** + * 加载对应角色菜单列表树 + * + * @param roleId 角色ID + */ + @SaCheckPermission("system:menu:query") + @GetMapping(value = "/roleMenuTreeselect/{roleId}") + public R roleMenuTreeselect(@PathVariable("roleId") Long roleId) { + List menus = menuService.selectMenuList(LoginHelper.getUserId()); + MenuTreeSelectVo selectVo = new MenuTreeSelectVo( + menuService.selectMenuListByRoleId(roleId), + menuService.buildMenuTreeSelect(menus)); + return R.ok(selectVo); + } + + /** + * 新增菜单 + */ + @SaCheckRole(SystemConstants.SUPER_ADMIN_ROLE_KEY) + @SaCheckPermission("system:menu:add") + @Log(title = "菜单管理", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysMenuBo menu) { + if (!menuService.checkMenuNameUnique(menu)) { + return R.fail("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在"); + } else if (SystemConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath())) { + return R.fail("新增菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头"); + } + return toAjax(menuService.insertMenu(menu)); + } + + /** + * 修改菜单 + */ + @SaCheckRole(SystemConstants.SUPER_ADMIN_ROLE_KEY) + @SaCheckPermission("system:menu:edit") + @Log(title = "菜单管理", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysMenuBo menu) { + if (!menuService.checkMenuNameUnique(menu)) { + return R.fail("修改菜单'" + menu.getMenuName() + "'失败,菜单名称已存在"); + } else if (SystemConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath())) { + return R.fail("修改菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头"); + } else if (menu.getMenuId().equals(menu.getParentId())) { + return R.fail("修改菜单'" + menu.getMenuName() + "'失败,上级菜单不能选择自己"); + } + return toAjax(menuService.updateMenu(menu)); + } + + /** + * 删除菜单 + * + * @param menuId 菜单ID + */ + @SaCheckRole(SystemConstants.SUPER_ADMIN_ROLE_KEY) + @SaCheckPermission("system:menu:remove") + @Log(title = "菜单管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{menuId}") + public R remove(@PathVariable("menuId") Long menuId) { + if (menuService.hasChildByMenuId(menuId)) { + return R.warn("存在子菜单,不允许删除"); + } + if (menuService.checkMenuExistRole(menuId)) { + return R.warn("菜单已分配,不允许删除"); + } + return toAjax(menuService.deleteMenuById(menuId)); + } + + /** + * 批量级联删除菜单 + * + * @param menuIds 菜单ID串 + */ + @SaCheckRole(SystemConstants.SUPER_ADMIN_ROLE_KEY) + @SaCheckPermission("system:menu:remove") + @Log(title = "菜单管理", businessType = BusinessType.DELETE) + @DeleteMapping("/cascade/{menuIds}") + public R remove(@PathVariable("menuIds") Long[] menuIds) { + List menuIdList = List.of(menuIds); + if (menuService.hasChildByMenuId(menuIdList)) { + return R.warn("存在子菜单,不允许删除"); + } + menuService.deleteMenuById(menuIdList); + return R.ok(); + } + + public record MenuTreeSelectVo(List checkedKeys, List> menus) { + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysNoticeController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysNoticeController.java new file mode 100644 index 0000000..3f70e51 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysNoticeController.java @@ -0,0 +1,90 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.service.DictService; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.sse.utils.SseMessageUtils; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysNoticeBo; +import org.dromara.system.domain.vo.SysNoticeVo; +import org.dromara.system.service.SysNoticeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 公告 信息操作处理 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/notice") +public class SysNoticeController extends BaseController { + + private final SysNoticeService noticeService; + private final DictService dictService; + + /** + * 获取通知公告列表 + */ + @SaCheckPermission("system:notice:list") + @GetMapping("/list") + public TableDataInfo list(SysNoticeBo notice, PageQuery pageQuery) { + return noticeService.selectPageNoticeList(notice, pageQuery); + } + + /** + * 根据通知公告编号获取详细信息 + * + * @param noticeId 公告ID + */ + @SaCheckPermission("system:notice:query") + @GetMapping(value = "/{noticeId}") + public R getInfo(@PathVariable Long noticeId) { + return R.ok(noticeService.selectNoticeById(noticeId)); + } + + /** + * 新增通知公告 + */ + @SaCheckPermission("system:notice:add") + @Log(title = "通知公告", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysNoticeBo notice) { + int rows = noticeService.insertNotice(notice); + if (rows <= 0) { + return R.fail(); + } + String type = dictService.getDictLabel("sys_notice_type", notice.getNoticeType()); + SseMessageUtils.publishAll("[" + type + "] " + notice.getNoticeTitle()); + return R.ok(); + } + + /** + * 修改通知公告 + */ + @SaCheckPermission("system:notice:edit") + @Log(title = "通知公告", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysNoticeBo notice) { + return toAjax(noticeService.updateNotice(notice)); + } + + /** + * 删除通知公告 + * + * @param noticeIds 公告ID串 + */ + @SaCheckPermission("system:notice:remove") + @Log(title = "通知公告", businessType = BusinessType.DELETE) + @DeleteMapping("/{noticeIds}") + public R remove(@PathVariable Long[] noticeIds) { + return toAjax(noticeService.deleteNoticeByIds(noticeIds)); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssConfigController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssConfigController.java new file mode 100644 index 0000000..c2ea35f --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssConfigController.java @@ -0,0 +1,105 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.core.validate.QueryGroup; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysOssConfigBo; +import org.dromara.system.domain.vo.SysOssConfigVo; +import org.dromara.system.service.SysOssConfigService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 对象存储配置 + * + * @author pengles + * @author 孤舟烟雨 + * @date 2021-08-13 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/resource/oss/config") +public class SysOssConfigController extends BaseController { + + private final SysOssConfigService ossConfigService; + + /** + * 查询对象存储配置列表 + */ + @SaCheckPermission("system:ossConfig:list") + @GetMapping("/list") + public TableDataInfo list(@Validated(QueryGroup.class) SysOssConfigBo bo, PageQuery pageQuery) { + return ossConfigService.queryPageList(bo, pageQuery); + } + + /** + * 获取对象存储配置详细信息 + * + * @param ossConfigId OSS配置ID + */ + @SaCheckPermission("system:ossConfig:list") + @GetMapping("/{ossConfigId}") + public R getInfo(@NotNull(message = "主键不能为空") + @PathVariable Long ossConfigId) { + return R.ok(ossConfigService.queryById(ossConfigId)); + } + + /** + * 新增对象存储配置 + */ + @SaCheckPermission("system:ossConfig:add") + @Log(title = "对象存储配置", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody SysOssConfigBo bo) { + return toAjax(ossConfigService.insertByBo(bo)); + } + + /** + * 修改对象存储配置 + */ + @SaCheckPermission("system:ossConfig:edit") + @Log(title = "对象存储配置", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody SysOssConfigBo bo) { + return toAjax(ossConfigService.updateByBo(bo)); + } + + /** + * 删除对象存储配置 + * + * @param ossConfigIds OSS配置ID串 + */ + @SaCheckPermission("system:ossConfig:remove") + @Log(title = "对象存储配置", businessType = BusinessType.DELETE) + @DeleteMapping("/{ossConfigIds}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable Long[] ossConfigIds) { + return toAjax(ossConfigService.deleteWithValidByIds(List.of(ossConfigIds), true)); + } + + /** + * 状态修改 + */ + @SaCheckPermission("system:ossConfig:edit") + @Log(title = "对象存储状态修改", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus") + public R changeStatus(@RequestBody SysOssConfigBo bo) { + return toAjax(ossConfigService.updateOssConfigStatus(bo)); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssController.java new file mode 100644 index 0000000..6265af9 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssController.java @@ -0,0 +1,108 @@ +package org.dromara.system.controller.system; + + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.ObjectUtil; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.QueryGroup; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysOssBo; +import org.dromara.system.domain.vo.SysOssUploadVo; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.SysOssService; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * 文件上传 控制层 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/resource/oss") +public class SysOssController extends BaseController { + + private final SysOssService ossService; + + /** + * 查询OSS对象存储列表 + */ + @SaCheckPermission("system:oss:list") + @GetMapping("/list") + public TableDataInfo list(@Validated(QueryGroup.class) SysOssBo bo, PageQuery pageQuery) { + return ossService.queryPageList(bo, pageQuery); + } + + /** + * 查询OSS对象基于id串 + * + * @param ossIds OSS对象ID串 + */ + @SaCheckPermission("system:oss:query") + @GetMapping("/listByIds/{ossIds}") + public R> listByIds(@NotEmpty(message = "主键不能为空") + @PathVariable Long[] ossIds) { + List list = ossService.listByIds(Arrays.asList(ossIds)); + return R.ok(list); + } + + /** + * 上传OSS对象存储 + * + * @param file 文件 + */ + @SaCheckPermission("system:oss:upload") + @Log(title = "OSS对象存储", businessType = BusinessType.INSERT) + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R upload(@RequestPart("file") MultipartFile file) { + if (ObjectUtil.isNull(file)) { + return R.fail("上传文件不能为空"); + } + SysOssVo oss = ossService.upload(file); + SysOssUploadVo uploadVo = new SysOssUploadVo(); + uploadVo.setUrl(oss.getUrl()); + uploadVo.setFileName(oss.getOriginalName()); + uploadVo.setOssId(oss.getOssId().toString()); + return R.ok(uploadVo); + } + + /** + * 下载OSS对象 + * + * @param ossId OSS对象ID + */ + @SaCheckPermission("system:oss:download") + @GetMapping("/download/{ossId}") + public void download(@PathVariable Long ossId, HttpServletResponse response) throws IOException { + ossService.download(ossId, response); + } + + /** + * 删除OSS对象存储 + * + * @param ossIds OSS对象ID串 + */ + @SaCheckPermission("system:oss:remove") + @Log(title = "OSS对象存储", businessType = BusinessType.DELETE) + @DeleteMapping("/{ossIds}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable Long[] ossIds) { + return toAjax(ossService.deleteWithValidByIds(List.of(ossIds), true)); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysPostController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysPostController.java new file mode 100644 index 0000000..26fc72a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysPostController.java @@ -0,0 +1,133 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.ObjectUtil; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysPostBo; +import org.dromara.system.domain.vo.SysPostVo; +import org.dromara.system.service.SysPostService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + +/** + * 岗位信息操作处理 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/post") +public class SysPostController extends BaseController { + + private final SysPostService postService; + + /** + * 获取岗位列表 + */ + @SaCheckPermission("system:post:list") + @GetMapping("/list") + public TableDataInfo list(SysPostBo post, PageQuery pageQuery) { + return postService.selectPagePostList(post, pageQuery); + } + + /** + * 导出岗位列表 + */ + @Log(title = "岗位管理", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:post:export") + @PostMapping("/export") + public void export(SysPostBo post, HttpServletResponse response) { + List list = postService.selectPostList(post); + ExcelUtil.exportExcel(list, "岗位数据", SysPostVo.class, response); + } + + /** + * 根据岗位编号获取详细信息 + * + * @param postId 岗位ID + */ + @SaCheckPermission("system:post:query") + @GetMapping(value = "/{postId}") + public R getInfo(@PathVariable Long postId) { + return R.ok(postService.selectPostById(postId)); + } + + /** + * 新增岗位 + */ + @SaCheckPermission("system:post:add") + @Log(title = "岗位管理", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysPostBo post) { + if (!postService.checkPostNameUnique(post)) { + return R.fail("新增岗位'" + post.getPostName() + "'失败,岗位名称已存在"); + } else if (!postService.checkPostCodeUnique(post)) { + return R.fail("新增岗位'" + post.getPostName() + "'失败,岗位编码已存在"); + } + return toAjax(postService.insertPost(post)); + } + + /** + * 修改岗位 + */ + @SaCheckPermission("system:post:edit") + @Log(title = "岗位管理", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysPostBo post) { + if (!postService.checkPostNameUnique(post)) { + return R.fail("修改岗位'" + post.getPostName() + "'失败,岗位名称已存在"); + } else if (!postService.checkPostCodeUnique(post)) { + return R.fail("修改岗位'" + post.getPostName() + "'失败,岗位编码已存在"); + } else if (SystemConstants.DISABLE.equals(post.getStatus()) + && postService.countUserPostById(post.getPostId()) > 0) { + return R.fail("该岗位下存在已分配用户,不能禁用!"); + } + return toAjax(postService.updatePost(post)); + } + + /** + * 删除岗位 + * + * @param postIds 岗位ID串 + */ + @SaCheckPermission("system:post:remove") + @Log(title = "岗位管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{postIds}") + public R remove(@PathVariable Long[] postIds) { + return toAjax(postService.deletePostByIds(postIds)); + } + + /** + * 获取岗位选择框列表 + * + * @param postIds 岗位ID串 + * @param deptId 部门id + */ + @SaCheckPermission("system:post:query") + @GetMapping("/optionselect") + public R> optionselect(@RequestParam(required = false) Long[] postIds, @RequestParam(required = false) Long deptId) { + List list = new ArrayList<>(); + if (ObjectUtil.isNotNull(deptId)) { + SysPostBo post = new SysPostBo(); + post.setDeptId(deptId); + list = postService.selectPostList(post); + } else if (postIds != null) { + list = postService.selectPostByIds(List.of(postIds)); + } + return R.ok(list); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysProfileController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysProfileController.java new file mode 100644 index 0000000..194ef35 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysProfileController.java @@ -0,0 +1,135 @@ +package org.dromara.system.controller.system; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.crypto.digest.BCrypt; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.file.MimeTypeUtils; +import org.dromara.common.encrypt.annotation.ApiEncrypt; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.helper.DataPermissionHelper; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysUserBo; +import org.dromara.system.domain.bo.SysUserPasswordBo; +import org.dromara.system.domain.bo.SysUserProfileBo; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.service.SysOssService; +import org.dromara.system.service.SysUserService; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Arrays; + +/** + * 个人信息 业务处理 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/user/profile") +public class SysProfileController extends BaseController { + + private final SysUserService userService; + private final SysOssService ossService; + + /** + * 个人信息 + */ + @GetMapping + public R profile() { + SysUserVo user = userService.selectUserById(LoginHelper.getUserId()); + String roleGroup = userService.selectUserRoleGroup(user.getUserId()); + String postGroup = userService.selectUserPostGroup(user.getUserId()); + ProfileVo profileVo = new ProfileVo(user, roleGroup, postGroup); + return R.ok(profileVo); + } + + /** + * 修改用户信息 + */ + @RepeatSubmit + @Log(title = "个人信息", businessType = BusinessType.UPDATE) + @PutMapping + public R updateProfile(@Validated @RequestBody SysUserProfileBo profile) { + SysUserBo user = BeanUtil.toBean(profile, SysUserBo.class); + user.setUserId(LoginHelper.getUserId()); + String username = LoginHelper.getUsername(); + if (StringUtils.isNotEmpty(user.getPhonenumber()) && !userService.checkPhoneUnique(user)) { + return R.fail("修改用户'" + username + "'失败,手机号码已存在"); + } + if (StringUtils.isNotEmpty(user.getEmail()) && !userService.checkEmailUnique(user)) { + return R.fail("修改用户'" + username + "'失败,邮箱账号已存在"); + } + int rows = DataPermissionHelper.ignore(() -> userService.updateUserProfile(user)); + if (rows > 0) { + return R.ok(); + } + return R.fail("修改个人信息异常,请联系管理员"); + } + + /** + * 重置密码 + * + * @param bo 新旧密码 + */ + @RepeatSubmit + @ApiEncrypt + @Log(title = "个人信息", businessType = BusinessType.UPDATE) + @PutMapping("/updatePwd") + public R updatePwd(@Validated @RequestBody SysUserPasswordBo bo) { + SysUserVo user = userService.selectUserById(LoginHelper.getUserId()); + String password = user.getPassword(); + if (!BCrypt.checkpw(bo.getOldPassword(), password)) { + return R.fail("修改密码失败,旧密码错误"); + } + if (BCrypt.checkpw(bo.getNewPassword(), password)) { + return R.fail("新密码不能与旧密码相同"); + } + int rows = DataPermissionHelper.ignore(() -> userService.resetUserPwd(user.getUserId(), BCrypt.hashpw(bo.getNewPassword()))); + if (rows > 0) { + return R.ok(); + } + return R.fail("修改密码异常,请联系管理员"); + } + + /** + * 头像上传 + * + * @param avatarfile 用户头像 + */ + @RepeatSubmit + @Log(title = "用户头像", businessType = BusinessType.UPDATE) + @PostMapping(value = "/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R avatar(@RequestPart("avatarfile") MultipartFile avatarfile) { + if (!avatarfile.isEmpty()) { + String extension = FileUtil.extName(avatarfile.getOriginalFilename()); + if (!StringUtils.equalsAnyIgnoreCase(extension, MimeTypeUtils.IMAGE_EXTENSION)) { + return R.fail("文件格式不正确,请上传" + Arrays.toString(MimeTypeUtils.IMAGE_EXTENSION) + "格式"); + } + SysOssVo oss = ossService.upload(avatarfile); + String avatar = oss.getUrl(); + boolean updateSuccess = DataPermissionHelper.ignore(() -> userService.updateUserAvatar(LoginHelper.getUserId(), oss.getOssId())); + if (updateSuccess) { + return R.ok(new AvatarVo(avatar)); + } + } + return R.fail("上传图片异常,请联系管理员"); + } + + public record AvatarVo(String imgUrl) { + } + + public record ProfileVo(SysUserVo user, String roleGroup, String postGroup) { + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysRoleController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysRoleController.java new file mode 100644 index 0000000..15e7a73 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysRoleController.java @@ -0,0 +1,233 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.lang.tree.Tree; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.SysUserRole; +import org.dromara.system.domain.bo.SysDeptBo; +import org.dromara.system.domain.bo.SysRoleBo; +import org.dromara.system.domain.bo.SysUserBo; +import org.dromara.system.domain.vo.SysRoleVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.service.SysDeptService; +import org.dromara.system.service.SysRoleService; +import org.dromara.system.service.SysUserService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 角色信息 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/role") +public class SysRoleController extends BaseController { + + private final SysRoleService roleService; + private final SysUserService userService; + private final SysDeptService deptService; + + /** + * 获取角色信息列表 + */ + @SaCheckPermission("system:role:list") + @GetMapping("/list") + public TableDataInfo list(SysRoleBo role, PageQuery pageQuery) { + return roleService.selectPageRoleList(role, pageQuery); + } + + /** + * 导出角色信息列表 + */ + @Log(title = "角色管理", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:role:export") + @PostMapping("/export") + public void export(SysRoleBo role, HttpServletResponse response) { + List list = roleService.selectRoleList(role); + ExcelUtil.exportExcel(list, "角色数据", SysRoleVo.class, response); + } + + /** + * 根据角色编号获取详细信息 + * + * @param roleId 角色ID + */ + @SaCheckPermission("system:role:query") + @GetMapping(value = "/{roleId}") + public R getInfo(@PathVariable Long roleId) { + roleService.checkRoleDataScope(roleId); + return R.ok(roleService.selectRoleById(roleId)); + } + + /** + * 新增角色 + */ + @SaCheckPermission("system:role:add") + @Log(title = "角色管理", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysRoleBo role) { + roleService.checkRoleAllowed(role); + if (!roleService.checkRoleNameUnique(role)) { + return R.fail("新增角色'" + role.getRoleName() + "'失败,角色名称已存在"); + } else if (!roleService.checkRoleKeyUnique(role)) { + return R.fail("新增角色'" + role.getRoleName() + "'失败,角色权限已存在"); + } + return toAjax(roleService.insertRole(role)); + + } + + /** + * 修改保存角色 + */ + @SaCheckPermission("system:role:edit") + @Log(title = "角色管理", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysRoleBo role) { + roleService.checkRoleAllowed(role); + roleService.checkRoleDataScope(role.getRoleId()); + if (!roleService.checkRoleNameUnique(role)) { + return R.fail("修改角色'" + role.getRoleName() + "'失败,角色名称已存在"); + } else if (!roleService.checkRoleKeyUnique(role)) { + return R.fail("修改角色'" + role.getRoleName() + "'失败,角色权限已存在"); + } + + if (roleService.updateRole(role) > 0) { + roleService.cleanOnlineUserByRole(role.getRoleId()); + return R.ok(); + } + return R.fail("修改角色'" + role.getRoleName() + "'失败,请联系管理员"); + } + + /** + * 修改保存数据权限 + */ + @SaCheckPermission("system:role:edit") + @Log(title = "角色管理", businessType = BusinessType.UPDATE) + @PutMapping("/dataScope") + public R dataScope(@RequestBody SysRoleBo role) { + roleService.checkRoleAllowed(role); + roleService.checkRoleDataScope(role.getRoleId()); + return toAjax(roleService.authDataScope(role)); + } + + /** + * 状态修改 + */ + @SaCheckPermission("system:role:edit") + @Log(title = "角色管理", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus") + public R changeStatus(@RequestBody SysRoleBo role) { + roleService.checkRoleAllowed(role); + roleService.checkRoleDataScope(role.getRoleId()); + return toAjax(roleService.updateRoleStatus(role.getRoleId(), role.getStatus())); + } + + /** + * 删除角色 + * + * @param roleIds 角色ID串 + */ + @SaCheckPermission("system:role:remove") + @Log(title = "角色管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{roleIds}") + public R remove(@PathVariable Long[] roleIds) { + return toAjax(roleService.deleteRoleByIds(roleIds)); + } + + /** + * 获取角色选择框列表 + * + * @param roleIds 角色ID串 + */ + @SaCheckPermission("system:role:query") + @GetMapping("/optionselect") + public R> optionselect(@RequestParam(required = false) Long[] roleIds) { + return R.ok(roleService.selectRoleByIds(roleIds == null ? null : List.of(roleIds))); + } + + /** + * 查询已分配用户角色列表 + */ + @SaCheckPermission("system:role:list") + @GetMapping("/authUser/allocatedList") + public TableDataInfo allocatedList(SysUserBo user, PageQuery pageQuery) { + return userService.selectAllocatedList(user, pageQuery); + } + + /** + * 查询未分配用户角色列表 + */ + @SaCheckPermission("system:role:list") + @GetMapping("/authUser/unallocatedList") + public TableDataInfo unallocatedList(SysUserBo user, PageQuery pageQuery) { + return userService.selectUnallocatedList(user, pageQuery); + } + + /** + * 取消授权用户 + */ + @SaCheckPermission("system:role:edit") + @Log(title = "角色管理", businessType = BusinessType.GRANT) + @PutMapping("/authUser/cancel") + public R cancelAuthUser(@RequestBody SysUserRole userRole) { + return toAjax(roleService.deleteAuthUser(userRole)); + } + + /** + * 批量取消授权用户 + * + * @param roleId 角色ID + * @param userIds 用户ID串 + */ + @SaCheckPermission("system:role:edit") + @Log(title = "角色管理", businessType = BusinessType.GRANT) + @PutMapping("/authUser/cancelAll") + public R cancelAuthUserAll(Long roleId, Long[] userIds) { + return toAjax(roleService.deleteAuthUsers(roleId, userIds)); + } + + /** + * 批量选择用户授权 + * + * @param roleId 角色ID + * @param userIds 用户ID串 + */ + @SaCheckPermission("system:role:edit") + @Log(title = "角色管理", businessType = BusinessType.GRANT) + @PutMapping("/authUser/selectAll") + public R selectAuthUserAll(Long roleId, Long[] userIds) { + roleService.checkRoleDataScope(roleId); + return toAjax(roleService.insertAuthUsers(roleId, userIds)); + } + + /** + * 获取对应角色部门树列表 + * + * @param roleId 角色ID + */ + @SaCheckPermission("system:role:list") + @GetMapping(value = "/deptTree/{roleId}") + public R roleDeptTreeselect(@PathVariable("roleId") Long roleId) { + DeptTreeSelectVo selectVo = new DeptTreeSelectVo( + deptService.selectDeptListByRoleId(roleId), + deptService.selectDeptTreeList(new SysDeptBo())); + return R.ok(selectVo); + } + + public record DeptTreeSelectVo(List checkedKeys, List> depts) { + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysSocialController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysSocialController.java new file mode 100644 index 0000000..4145859 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysSocialController.java @@ -0,0 +1,38 @@ +package org.dromara.system.controller.system; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.vo.SysSocialVo; +import org.dromara.system.service.SysSocialService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 社会化关系 + * + * @author thiszhc + * @date 2023-06-16 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/social") +public class SysSocialController extends BaseController { + + private final SysSocialService socialUserService; + + /** + * 查询社会化关系列表 + */ + @GetMapping("/list") + public R> list() { + return R.ok(socialUserService.queryListByUserId(LoginHelper.getUserId())); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysUserController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysUserController.java new file mode 100644 index 0000000..b4dcaa3 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysUserController.java @@ -0,0 +1,292 @@ +package org.dromara.system.controller.system; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.lang.tree.Tree; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.crypto.digest.BCrypt; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.encrypt.annotation.ApiEncrypt; +import org.dromara.common.excel.core.ExcelResult; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.SysDeptBo; +import org.dromara.system.domain.bo.SysPostBo; +import org.dromara.system.domain.bo.SysRoleBo; +import org.dromara.system.domain.bo.SysUserBo; +import org.dromara.system.domain.vo.*; +import org.dromara.system.listener.SysUserImportListener; +import org.dromara.system.service.SysDeptService; +import org.dromara.system.service.SysPostService; +import org.dromara.system.service.SysRoleService; +import org.dromara.system.service.SysUserService; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; + +/** + * 用户信息 + * + * @author pengles + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/user") +public class SysUserController extends BaseController { + + private final SysUserService userService; + private final SysRoleService roleService; + private final SysPostService postService; + private final SysDeptService deptService; + + /** + * 获取用户列表 + */ + @SaCheckPermission("system:user:list") + @GetMapping("/list") + public TableDataInfo list(SysUserBo user, PageQuery pageQuery) { + return userService.selectPageUserList(user, pageQuery); + } + + /** + * 导出用户列表 + */ + @Log(title = "用户管理", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:user:export") + @PostMapping("/export") + public void export(SysUserBo user, HttpServletResponse response) { + List list = userService.selectUserExportList(user); + ExcelUtil.exportExcel(list, "用户数据", SysUserExportVo.class, response); + } + + /** + * 导入数据 + * + * @param file 导入文件 + * @param updateSupport 是否更新已存在数据 + */ + @Log(title = "用户管理", businessType = BusinessType.IMPORT) + @SaCheckPermission("system:user:import") + @PostMapping(value = "/importData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R importData(@RequestPart("file") MultipartFile file, boolean updateSupport) throws Exception { + ExcelResult result = ExcelUtil.importExcel(file.getInputStream(), SysUserImportVo.class, new SysUserImportListener(updateSupport)); + return R.ok(result.getAnalysis()); + } + + /** + * 获取导入模板 + */ + @PostMapping("/importTemplate") + public void importTemplate(HttpServletResponse response) { + ExcelUtil.exportExcel(new ArrayList<>(), "用户数据", SysUserImportVo.class, response); + } + + /** + * 获取用户信息 + * + * @return 用户信息 + */ + @GetMapping("/getInfo") + public R getInfo() { + UserInfoVo userInfoVo = new UserInfoVo(); + LoginUser loginUser = LoginHelper.getLoginUser(); + SysUserVo user = userService.selectUserById(loginUser.getUserId()); + if (ObjectUtil.isNull(user)) { + return R.fail("没有权限访问用户数据!"); + } + userInfoVo.setUser(user); + userInfoVo.setPermissions(loginUser.getMenuPermission()); + userInfoVo.setRoles(loginUser.getRolePermission()); + return R.ok(userInfoVo); + } + + /** + * 根据用户编号获取详细信息 + * + * @param userId 用户ID + */ + @SaCheckPermission("system:user:query") + @GetMapping(value = {"/", "/{userId}"}) + public R getInfo(@PathVariable(value = "userId", required = false) Long userId) { + SysUserInfoVo userInfoVo = new SysUserInfoVo(); + if (ObjectUtil.isNotNull(userId)) { + userService.checkUserDataScope(userId); + SysUserVo sysUser = userService.selectUserById(userId); + userInfoVo.setUser(sysUser); + userInfoVo.setRoleIds(roleService.selectRoleListByUserId(userId)); + Long deptId = sysUser.getDeptId(); + if (ObjectUtil.isNotNull(deptId)) { + SysPostBo postBo = new SysPostBo(); + postBo.setDeptId(deptId); + userInfoVo.setPosts(postService.selectPostList(postBo)); + userInfoVo.setPostIds(postService.selectPostListByUserId(userId)); + } + } + SysRoleBo roleBo = new SysRoleBo(); + roleBo.setStatus(SystemConstants.NORMAL); + List roles = roleService.selectRoleList(roleBo); + userInfoVo.setRoles(LoginHelper.isSuperAdmin(userId) ? roles : StreamUtils.filter(roles, r -> !r.isSuperAdmin())); + return R.ok(userInfoVo); + } + + /** + * 新增用户 + */ + @SaCheckPermission("system:user:add") + @Log(title = "用户管理", businessType = BusinessType.INSERT) + @PostMapping + public R add(@Validated @RequestBody SysUserBo user) { + deptService.checkDeptDataScope(user.getDeptId()); + if (!userService.checkUserNameUnique(user)) { + return R.fail("新增用户'" + user.getUserName() + "'失败,登录账号已存在"); + } else if (StringUtils.isNotEmpty(user.getPhonenumber()) && !userService.checkPhoneUnique(user)) { + return R.fail("新增用户'" + user.getUserName() + "'失败,手机号码已存在"); + } else if (StringUtils.isNotEmpty(user.getEmail()) && !userService.checkEmailUnique(user)) { + return R.fail("新增用户'" + user.getUserName() + "'失败,邮箱账号已存在"); + } + user.setPassword(BCrypt.hashpw(user.getPassword())); + return toAjax(userService.insertUser(user)); + } + + /** + * 修改用户 + */ + @SaCheckPermission("system:user:edit") + @Log(title = "用户管理", businessType = BusinessType.UPDATE) + @PutMapping + public R edit(@Validated @RequestBody SysUserBo user) { + userService.checkUserAllowed(user.getUserId()); + userService.checkUserDataScope(user.getUserId()); + deptService.checkDeptDataScope(user.getDeptId()); + if (!userService.checkUserNameUnique(user)) { + return R.fail("修改用户'" + user.getUserName() + "'失败,登录账号已存在"); + } else if (StringUtils.isNotEmpty(user.getPhonenumber()) && !userService.checkPhoneUnique(user)) { + return R.fail("修改用户'" + user.getUserName() + "'失败,手机号码已存在"); + } else if (StringUtils.isNotEmpty(user.getEmail()) && !userService.checkEmailUnique(user)) { + return R.fail("修改用户'" + user.getUserName() + "'失败,邮箱账号已存在"); + } + return toAjax(userService.updateUser(user)); + } + + /** + * 删除用户 + * + * @param userIds 角色ID串 + */ + @SaCheckPermission("system:user:remove") + @Log(title = "用户管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{userIds}") + public R remove(@PathVariable Long[] userIds) { + if (ArrayUtil.contains(userIds, LoginHelper.getUserId())) { + return R.fail("当前用户不能删除"); + } + return toAjax(userService.deleteUserByIds(userIds)); + } + + /** + * 根据用户ID串批量获取用户基础信息 + * + * @param userIds 用户ID串 + * @param deptId 部门ID + */ + @SaCheckPermission("system:user:query") + @GetMapping("/optionselect") + public R> optionselect(@RequestParam(required = false) Long[] userIds, + @RequestParam(required = false) Long deptId) { + return R.ok(userService.selectUserByIds(ArrayUtil.isEmpty(userIds) ? null : List.of(userIds), deptId)); + } + + /** + * 重置密码 + */ + @ApiEncrypt + @SaCheckPermission("system:user:resetPwd") + @Log(title = "用户管理", businessType = BusinessType.UPDATE) + @PutMapping("/resetPwd") + public R resetPwd(@RequestBody SysUserBo user) { + userService.checkUserAllowed(user.getUserId()); + userService.checkUserDataScope(user.getUserId()); + user.setPassword(BCrypt.hashpw(user.getPassword())); + return toAjax(userService.resetUserPwd(user.getUserId(), user.getPassword())); + } + + /** + * 状态修改 + */ + @SaCheckPermission("system:user:edit") + @Log(title = "用户管理", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus") + public R changeStatus(@RequestBody SysUserBo user) { + userService.checkUserAllowed(user.getUserId()); + userService.checkUserDataScope(user.getUserId()); + return toAjax(userService.updateUserStatus(user.getUserId(), user.getStatus())); + } + + /** + * 根据用户编号获取授权角色 + * + * @param userId 用户ID + */ + @SaCheckPermission("system:user:query") + @GetMapping("/authRole/{userId}") + public R authRole(@PathVariable Long userId) { + userService.checkUserDataScope(userId); + SysUserVo user = userService.selectUserById(userId); + List roles = roleService.selectRolesAuthByUserId(userId); + SysUserInfoVo userInfoVo = new SysUserInfoVo(); + userInfoVo.setUser(user); + userInfoVo.setRoles(LoginHelper.isSuperAdmin(userId) ? roles : StreamUtils.filter(roles, r -> !r.isSuperAdmin())); + return R.ok(userInfoVo); + } + + /** + * 用户授权角色 + * + * @param userId 用户Id + * @param roleIds 角色ID串 + */ + @SaCheckPermission("system:user:edit") + @Log(title = "用户管理", businessType = BusinessType.GRANT) + @PutMapping("/authRole") + public R insertAuthRole(Long userId, Long[] roleIds) { + userService.checkUserDataScope(userId); + userService.insertUserAuth(userId, roleIds); + return R.ok(); + } + + /** + * 获取部门树列表 + */ + @SaCheckPermission("system:user:list") + @GetMapping("/deptTree") + public R>> deptTree(SysDeptBo dept) { + return R.ok(deptService.selectDeptTreeList(dept)); + } + + /** + * 获取部门下的所有用户信息 + */ + @SaCheckPermission("system:user:list") + @GetMapping("/list/dept/{deptId}") + public R> listByDept(@PathVariable @NotNull Long deptId) { + return R.ok(userService.selectUserListByDept(deptId)); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysCache.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysCache.java new file mode 100644 index 0000000..431beeb --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysCache.java @@ -0,0 +1,47 @@ +package org.dromara.system.domain; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.dromara.common.core.utils.StringUtils; + +/** + * 缓存信息 + * + * @author pengles + */ +@Data +@NoArgsConstructor +public class SysCache { + + /** + * 缓存名称 + */ + private String cacheName = ""; + + /** + * 缓存键名 + */ + private String cacheKey = ""; + + /** + * 缓存内容 + */ + private String cacheValue = ""; + + /** + * 备注 + */ + private String remark = ""; + + public SysCache(String cacheName, String remark) { + this.cacheName = cacheName; + this.remark = remark; + } + + public SysCache(String cacheName, String cacheKey, String cacheValue) { + this.cacheName = StringUtils.replace(cacheName, ":", ""); + this.cacheKey = StringUtils.replace(cacheKey, cacheName, ""); + this.cacheValue = cacheValue; + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysClient.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysClient.java new file mode 100644 index 0000000..aaeeeae --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysClient.java @@ -0,0 +1,79 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +/** + * 授权管理对象 sys_client + * + * @author pengles + * @date 2023-05-15 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_client") +public class SysClient extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * id + */ + @TableId(value = "id") + private Long id; + + /** + * 客户端id + */ + private String clientId; + + /** + * 客户端key + */ + private String clientKey; + + /** + * 客户端秘钥 + */ + private String clientSecret; + + /** + * 授权类型 + */ + private String grantType; + + /** + * 设备类型 + */ + private String deviceType; + + /** + * token活跃超时时间 + */ + private Long activeTimeout; + + /** + * token固定超时时间 + */ + private Long timeout; + + /** + * 状态(0正常 1停用) + */ + private String status; + + /** + * 删除标志(0代表存在 1代表删除) + */ + @TableLogic + private String delFlag; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysConfig.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysConfig.java new file mode 100644 index 0000000..8f04bc6 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysConfig.java @@ -0,0 +1,50 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +/** + * 参数配置表 sys_config + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_config") +public class SysConfig extends BaseEntity { + + /** + * 参数主键 + */ + @TableId(value = "config_id") + private Long configId; + + /** + * 参数名称 + */ + private String configName; + + /** + * 参数键名 + */ + private String configKey; + + /** + * 参数键值 + */ + private String configValue; + + /** + * 系统内置(Y是 N否) + */ + private String configType; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDept.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDept.java new file mode 100644 index 0000000..5bc6d13 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDept.java @@ -0,0 +1,92 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.List; + +/** + * 部门表 sys_dept + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_dept") +public class SysDept extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 部门ID + */ + @TableId(value = "dept_id") + private Long deptId; + + /** + * 父部门ID + */ + private Long parentId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 部门类别编码 + */ + private String deptCategory; + + /** + * 显示顺序 + */ + private Integer orderNum; + + /** + * 负责人 + */ + private Long leader; + + /** + * 联系电话 + */ + private String phone; + + /** + * 邮箱 + */ + private String email; + + /** + * 部门状态:0正常,1停用 + */ + private String status; + + /** + * 删除标志(0代表存在 1代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 祖级列表 + */ + private String ancestors; + + /** + * 子部门 + */ + @TableField(exist = false) + private List children = new ArrayList<>(); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDictData.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDictData.java new file mode 100644 index 0000000..200ef7b --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDictData.java @@ -0,0 +1,71 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +/** + * 字典数据表 sys_dict_data + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_dict_data") +public class SysDictData extends BaseEntity { + + /** + * 字典编码 + */ + @TableId(value = "dict_code") + private Long dictCode; + + /** + * 字典排序 + */ + private Integer dictSort; + + /** + * 字典标签 + */ + private String dictLabel; + + /** + * 字典键值 + */ + private String dictValue; + + /** + * 字典类型 + */ + private String dictType; + + /** + * 样式属性(其他样式扩展) + */ + private String cssClass; + + /** + * 表格字典样式 + */ + private String listClass; + + /** + * 是否默认(Y是 N否) + */ + private String isDefault; + + /** + * 备注 + */ + private String remark; + + public boolean getDefault() { + return SystemConstants.YES.equals(this.isDefault); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDictType.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDictType.java new file mode 100644 index 0000000..defbd78 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysDictType.java @@ -0,0 +1,41 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +/** + * 字典类型表 sys_dict_type + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_dict_type") +public class SysDictType extends BaseEntity { + + /** + * 字典主键 + */ + @TableId(value = "dict_id") + private Long dictId; + + /** + * 字典名称 + */ + private String dictName; + + /** + * 字典类型 + */ + private String dictType; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysLogininfor.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysLogininfor.java new file mode 100644 index 0000000..942c239 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysLogininfor.java @@ -0,0 +1,80 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 系统访问记录表 sys_logininfor + * + * @author pengles + */ + +@Data +@TableName("sys_logininfor") +public class SysLogininfor implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * ID + */ + @TableId(value = "info_id") + private Long infoId; + + /** + * 用户账号 + */ + private String userName; + + /** + * 客户端 + */ + private String clientKey; + + /** + * 设备类型 + */ + private String deviceType; + + /** + * 登录状态 0成功 1失败 + */ + private String status; + + /** + * 登录IP地址 + */ + private String ipaddr; + + /** + * 登录地点 + */ + private String loginLocation; + + /** + * 浏览器类型 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; + + /** + * 提示消息 + */ + private String msg; + + /** + * 访问时间 + */ + private Date loginTime; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysMenu.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysMenu.java new file mode 100644 index 0000000..4a2efbd --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysMenu.java @@ -0,0 +1,191 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.util.ArrayList; +import java.util.List; + +/** + * 菜单权限表 sys_menu + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_menu") +public class SysMenu extends BaseEntity { + + /** + * 菜单ID + */ + @TableId(value = "menu_id") + private Long menuId; + + /** + * 父菜单ID + */ + private Long parentId; + + /** + * 菜单名称 + */ + private String menuName; + + /** + * 显示顺序 + */ + private Integer orderNum; + + /** + * 路由地址 + */ + private String path; + + /** + * 组件路径 + */ + private String component; + + /** + * 路由参数 + */ + private String queryParam; + + /** + * 是否为外链(0是 1否) + */ + private String isFrame; + + /** + * 是否缓存(0缓存 1不缓存) + */ + private String isCache; + + /** + * 类型(M目录 C菜单 F按钮) + */ + private String menuType; + + /** + * 显示状态(0显示 1隐藏) + */ + private String visible; + + /** + * 菜单状态(0正常 1停用) + */ + private String status; + + /** + * 权限字符串 + */ + private String perms; + + /** + * 菜单图标 + */ + private String icon; + + /** + * 备注 + */ + private String remark; + + /** + * 父菜单名称 + */ + @TableField(exist = false) + private String parentName; + + /** + * 子菜单 + */ + @TableField(exist = false) + private List children = new ArrayList<>(); + + /** + * 内链域名特殊字符替换 + */ + public static String innerLinkReplaceEach(String path) { + return StringUtils.replaceEach(path, new String[]{Constants.HTTP, Constants.HTTPS, Constants.WWW, ".", ":"}, + new String[]{"", "", "", "/", "/"}); + } + + /** + * 获取路由名称 + */ + public String getRouteName() { + String routerName = StringUtils.capitalize(path); + // 非外链并且是一级目录(类型为目录) + if (isMenuFrame()) { + routerName = StringUtils.EMPTY; + } + return routerName; + } + + /** + * 获取路由地址 + */ + public String getRouterPath() { + String routerPath = this.path; + // 内链打开外网方式 + if (getParentId() != 0L && isInnerLink()) { + routerPath = innerLinkReplaceEach(routerPath); + } + // 非外链并且是一级目录(类型为目录) + if (0L == getParentId() && SystemConstants.TYPE_DIR.equals(getMenuType()) + && SystemConstants.NO_FRAME.equals(getIsFrame())) { + routerPath = "/" + this.path; + } + // 非外链并且是一级目录(类型为菜单) + else if (isMenuFrame()) { + routerPath = "/"; + } + return routerPath; + } + + /** + * 获取组件信息 + */ + public String getComponentInfo() { + String component = SystemConstants.LAYOUT; + if (StringUtils.isNotEmpty(this.component) && !isMenuFrame()) { + component = this.component; + } else if (StringUtils.isEmpty(this.component) && getParentId() != 0L && isInnerLink()) { + component = SystemConstants.INNER_LINK; + } else if (StringUtils.isEmpty(this.component) && isParentView()) { + component = SystemConstants.PARENT_VIEW; + } + return component; + } + + /** + * 是否为菜单内部跳转 + */ + public boolean isMenuFrame() { + return getParentId() == 0L && SystemConstants.TYPE_MENU.equals(menuType) && isFrame.equals(SystemConstants.NO_FRAME); + } + + /** + * 是否为内链组件 + */ + public boolean isInnerLink() { + return isFrame.equals(SystemConstants.NO_FRAME) && StringUtils.ishttp(path); + } + + /** + * 是否为parent_view组件 + */ + public boolean isParentView() { + return getParentId() != 0L && SystemConstants.TYPE_DIR.equals(menuType); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysNotice.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysNotice.java new file mode 100644 index 0000000..651e69a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysNotice.java @@ -0,0 +1,51 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + + +/** + * 通知公告表 sys_notice + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_notice") +public class SysNotice extends BaseEntity { + + /** + * 公告ID + */ + @TableId(value = "notice_id") + private Long noticeId; + + /** + * 公告标题 + */ + private String noticeTitle; + + /** + * 公告类型(1通知 2公告) + */ + private String noticeType; + + /** + * 公告内容 + */ + private String noticeContent; + + /** + * 公告状态(0正常 1关闭) + */ + private String status; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOperLog.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOperLog.java new file mode 100644 index 0000000..0398bd5 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOperLog.java @@ -0,0 +1,111 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 操作日志记录表 oper_log + * + * @author pengles + */ + +@Data +@TableName("sys_oper_log") +public class SysOperLog implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 日志主键 + */ + @TableId(value = "oper_id") + private Long operId; + + + /** + * 操作模块 + */ + private String title; + + /** + * 业务类型(0其它 1新增 2修改 3删除) + */ + private Integer businessType; + + /** + * 请求方法 + */ + private String method; + + /** + * 请求方式 + */ + private String requestMethod; + + /** + * 操作类别(0其它 1后台用户 2手机端用户) + */ + private Integer operatorType; + + /** + * 操作人员 + */ + private String operName; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 请求url + */ + private String operUrl; + + /** + * 操作地址 + */ + private String operIp; + + /** + * 操作地点 + */ + private String operLocation; + + /** + * 请求参数 + */ + private String operParam; + + /** + * 返回参数 + */ + private String jsonResult; + + /** + * 操作状态(0正常 1异常) + */ + private Integer status; + + /** + * 错误消息 + */ + private String errorMsg; + + /** + * 操作时间 + */ + private Date operTime; + + /** + * 消耗时间 + */ + private Long costTime; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOss.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOss.java new file mode 100644 index 0000000..f086220 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOss.java @@ -0,0 +1,55 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +/** + * OSS对象存储对象 + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_oss") +public class SysOss extends BaseEntity { + + /** + * 对象存储主键 + */ + @TableId(value = "oss_id") + private Long ossId; + + /** + * 文件名 + */ + private String fileName; + + /** + * 原名 + */ + private String originalName; + + /** + * 文件后缀名 + */ + private String fileSuffix; + + /** + * URL地址 + */ + private String url; + + /** + * 扩展字段 + */ + private String ext1; + + /** + * 服务商 + */ + private String service; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOssConfig.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOssConfig.java new file mode 100644 index 0000000..08903f1 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOssConfig.java @@ -0,0 +1,89 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +/** + * 对象存储配置对象 sys_oss_config + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_oss_config") +public class SysOssConfig extends BaseEntity { + + /** + * 主键 + */ + @TableId(value = "oss_config_id") + private Long ossConfigId; + + /** + * 配置key + */ + private String configKey; + + /** + * accessKey + */ + private String accessKey; + + /** + * 秘钥 + */ + private String secretKey; + + /** + * 桶名称 + */ + private String bucketName; + + /** + * 前缀 + */ + private String prefix; + + /** + * 访问站点 + */ + private String endpoint; + + /** + * 自定义域名 + */ + private String domain; + + /** + * 是否https(0否 1是) + */ + private String isHttps; + + /** + * 域 + */ + private String region; + + /** + * 是否默认(0=是,1=否) + */ + private String status; + + /** + * 扩展字段 + */ + private String ext1; + + /** + * 备注 + */ + private String remark; + + /** + * 桶权限类型(0private 1public 2custom) + */ + private String accessPolicy; +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysPost.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysPost.java new file mode 100644 index 0000000..37f1f7b --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysPost.java @@ -0,0 +1,61 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +/** + * 岗位表 sys_post + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_post") +public class SysPost extends BaseEntity { + + /** + * 岗位序号 + */ + @TableId(value = "post_id") + private Long postId; + + /** + * 部门id + */ + private Long deptId; + + /** + * 岗位编码 + */ + private String postCode; + + /** + * 岗位名称 + */ + private String postName; + + /** + * 岗位类别编码 + */ + private String postCategory; + + /** + * 岗位排序 + */ + private Integer postSort; + + /** + * 状态(0正常 1停用) + */ + private String status; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRole.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRole.java new file mode 100644 index 0000000..e5b6394 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRole.java @@ -0,0 +1,79 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +/** + * 角色表 sys_role + * + * @author pengles + */ + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@TableName("sys_role") +public class SysRole extends BaseEntity { + + /** + * 角色ID + */ + @TableId(value = "role_id") + private Long roleId; + + /** + * 角色名称 + */ + private String roleName; + + /** + * 角色权限 + */ + private String roleKey; + + /** + * 角色排序 + */ + private Integer roleSort; + + /** + * 数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限 5:仅本人数据权限 6:部门及以下或本人数据权限) + */ + private String dataScope; + + /** + * 菜单树选择项是否关联显示( 0:父子不互相关联显示 1:父子互相关联显示) + */ + private Boolean menuCheckStrictly; + + /** + * 部门树选择项是否关联显示(0:父子不互相关联显示 1:父子互相关联显示 ) + */ + private Boolean deptCheckStrictly; + + /** + * 角色状态(0正常 1停用) + */ + private String status; + + /** + * 删除标志(0代表存在 1代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 备注 + */ + private String remark; + + public SysRole(Long roleId) { + this.roleId = roleId; + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRoleDept.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRoleDept.java new file mode 100644 index 0000000..0946e4e --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRoleDept.java @@ -0,0 +1,29 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 角色和部门关联 sys_role_dept + * + * @author pengles + */ + +@Data +@TableName("sys_role_dept") +public class SysRoleDept { + + /** + * 角色ID + */ + @TableId(type = IdType.INPUT) + private Long roleId; + + /** + * 部门ID + */ + private Long deptId; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRoleMenu.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRoleMenu.java new file mode 100644 index 0000000..3d71a4e --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysRoleMenu.java @@ -0,0 +1,29 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 角色和菜单关联 sys_role_menu + * + * @author pengles + */ + +@Data +@TableName("sys_role_menu") +public class SysRoleMenu { + + /** + * 角色ID + */ + @TableId(type = IdType.INPUT) + private Long roleId; + + /** + * 菜单ID + */ + private Long menuId; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysSocial.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysSocial.java new file mode 100644 index 0000000..6210036 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysSocial.java @@ -0,0 +1,136 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +/** + * 社会化关系对象 sys_social + * + * @author thiszhc + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_social") +public class SysSocial extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 的唯一ID + */ + private String authId; + + /** + * 用户来源 + */ + private String source; + + /** + * 用户的授权令牌 + */ + private String accessToken; + + /** + * 用户的授权令牌的有效期,部分平台可能没有 + */ + private int expireIn; + + /** + * 刷新令牌,部分平台可能没有 + */ + private String refreshToken; + + /** + * 用户的 open id + */ + private String openId; + + /** + * 授权的第三方账号 + */ + private String userName; + + /** + * 授权的第三方昵称 + */ + private String nickName; + + /** + * 授权的第三方邮箱 + */ + private String email; + + /** + * 授权的第三方头像地址 + */ + private String avatar; + + /** + * 平台的授权信息,部分平台可能没有 + */ + private String accessCode; + + /** + * 用户的 unionid + */ + private String unionId; + + /** + * 授予的权限,部分平台可能没有 + */ + private String scope; + + /** + * 个别平台的授权信息,部分平台可能没有 + */ + private String tokenType; + + /** + * id token,部分平台可能没有 + */ + private String idToken; + + /** + * 小米平台用户的附带属性,部分平台可能没有 + */ + private String macAlgorithm; + + /** + * 小米平台用户的附带属性,部分平台可能没有 + */ + private String macKey; + + /** + * 用户的授权code,部分平台可能没有 + */ + private String code; + + /** + * Twitter平台用户的附带属性,部分平台可能没有 + */ + private String oauthToken; + + /** + * Twitter平台用户的附带属性,部分平台可能没有 + */ + private String oauthTokenSecret; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUser.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUser.java new file mode 100644 index 0000000..fe01dc4 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUser.java @@ -0,0 +1,115 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.util.Date; + +/** + * 用户对象 sys_user + * + * @author pengles + */ + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@TableName("sys_user") +public class SysUser extends BaseEntity { + + /** + * 用户ID + */ + @TableId(value = "user_id") + private Long userId; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 用户账号 + */ + private String userName; + + /** + * 用户昵称 + */ + private String nickName; + + /** + * 用户类型(sys_user系统用户) + */ + private String userType; + + /** + * 用户邮箱 + */ + private String email; + + /** + * 手机号码 + */ + private String phonenumber; + + /** + * 用户性别 + */ + private String sex; + + /** + * 用户头像 + */ + private Long avatar; + + /** + * 密码 + */ + @TableField( + insertStrategy = FieldStrategy.NOT_EMPTY, + updateStrategy = FieldStrategy.NOT_EMPTY, + whereStrategy = FieldStrategy.NOT_EMPTY + ) + private String password; + + /** + * 帐号状态(0正常 1停用) + */ + private String status; + + /** + * 删除标志(0代表存在 1代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 最后登录IP + */ + private String loginIp; + + /** + * 最后登录时间 + */ + private Date loginDate; + + /** + * 备注 + */ + private String remark; + + + public SysUser(Long userId) { + this.userId = userId; + } + + public boolean isSuperAdmin() { + return SystemConstants.SUPER_ADMIN_ID.equals(this.userId); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserOnline.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserOnline.java new file mode 100644 index 0000000..137ddc0 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserOnline.java @@ -0,0 +1,63 @@ +package org.dromara.system.domain; + +import lombok.Data; + +/** + * 当前在线会话 + * + * @author pengles + */ +@Data +public class SysUserOnline { + + /** + * 会话编号 + */ + private String tokenId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 用户名称 + */ + private String userName; + + /** + * 客户端 + */ + private String clientKey; + + /** + * 设备类型 + */ + private String deviceType; + + /** + * 登录IP地址 + */ + private String ipaddr; + + /** + * 登录地址 + */ + private String loginLocation; + + /** + * 浏览器类型 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; + + /** + * 登录时间 + */ + private Long loginTime; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserPost.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserPost.java new file mode 100644 index 0000000..6dd54a5 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserPost.java @@ -0,0 +1,29 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 用户和岗位关联 sys_user_post + * + * @author pengles + */ + +@Data +@TableName("sys_user_post") +public class SysUserPost { + + /** + * 用户ID + */ + @TableId(type = IdType.INPUT) + private Long userId; + + /** + * 岗位ID + */ + private Long postId; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserRole.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserRole.java new file mode 100644 index 0000000..d1f272a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserRole.java @@ -0,0 +1,29 @@ +package org.dromara.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 用户和角色关联 sys_user_role + * + * @author pengles + */ + +@Data +@TableName("sys_user_role") +public class SysUserRole { + + /** + * 用户ID + */ + @TableId(type = IdType.INPUT) + private Long userId; + + /** + * 角色ID + */ + private Long roleId; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysClientBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysClientBo.java new file mode 100644 index 0000000..51559a4 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysClientBo.java @@ -0,0 +1,81 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysClient; + +import java.util.List; + +/** + * 授权管理业务对象 sys_client + * + * @author pengles + * @date 2023-05-15 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysClient.class, reverseConvertGenerate = false) +public class SysClientBo extends BaseEntity { + + /** + * id + */ + @NotNull(message = "id不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 客户端id + */ + private String clientId; + + /** + * 客户端key + */ + @NotBlank(message = "客户端key不能为空", groups = {AddGroup.class, EditGroup.class}) + private String clientKey; + + /** + * 客户端秘钥 + */ + @NotBlank(message = "客户端秘钥不能为空", groups = {AddGroup.class, EditGroup.class}) + private String clientSecret; + + /** + * 授权类型 + */ + @NotNull(message = "授权类型不能为空", groups = {AddGroup.class, EditGroup.class}) + private List grantTypeList; + + /** + * 授权类型 + */ + private String grantType; + + /** + * 设备类型 + */ + private String deviceType; + + /** + * token活跃超时时间 + */ + private Long activeTimeout; + + /** + * token固定超时时间 + */ + private Long timeout; + + /** + * 状态(0正常 1停用) + */ + private String status; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysConfigBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysConfigBo.java new file mode 100644 index 0000000..e1742e9 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysConfigBo.java @@ -0,0 +1,59 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysConfig; + +/** + * 参数配置业务对象 sys_config + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysConfig.class, reverseConvertGenerate = false) +public class SysConfigBo extends BaseEntity { + + /** + * 参数主键 + */ + private Long configId; + + /** + * 参数名称 + */ + @NotBlank(message = "参数名称不能为空") + @Size(min = 0, max = 100, message = "参数名称不能超过{max}个字符") + private String configName; + + /** + * 参数键名 + */ + @NotBlank(message = "参数键名不能为空") + @Size(min = 0, max = 100, message = "参数键名长度不能超过{max}个字符") + private String configKey; + + /** + * 参数键值 + */ + @NotBlank(message = "参数键值不能为空") + @Size(min = 0, max = 500, message = "参数键值长度不能超过{max}个字符") + private String configValue; + + /** + * 系统内置(Y是 N否) + */ + private String configType; + + /** + * 备注 + */ + private String remark; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDeptBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDeptBo.java new file mode 100644 index 0000000..ccedb2d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDeptBo.java @@ -0,0 +1,81 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysDept; + +/** + * 部门业务对象 sys_dept + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysDept.class, reverseConvertGenerate = false) +public class SysDeptBo extends BaseEntity { + + /** + * 部门id + */ + private Long deptId; + + /** + * 父部门ID + */ + private Long parentId; + + /** + * 部门名称 + */ + @NotBlank(message = "部门名称不能为空") + @Size(min = 0, max = 30, message = "部门名称长度不能超过{max}个字符") + private String deptName; + + /** + * 部门类别编码 + */ + @Size(min = 0, max = 100, message = "部门类别编码长度不能超过{max}个字符") + private String deptCategory; + + /** + * 显示顺序 + */ + @NotNull(message = "显示顺序不能为空") + private Integer orderNum; + + /** + * 负责人 + */ + private Long leader; + + /** + * 联系电话 + */ + @Size(min = 0, max = 11, message = "联系电话长度不能超过{max}个字符") + private String phone; + + /** + * 邮箱 + */ + @Email(message = "邮箱格式不正确") + @Size(min = 0, max = 50, message = "邮箱长度不能超过{max}个字符") + private String email; + + /** + * 部门状态(0正常 1停用) + */ + private String status; + + /** + * 归属部门id(部门树) + */ + private Long belongDeptId; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDictDataBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDictDataBo.java new file mode 100644 index 0000000..ab4c556 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDictDataBo.java @@ -0,0 +1,79 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysDictData; + +/** + * 字典数据业务对象 sys_dict_data + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysDictData.class, reverseConvertGenerate = false) +public class SysDictDataBo extends BaseEntity { + + /** + * 字典编码 + */ + private Long dictCode; + + /** + * 字典排序 + */ + private Integer dictSort; + + /** + * 字典标签 + */ + @NotBlank(message = "字典标签不能为空") + @Size(min = 0, max = 100, message = "字典标签长度不能超过{max}个字符") + private String dictLabel; + + /** + * 字典键值 + */ + @NotBlank(message = "字典键值不能为空") + @Size(min = 0, max = 100, message = "字典键值长度不能超过{max}个字符") + private String dictValue; + + /** + * 字典类型 + */ + @NotBlank(message = "字典类型不能为空") + @Size(min = 0, max = 100, message = "字典类型长度不能超过{max}个字符") + private String dictType; + + /** + * 样式属性(其他样式扩展) + */ + @Size(min = 0, max = 100, message = "样式属性长度不能超过{max}个字符") + private String cssClass; + + /** + * 表格回显样式 + */ + private String listClass; + + /** + * 是否默认(Y是 N否) + */ + private String isDefault; + + /** + * 创建部门 + */ + private Long createDept; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDictTypeBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDictTypeBo.java new file mode 100644 index 0000000..56790f0 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysDictTypeBo.java @@ -0,0 +1,50 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.constant.RegexConstants; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysDictType; + +/** + * 字典类型业务对象 sys_dict_type + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysDictType.class, reverseConvertGenerate = false) +public class SysDictTypeBo extends BaseEntity { + + /** + * 字典主键 + */ + private Long dictId; + + /** + * 字典名称 + */ + @NotBlank(message = "字典名称不能为空") + @Size(min = 0, max = 100, message = "字典类型名称长度不能超过{max}个字符") + private String dictName; + + /** + * 字典类型 + */ + @NotBlank(message = "字典类型不能为空") + @Size(min = 0, max = 100, message = "字典类型类型长度不能超过{max}个字符") + @Pattern(regexp = RegexConstants.DICTIONARY_TYPE, message = "字典类型必须以字母开头,且只能为(小写字母,数字,下滑线)") + private String dictType; + + /** + * 备注 + */ + private String remark; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysLogininforBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysLogininforBo.java new file mode 100644 index 0000000..a773ffe --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysLogininforBo.java @@ -0,0 +1,82 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.system.domain.SysLogininfor; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * 系统访问记录业务对象 sys_logininfor + * + * @author pengles + */ + +@Data +@AutoMapper(target = SysLogininfor.class, reverseConvertGenerate = false) +public class SysLogininforBo { + + /** + * 访问ID + */ + private Long infoId; + + /** + * 用户账号 + */ + private String userName; + + /** + * 客户端 + */ + private String clientKey; + + /** + * 设备类型 + */ + private String deviceType; + + /** + * 登录IP地址 + */ + private String ipaddr; + + /** + * 登录地点 + */ + private String loginLocation; + + /** + * 浏览器类型 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; + + /** + * 登录状态(0成功 1失败) + */ + private String status; + + /** + * 提示消息 + */ + private String msg; + + /** + * 访问时间 + */ + private Date loginTime; + + /** + * 请求参数 + */ + private Map params = new HashMap<>(); + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysMenuBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysMenuBo.java new file mode 100644 index 0000000..eae4eb4 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysMenuBo.java @@ -0,0 +1,109 @@ +package org.dromara.system.domain.bo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.constant.RegexConstants; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysMenu; + +/** + * 菜单权限业务对象 sys_menu + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysMenu.class, reverseConvertGenerate = false) +public class SysMenuBo extends BaseEntity { + + /** + * 菜单ID + */ + private Long menuId; + + /** + * 父菜单ID + */ + private Long parentId; + + /** + * 菜单名称 + */ + @NotBlank(message = "菜单名称不能为空") + @Size(min = 0, max = 50, message = "菜单名称长度不能超过{max}个字符") + private String menuName; + + /** + * 显示顺序 + */ + @NotNull(message = "显示顺序不能为空") + private Integer orderNum; + + /** + * 路由地址 + */ + @Size(min = 0, max = 200, message = "路由地址不能超过{max}个字符") + private String path; + + /** + * 组件路径 + */ + @Size(min = 0, max = 200, message = "组件路径不能超过{max}个字符") + private String component; + + /** + * 路由参数 + */ + private String queryParam; + + /** + * 是否为外链(0是 1否) + */ + private String isFrame; + + /** + * 是否缓存(0缓存 1不缓存) + */ + private String isCache; + + /** + * 菜单类型(M目录 C菜单 F按钮) + */ + @NotBlank(message = "菜单类型不能为空") + private String menuType; + + /** + * 显示状态(0显示 1隐藏) + */ + private String visible; + + /** + * 菜单状态(0正常 1停用) + */ + private String status; + + /** + * 权限标识 + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @Size(min = 0, max = 100, message = "权限标识长度不能超过{max}个字符") + @Pattern(regexp = RegexConstants.PERMISSION_STRING, message = "权限标识必须符合 tool:build:list 格式") + private String perms; + + /** + * 菜单图标 + */ + private String icon; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysNoticeBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysNoticeBo.java new file mode 100644 index 0000000..251b214 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysNoticeBo.java @@ -0,0 +1,60 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.xss.Xss; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysNotice; + +/** + * 通知公告业务对象 sys_notice + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysNotice.class, reverseConvertGenerate = false) +public class SysNoticeBo extends BaseEntity { + + /** + * 公告ID + */ + private Long noticeId; + + /** + * 公告标题 + */ + @Xss(message = "公告标题不能包含脚本字符") + @NotBlank(message = "公告标题不能为空") + @Size(min = 0, max = 50, message = "公告标题不能超过{max}个字符") + private String noticeTitle; + + /** + * 公告类型(1通知 2公告) + */ + private String noticeType; + + /** + * 公告内容 + */ + private String noticeContent; + + /** + * 公告状态(0正常 1关闭) + */ + private String status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建人名称 + */ + private String createByName; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOperLogBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOperLogBo.java new file mode 100644 index 0000000..92e8c6b --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOperLogBo.java @@ -0,0 +1,116 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.system.domain.SysOperLog; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * 操作日志记录业务对象 sys_oper_log + * + * @author pengles + */ +@Data +@AutoMapper(target = SysOperLog.class, reverseConvertGenerate = false) +public class SysOperLogBo { + + /** + * 日志主键 + */ + private Long operId; + + + /** + * 模块标题 + */ + private String title; + + /** + * 业务类型(0其它 1新增 2修改 3删除) + */ + private Integer businessType; + + /** + * 业务类型数组 + */ + private Integer[] businessTypes; + + /** + * 方法名称 + */ + private String method; + + /** + * 请求方式 + */ + private String requestMethod; + + /** + * 操作类别(0其它 1后台用户 2手机端用户) + */ + private Integer operatorType; + + /** + * 操作人员 + */ + private String operName; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 请求URL + */ + private String operUrl; + + /** + * 主机地址 + */ + private String operIp; + + /** + * 操作地点 + */ + private String operLocation; + + /** + * 请求参数 + */ + private String operParam; + + /** + * 返回参数 + */ + private String jsonResult; + + /** + * 操作状态(0正常 1异常) + */ + private Integer status; + + /** + * 错误消息 + */ + private String errorMsg; + + /** + * 操作时间 + */ + private Date operTime; + + /** + * 消耗时间 + */ + private Long costTime; + + /** + * 请求参数 + */ + private Map params = new HashMap<>(); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOssBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOssBo.java new file mode 100644 index 0000000..cd77811 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOssBo.java @@ -0,0 +1,54 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysOss; + +/** + * OSS对象存储分页查询对象 sys_oss + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysOss.class, reverseConvertGenerate = false) +public class SysOssBo extends BaseEntity { + + /** + * ossId + */ + private Long ossId; + + /** + * 文件名 + */ + private String fileName; + + /** + * 原名 + */ + private String originalName; + + /** + * 文件后缀名 + */ + private String fileSuffix; + + /** + * URL地址 + */ + private String url; + + /** + * 扩展字段 + */ + private String ext1; + + /** + * 服务商 + */ + private String service; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOssConfigBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOssConfigBo.java new file mode 100644 index 0000000..5ff06d0 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysOssConfigBo.java @@ -0,0 +1,109 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysOssConfig; + +/** + * 对象存储配置业务对象 sys_oss_config + * + * @author pengles + * @author 孤舟烟雨 + * @date 2021-08-13 + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysOssConfig.class, reverseConvertGenerate = false) +public class SysOssConfigBo extends BaseEntity { + + /** + * 主键 + */ + @NotNull(message = "主键不能为空", groups = {EditGroup.class}) + private Long ossConfigId; + + /** + * 配置key + */ + @NotBlank(message = "配置key不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(min = 2, max = 100, message = "configKey长度必须介于{min}和{max} 之间") + private String configKey; + + /** + * accessKey + */ + @NotBlank(message = "accessKey不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(min = 2, max = 100, message = "accessKey长度必须介于{min}和{max} 之间") + private String accessKey; + + /** + * 秘钥 + */ + @NotBlank(message = "secretKey不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(min = 2, max = 100, message = "secretKey长度必须介于{min}和{max} 之间") + private String secretKey; + + /** + * 桶名称 + */ + @NotBlank(message = "桶名称不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(min = 2, max = 100, message = "bucketName长度必须介于{min}和{max}之间") + private String bucketName; + + /** + * 前缀 + */ + private String prefix; + + /** + * 访问站点 + */ + @NotBlank(message = "访问站点不能为空", groups = {AddGroup.class, EditGroup.class}) + @Size(min = 2, max = 100, message = "endpoint长度必须介于{min}和{max}之间") + private String endpoint; + + /** + * 自定义域名 + */ + private String domain; + + /** + * 是否https(Y=是,N=否) + */ + private String isHttps; + + /** + * 是否默认(0=是,1=否) + */ + private String status; + + /** + * 域 + */ + private String region; + + /** + * 扩展字段 + */ + private String ext1; + + /** + * 备注 + */ + private String remark; + + /** + * 桶权限类型(0private 1public 2custom) + */ + @NotBlank(message = "桶权限类型不能为空", groups = {AddGroup.class, EditGroup.class}) + private String accessPolicy; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysPostBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysPostBo.java new file mode 100644 index 0000000..09bc888 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysPostBo.java @@ -0,0 +1,75 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysPost; + +/** + * 岗位信息业务对象 sys_post + * + * @author pengles + */ + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysPost.class, reverseConvertGenerate = false) +public class SysPostBo extends BaseEntity { + + /** + * 岗位ID + */ + private Long postId; + + /** + * 部门id(单部门) + */ + @NotNull(message = "部门id不能为空") + private Long deptId; + + /** + * 归属部门id(部门树) + */ + private Long belongDeptId; + + /** + * 岗位编码 + */ + @NotBlank(message = "岗位编码不能为空") + @Size(min = 0, max = 64, message = "岗位编码长度不能超过{max}个字符") + private String postCode; + + /** + * 岗位名称 + */ + @NotBlank(message = "岗位名称不能为空") + @Size(min = 0, max = 50, message = "岗位名称长度不能超过{max}个字符") + private String postName; + + /** + * 岗位类别编码 + */ + @Size(min = 0, max = 100, message = "类别编码长度不能超过{max}个字符") + private String postCategory; + + /** + * 显示顺序 + */ + @NotNull(message = "显示顺序不能为空") + private Integer postSort; + + /** + * 状态(0正常 1停用) + */ + private String status; + + /** + * 备注 + */ + private String remark; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysRoleBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysRoleBo.java new file mode 100644 index 0000000..f236f46 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysRoleBo.java @@ -0,0 +1,94 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysRole; + +/** + * 角色信息业务对象 sys_role + * + * @author pengles + */ + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysRole.class, reverseConvertGenerate = false) +public class SysRoleBo extends BaseEntity { + + /** + * 角色ID + */ + private Long roleId; + + /** + * 角色名称 + */ + @NotBlank(message = "角色名称不能为空") + @Size(min = 0, max = 30, message = "角色名称长度不能超过{max}个字符") + private String roleName; + + /** + * 角色权限字符串 + */ + @NotBlank(message = "角色权限字符串不能为空") + @Size(min = 0, max = 100, message = "权限字符长度不能超过{max}个字符") + private String roleKey; + + /** + * 显示顺序 + */ + @NotNull(message = "显示顺序不能为空") + private Integer roleSort; + + /** + * 数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限 5:仅本人数据权限 6:部门及以下或本人数据权限) + */ + private String dataScope; + + /** + * 菜单树选择项是否关联显示 + */ + private Boolean menuCheckStrictly; + + /** + * 部门树选择项是否关联显示 + */ + private Boolean deptCheckStrictly; + + /** + * 角色状态(0正常 1停用) + */ + private String status; + + /** + * 备注 + */ + private String remark; + + /** + * 菜单组 + */ + private Long[] menuIds; + + /** + * 部门组(数据权限) + */ + private Long[] deptIds; + + public SysRoleBo(Long roleId) { + this.roleId = roleId; + } + + public boolean isSuperAdmin() { + return SystemConstants.SUPER_ADMIN_ID.equals(this.roleId); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysSocialBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysSocialBo.java new file mode 100644 index 0000000..5111758 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysSocialBo.java @@ -0,0 +1,141 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysSocial; + +/** + * 社会化关系业务对象 sys_social + * + * @author pengles + */ +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysSocial.class, reverseConvertGenerate = false) +public class SysSocialBo extends BaseEntity { + + /** + * 主键 + */ + @NotNull(message = "主键不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 认证唯一ID + */ + @NotBlank(message = "认证唯一ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private String authId; + + /** + * 用户来源 + */ + @NotBlank(message = "用户来源不能为空", groups = {AddGroup.class, EditGroup.class}) + private String source; + + /** + * 用户的授权令牌 + */ + @NotBlank(message = "用户的授权令牌不能为空", groups = {AddGroup.class, EditGroup.class}) + private String accessToken; + + /** + * 用户的授权令牌的有效期,部分平台可能没有 + */ + private int expireIn; + + /** + * 刷新令牌,部分平台可能没有 + */ + private String refreshToken; + + /** + * 平台唯一id + */ + private String openId; + + /** + * 用户的 ID + */ + @NotBlank(message = "用户的ID不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long userId; + + /** + * 平台的授权信息,部分平台可能没有 + */ + private String accessCode; + + /** + * 用户的 unionid + */ + private String unionId; + + /** + * 授予的权限,部分平台可能没有 + */ + private String scope; + + /** + * 授权的第三方账号 + */ + private String userName; + + /** + * 授权的第三方昵称 + */ + private String nickName; + + /** + * 授权的第三方邮箱 + */ + private String email; + + /** + * 授权的第三方头像地址 + */ + private String avatar; + + /** + * 个别平台的授权信息,部分平台可能没有 + */ + private String tokenType; + + /** + * id token,部分平台可能没有 + */ + private String idToken; + + /** + * 小米平台用户的附带属性,部分平台可能没有 + */ + private String macAlgorithm; + + /** + * 小米平台用户的附带属性,部分平台可能没有 + */ + private String macKey; + + /** + * 用户的授权code,部分平台可能没有 + */ + private String code; + + /** + * Twitter平台用户的附带属性,部分平台可能没有 + */ + private String oauthToken; + + /** + * Twitter平台用户的附带属性,部分平台可能没有 + */ + private String oauthTokenSecret; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserBo.java new file mode 100644 index 0000000..848089f --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserBo.java @@ -0,0 +1,124 @@ +package org.dromara.system.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.xss.Xss; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.system.domain.SysUser; + +/** + * 用户信息业务对象 sys_user + * + * @author pengles + */ + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SysUser.class, reverseConvertGenerate = false) +public class SysUserBo extends BaseEntity { + + /** + * 用户ID + */ + private Long userId; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 用户账号 + */ + @Xss(message = "用户账号不能包含脚本字符") + @NotBlank(message = "用户账号不能为空") + @Size(min = 0, max = 30, message = "用户账号长度不能超过{max}个字符") + private String userName; + + /** + * 用户昵称 + */ + @Xss(message = "用户昵称不能包含脚本字符") + @NotBlank(message = "用户昵称不能为空") + @Size(min = 0, max = 30, message = "用户昵称长度不能超过{max}个字符") + private String nickName; + + /** + * 用户类型(sys_user系统用户) + */ + private String userType; + + /** + * 用户邮箱 + */ + @Email(message = "邮箱格式不正确") + @Size(min = 0, max = 50, message = "邮箱长度不能超过{max}个字符") + private String email; + + /** + * 手机号码 + */ + private String phonenumber; + + /** + * 用户性别(0男 1女 2未知) + */ + private String sex; + + /** + * 密码 + */ + private String password; + + /** + * 帐号状态(0正常 1停用) + */ + private String status; + + /** + * 备注 + */ + private String remark; + + /** + * 角色组 + */ + @Size(min = 1, message = "用户角色不能为空") + private Long[] roleIds; + + /** + * 岗位组 + */ + private Long[] postIds; + + /** + * 数据权限 当前角色ID + */ + private Long roleId; + + /** + * 用户ID + */ + private String userIds; + + /** + * 排除不查询的用户(工作流用) + */ + private String excludeUserIds; + + public SysUserBo(Long userId) { + this.userId = userId; + } + + public boolean isSuperAdmin() { + return SystemConstants.SUPER_ADMIN_ID.equals(this.userId); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserPasswordBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserPasswordBo.java new file mode 100644 index 0000000..8615fcd --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserPasswordBo.java @@ -0,0 +1,29 @@ +package org.dromara.system.domain.bo; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 用户密码修改bo + */ +@Data +public class SysUserPasswordBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 旧密码 + */ + @NotBlank(message = "旧密码不能为空") + private String oldPassword; + + /** + * 新密码 + */ + @NotBlank(message = "新密码不能为空") + private String newPassword; +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserProfileBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserProfileBo.java new file mode 100644 index 0000000..d8c9d35 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserProfileBo.java @@ -0,0 +1,53 @@ +package org.dromara.system.domain.bo; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.dromara.common.core.constant.RegexConstants; +import org.dromara.common.core.xss.Xss; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.common.sensitive.annotation.Sensitive; +import org.dromara.common.sensitive.core.SensitiveStrategy; + +/** + * 个人信息业务处理 + * + * @author pengles + */ + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class SysUserProfileBo extends BaseEntity { + + /** + * 用户昵称 + */ + @Xss(message = "用户昵称不能包含脚本字符") + @Size(min = 0, max = 30, message = "用户昵称长度不能超过{max}个字符") + private String nickName; + + /** + * 用户邮箱 + */ + @Sensitive(strategy = SensitiveStrategy.EMAIL) + @Email(message = "邮箱格式不正确") + @Size(min = 0, max = 50, message = "邮箱长度不能超过{max}个字符") + private String email; + + /** + * 手机号码 + */ + @Pattern(regexp = RegexConstants.MOBILE, message = "手机号格式不正确") + @Sensitive(strategy = SensitiveStrategy.PHONE) + private String phonenumber; + + /** + * 用户性别(0男 1女 2未知) + */ + private String sex; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/MetaVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/MetaVo.java new file mode 100644 index 0000000..075b592 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/MetaVo.java @@ -0,0 +1,61 @@ +package org.dromara.system.domain.vo; + +import lombok.Data; +import org.dromara.common.core.utils.StringUtils; + +/** + * 路由显示信息 + * + * @author pengles + */ + +@Data +public class MetaVo { + + /** + * 设置该路由在侧边栏和面包屑中展示的名字 + */ + private String title; + + /** + * 设置该路由的图标,对应路径src/assets/icons/svg + */ + private String icon; + + /** + * 设置为true,则不会被 缓存 + */ + private Boolean noCache; + + /** + * 内链地址(http(s)://开头) + */ + private String link; + + public MetaVo(String title, String icon) { + this.title = title; + this.icon = icon; + } + + public MetaVo(String title, String icon, Boolean noCache) { + this.title = title; + this.icon = icon; + this.noCache = noCache; + } + + public MetaVo(String title, String icon, String link) { + this.title = title; + this.icon = icon; + this.link = link; + } + + public MetaVo(String title, String icon, Boolean noCache, String link) { + this.title = title; + this.icon = icon; + this.noCache = noCache; + if (StringUtils.ishttp(link)) { + this.link = link; + } + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/RouterVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/RouterVo.java new file mode 100644 index 0000000..42da7e9 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/RouterVo.java @@ -0,0 +1,62 @@ +package org.dromara.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.util.List; + +/** + * 路由配置信息 + * + * @author pengles + */ +@Data +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class RouterVo { + + /** + * 路由名字 + */ + private String name; + + /** + * 路由地址 + */ + private String path; + + /** + * 是否隐藏路由,当设置 true 的时候该路由不会再侧边栏出现 + */ + private Boolean hidden; + + /** + * 重定向地址,当设置 noRedirect 的时候该路由在面包屑导航中不可被点击 + */ + private String redirect; + + /** + * 组件地址 + */ + private String component; + + /** + * 路由参数:如 {"id": 1, "name": "ry"} + */ + private String query; + + /** + * 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式--如组件页面 + */ + private Boolean alwaysShow; + + /** + * 其他元素 + */ + private MetaVo meta; + + /** + * 子路由 + */ + private List children; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysClientVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysClientVo.java new file mode 100644 index 0000000..0308c74 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysClientVo.java @@ -0,0 +1,90 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.system.domain.SysClient; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + + +/** + * 授权管理视图对象 sys_client + * + * @author pengles + * @date 2023-05-15 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysClient.class) +public class SysClientVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * id + */ + @ExcelProperty(value = "id") + private Long id; + + /** + * 客户端id + */ + @ExcelProperty(value = "客户端id") + private String clientId; + + /** + * 客户端key + */ + @ExcelProperty(value = "客户端key") + private String clientKey; + + /** + * 客户端秘钥 + */ + @ExcelProperty(value = "客户端秘钥") + private String clientSecret; + + /** + * 授权类型 + */ + @ExcelProperty(value = "授权类型") + private List grantTypeList; + + /** + * 授权类型 + */ + private String grantType; + + /** + * 设备类型 + */ + private String deviceType; + + /** + * token活跃超时时间 + */ + @ExcelProperty(value = "token活跃超时时间") + private Long activeTimeout; + + /** + * token固定超时时间 + */ + @ExcelProperty(value = "token固定超时时间") + private Long timeout; + + /** + * 状态(0正常 1停用) + */ + @ExcelProperty(value = "状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "0=正常,1=停用") + private String status; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysConfigVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysConfigVo.java new file mode 100644 index 0000000..f6c513d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysConfigVo.java @@ -0,0 +1,71 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.system.domain.SysConfig; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 参数配置视图对象 sys_config + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysConfig.class) +public class SysConfigVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 参数主键 + */ + @ExcelProperty(value = "参数主键") + private Long configId; + + /** + * 参数名称 + */ + @ExcelProperty(value = "参数名称") + private String configName; + + /** + * 参数键名 + */ + @ExcelProperty(value = "参数键名") + private String configKey; + + /** + * 参数键值 + */ + @ExcelProperty(value = "参数键值") + private String configValue; + + /** + * 系统内置(Y是 N否) + */ + @ExcelProperty(value = "系统内置", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_yes_no") + private String configType; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDeptVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDeptVo.java new file mode 100644 index 0000000..79a3ba7 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDeptVo.java @@ -0,0 +1,109 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.system.domain.SysDept; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * 部门视图对象 sys_dept + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysDept.class) +public class SysDeptVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 部门id + */ + @ExcelProperty(value = "部门id") + private Long deptId; + + /** + * 父部门id + */ + private Long parentId; + + /** + * 父部门名称 + */ + private String parentName; + + /** + * 祖级列表 + */ + private String ancestors; + + /** + * 部门名称 + */ + @ExcelProperty(value = "部门名称") + private String deptName; + + /** + * 部门类别编码 + */ + @ExcelProperty(value = "部门类别编码") + private String deptCategory; + + /** + * 显示顺序 + */ + private Integer orderNum; + + /** + * 负责人ID + */ + private Long leader; + + /** + * 负责人 + */ + @ExcelProperty(value = "负责人") + private String leaderName; + + /** + * 联系电话 + */ + @ExcelProperty(value = "联系电话") + private String phone; + + /** + * 邮箱 + */ + @ExcelProperty(value = "邮箱") + private String email; + + /** + * 部门状态(0正常 1停用) + */ + @ExcelProperty(value = "部门状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private String status; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 子部门 + */ + private List children = new ArrayList<>(); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDictDataVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDictDataVo.java new file mode 100644 index 0000000..b34efed --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDictDataVo.java @@ -0,0 +1,88 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.system.domain.SysDictData; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * 字典数据视图对象 sys_dict_data + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysDictData.class) +public class SysDictDataVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 字典编码 + */ + @ExcelProperty(value = "字典编码") + private Long dictCode; + + /** + * 字典排序 + */ + @ExcelProperty(value = "字典排序") + private Integer dictSort; + + /** + * 字典标签 + */ + @ExcelProperty(value = "字典标签") + private String dictLabel; + + /** + * 字典键值 + */ + @ExcelProperty(value = "字典键值") + private String dictValue; + + /** + * 字典类型 + */ + @ExcelProperty(value = "字典类型") + private String dictType; + + /** + * 样式属性(其他样式扩展) + */ + private String cssClass; + + /** + * 表格回显样式 + */ + private String listClass; + + /** + * 是否默认(Y是 N否) + */ + @ExcelProperty(value = "是否默认", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_yes_no") + private String isDefault; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDictTypeVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDictTypeVo.java new file mode 100644 index 0000000..8bd4197 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDictTypeVo.java @@ -0,0 +1,57 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.system.domain.SysDictType; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * 字典类型视图对象 sys_dict_type + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysDictType.class) +public class SysDictTypeVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 字典主键 + */ + @ExcelProperty(value = "字典主键") + private Long dictId; + + /** + * 字典名称 + */ + @ExcelProperty(value = "字典名称") + private String dictName; + + /** + * 字典类型 + */ + @ExcelProperty(value = "字典类型") + private String dictType; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysLogininforVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysLogininforVo.java new file mode 100644 index 0000000..21ab91a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysLogininforVo.java @@ -0,0 +1,100 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.system.domain.SysLogininfor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * 系统访问记录视图对象 sys_logininfor + * + * @author pengles + * @date 2023-02-07 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysLogininfor.class) +public class SysLogininforVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 访问ID + */ + @ExcelProperty(value = "序号") + private Long infoId; + + /** + * 用户账号 + */ + @ExcelProperty(value = "用户账号") + private String userName; + + /** + * 客户端 + */ + @ExcelProperty(value = "客户端") + private String clientKey; + + /** + * 设备类型 + */ + @ExcelProperty(value = "设备类型", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_device_type") + private String deviceType; + + /** + * 登录状态(0成功 1失败) + */ + @ExcelProperty(value = "登录状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_common_status") + private String status; + + /** + * 登录IP地址 + */ + @ExcelProperty(value = "登录地址") + private String ipaddr; + + /** + * 登录地点 + */ + @ExcelProperty(value = "登录地点") + private String loginLocation; + + /** + * 浏览器类型 + */ + @ExcelProperty(value = "浏览器") + private String browser; + + /** + * 操作系统 + */ + @ExcelProperty(value = "操作系统") + private String os; + + + /** + * 提示消息 + */ + @ExcelProperty(value = "提示消息") + private String msg; + + /** + * 访问时间 + */ + @ExcelProperty(value = "访问时间") + private Date loginTime; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysMenuVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysMenuVo.java new file mode 100644 index 0000000..f4a57ae --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysMenuVo.java @@ -0,0 +1,116 @@ +package org.dromara.system.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.system.domain.SysMenu; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + + +/** + * 菜单权限视图对象 sys_menu + * + * @author pengles + */ +@Data +@AutoMapper(target = SysMenu.class) +public class SysMenuVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 菜单ID + */ + private Long menuId; + + /** + * 菜单名称 + */ + private String menuName; + + /** + * 父菜单ID + */ + private Long parentId; + + /** + * 显示顺序 + */ + private Integer orderNum; + + /** + * 路由地址 + */ + private String path; + + /** + * 组件路径 + */ + private String component; + + /** + * 路由参数 + */ + private String queryParam; + + /** + * 是否为外链(0是 1否) + */ + private String isFrame; + + /** + * 是否缓存(0缓存 1不缓存) + */ + private String isCache; + + /** + * 菜单类型(M目录 C菜单 F按钮) + */ + private String menuType; + + /** + * 显示状态(0显示 1隐藏) + */ + private String visible; + + /** + * 菜单状态(0正常 1停用) + */ + private String status; + + /** + * 权限标识 + */ + private String perms; + + /** + * 菜单图标 + */ + private String icon; + + /** + * 创建部门 + */ + private Long createDept; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 子菜单 + */ + private List children = new ArrayList<>(); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysNoticeVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysNoticeVo.java new file mode 100644 index 0000000..0500018 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysNoticeVo.java @@ -0,0 +1,72 @@ +package org.dromara.system.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.common.translation.constant.TransConstant; +import org.dromara.system.domain.SysNotice; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * 通知公告视图对象 sys_notice + * + * @author pengles + */ +@Data +@AutoMapper(target = SysNotice.class) +public class SysNoticeVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 公告ID + */ + private Long noticeId; + + /** + * 公告标题 + */ + private String noticeTitle; + + /** + * 公告类型(1通知 2公告) + */ + private String noticeType; + + /** + * 公告内容 + */ + private String noticeContent; + + /** + * 公告状态(0正常 1关闭) + */ + private String status; + + /** + * 备注 + */ + private String remark; + + /** + * 创建者 + */ + private Long createBy; + + /** + * 创建人名称 + */ + @Translation(type = TransConstant.USER_ID_TO_NAME, mapper = "createBy") + private String createByName; + + /** + * 创建时间 + */ + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOperLogVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOperLogVo.java new file mode 100644 index 0000000..15a6d0a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOperLogVo.java @@ -0,0 +1,139 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.system.domain.SysOperLog; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * 操作日志记录视图对象 sys_oper_log + * + * @author pengles + * @date 2023-02-07 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysOperLog.class) +public class SysOperLogVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 日志主键 + */ + @ExcelProperty(value = "日志主键") + private Long operId; + + /** + * 模块标题 + */ + @ExcelProperty(value = "操作模块") + private String title; + + /** + * 业务类型(0其它 1新增 2修改 3删除) + */ + @ExcelProperty(value = "业务类型", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_oper_type") + private Integer businessType; + + /** + * 业务类型数组 + */ + private Integer[] businessTypes; + + /** + * 方法名称 + */ + @ExcelProperty(value = "请求方法") + private String method; + + /** + * 请求方式 + */ + @ExcelProperty(value = "请求方式") + private String requestMethod; + + /** + * 操作类别(0其它 1后台用户 2手机端用户) + */ + @ExcelProperty(value = "操作类别", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "0=其它,1=后台用户,2=手机端用户") + private Integer operatorType; + + /** + * 操作人员 + */ + @ExcelProperty(value = "操作人员") + private String operName; + + /** + * 部门名称 + */ + @ExcelProperty(value = "部门名称") + private String deptName; + + /** + * 请求URL + */ + @ExcelProperty(value = "请求地址") + private String operUrl; + + /** + * 主机地址 + */ + @ExcelProperty(value = "操作地址") + private String operIp; + + /** + * 操作地点 + */ + @ExcelProperty(value = "操作地点") + private String operLocation; + + /** + * 请求参数 + */ + @ExcelProperty(value = "请求参数") + private String operParam; + + /** + * 返回参数 + */ + @ExcelProperty(value = "返回参数") + private String jsonResult; + + /** + * 操作状态(0正常 1异常) + */ + @ExcelProperty(value = "状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_common_status") + private Integer status; + + /** + * 错误消息 + */ + @ExcelProperty(value = "错误消息") + private String errorMsg; + + /** + * 操作时间 + */ + @ExcelProperty(value = "操作时间") + private Date operTime; + + /** + * 消耗时间 + */ + @ExcelProperty(value = "消耗时间") + private Long costTime; +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssConfigVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssConfigVo.java new file mode 100644 index 0000000..3700c69 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssConfigVo.java @@ -0,0 +1,97 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.system.domain.SysOssConfig; + +import java.io.Serial; +import java.io.Serializable; + + +/** + * 对象存储配置视图对象 sys_oss_config + * + * @author pengles + * @author 孤舟烟雨 + * @date 2021-08-13 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysOssConfig.class) +public class SysOssConfigVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + private Long ossConfigId; + + /** + * 配置key + */ + private String configKey; + + /** + * accessKey + */ + private String accessKey; + + /** + * 秘钥 + */ + private String secretKey; + + /** + * 桶名称 + */ + private String bucketName; + + /** + * 前缀 + */ + private String prefix; + + /** + * 访问站点 + */ + private String endpoint; + + /** + * 自定义域名 + */ + private String domain; + + /** + * 是否https(Y=是,N=否) + */ + private String isHttps; + + /** + * 域 + */ + private String region; + + /** + * 是否默认(0=是,1=否) + */ + private String status; + + /** + * 扩展字段 + */ + private String ext1; + + /** + * 备注 + */ + private String remark; + + /** + * 桶权限类型(0private 1public 2custom) + */ + private String accessPolicy; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssUploadVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssUploadVo.java new file mode 100644 index 0000000..5a8a085 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssUploadVo.java @@ -0,0 +1,28 @@ +package org.dromara.system.domain.vo; + +import lombok.Data; + +/** + * 上传对象信息 + * + * @author pengles + */ +@Data +public class SysOssUploadVo { + + /** + * URL地址 + */ + private String url; + + /** + * 文件名 + */ + private String fileName; + + /** + * 对象存储主键 + */ + private String ossId; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssVo.java new file mode 100644 index 0000000..c1b8bce --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysOssVo.java @@ -0,0 +1,77 @@ +package org.dromara.system.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.common.translation.constant.TransConstant; +import org.dromara.system.domain.SysOss; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * OSS对象存储视图对象 sys_oss + * + * @author pengles + */ +@Data +@AutoMapper(target = SysOss.class) +public class SysOssVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 对象存储主键 + */ + private Long ossId; + + /** + * 文件名 + */ + private String fileName; + + /** + * 原名 + */ + private String originalName; + + /** + * 文件后缀名 + */ + private String fileSuffix; + + /** + * URL地址 + */ + private String url; + + /** + * 扩展字段 + */ + private String ext1; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 上传人 + */ + private Long createBy; + + /** + * 上传人名称 + */ + @Translation(type = TransConstant.USER_ID_TO_NAME, mapper = "createBy") + private String createByName; + + /** + * 服务商 + */ + private String service; + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysPostVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysPostVo.java new file mode 100644 index 0000000..6da6c62 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysPostVo.java @@ -0,0 +1,91 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.common.translation.constant.TransConstant; +import org.dromara.system.domain.SysPost; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 岗位信息视图对象 sys_post + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysPost.class) +public class SysPostVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 岗位ID + */ + @ExcelProperty(value = "岗位序号") + private Long postId; + + /** + * 部门id + */ + @ExcelProperty(value = "部门id") + private Long deptId; + + /** + * 岗位编码 + */ + @ExcelProperty(value = "岗位编码") + private String postCode; + + /** + * 岗位名称 + */ + @ExcelProperty(value = "岗位名称") + private String postName; + + /** + * 岗位类别编码 + */ + @ExcelProperty(value = "类别编码") + private String postCategory; + + /** + * 显示顺序 + */ + @ExcelProperty(value = "岗位排序") + private Integer postSort; + + /** + * 状态(0正常 1停用) + */ + @ExcelProperty(value = "状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private String status; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 部门名 + */ + @Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId") + private String deptName; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysRoleVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysRoleVo.java new file mode 100644 index 0000000..ffa051d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysRoleVo.java @@ -0,0 +1,100 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; +import org.dromara.system.domain.SysRole; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 角色信息视图对象 sys_role + * + * @author pengles + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SysRole.class) +public class SysRoleVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 角色ID + */ + @ExcelProperty(value = "角色序号") + private Long roleId; + + /** + * 角色名称 + */ + @ExcelProperty(value = "角色名称") + private String roleName; + + /** + * 角色权限字符串 + */ + @ExcelProperty(value = "角色权限") + private String roleKey; + + /** + * 显示顺序 + */ + @ExcelProperty(value = "角色排序") + private Integer roleSort; + + /** + * 数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限 5:仅本人数据权限 6:部门及以下或本人数据权限) + */ + @ExcelProperty(value = "数据范围", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "1=全部数据权限,2=自定义数据权限,3=本部门数据权限,4=本部门及以下数据权限,5=仅本人数据权限,6=部门及以下或本人数据权限") + private String dataScope; + + /** + * 菜单树选择项是否关联显示 + */ + @ExcelProperty(value = "菜单树选择项是否关联显示") + private Boolean menuCheckStrictly; + + /** + * 部门树选择项是否关联显示 + */ + @ExcelProperty(value = "部门树选择项是否关联显示") + private Boolean deptCheckStrictly; + + /** + * 角色状态(0正常 1停用) + */ + @ExcelProperty(value = "角色状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private String status; + + /** + * 备注 + */ + @ExcelProperty(value = "备注") + private String remark; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + + /** + * 用户是否存在此角色标识 默认不存在 + */ + private boolean flag = false; + + public boolean isSuperAdmin() { + return SystemConstants.SUPER_ADMIN_ID.equals(this.roleId); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysSocialVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysSocialVo.java new file mode 100644 index 0000000..4e4e00d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysSocialVo.java @@ -0,0 +1,139 @@ +package org.dromara.system.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.system.domain.SysSocial; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * 社会化关系视图对象 sys_social + * + * @author thiszhc + */ +@Data +@AutoMapper(target = SysSocial.class) +public class SysSocialVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 的唯一ID + */ + private String authId; + + /** + * 用户来源 + */ + private String source; + + /** + * 用户的授权令牌 + */ + private String accessToken; + + /** + * 用户的授权令牌的有效期,部分平台可能没有 + */ + private int expireIn; + + /** + * 刷新令牌,部分平台可能没有 + */ + private String refreshToken; + + /** + * 用户的 open id + */ + private String openId; + + /** + * 授权的第三方账号 + */ + private String userName; + + /** + * 授权的第三方昵称 + */ + private String nickName; + + /** + * 授权的第三方邮箱 + */ + private String email; + + /** + * 授权的第三方头像地址 + */ + private String avatar; + + + /** + * 平台的授权信息,部分平台可能没有 + */ + private String accessCode; + + /** + * 用户的 unionid + */ + private String unionId; + + /** + * 授予的权限,部分平台可能没有 + */ + private String scope; + + /** + * 个别平台的授权信息,部分平台可能没有 + */ + private String tokenType; + + /** + * id token,部分平台可能没有 + */ + private String idToken; + + /** + * 小米平台用户的附带属性,部分平台可能没有 + */ + private String macAlgorithm; + + /** + * 小米平台用户的附带属性,部分平台可能没有 + */ + private String macKey; + + /** + * 用户的授权code,部分平台可能没有 + */ + private String code; + + /** + * Twitter平台用户的附带属性,部分平台可能没有 + */ + private String oauthToken; + + /** + * Twitter平台用户的附带属性,部分平台可能没有 + */ + private String oauthTokenSecret; + + /** + * 创建时间 + */ + private Date createTime; +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserExportVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserExportVo.java new file mode 100644 index 0000000..3fe21ff --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserExportVo.java @@ -0,0 +1,94 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 用户对象导出VO + * + * @author pengles + */ + +@Data +@NoArgsConstructor +public class SysUserExportVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + @ExcelProperty(value = "用户序号") + private Long userId; + + /** + * 用户账号 + */ + @ExcelProperty(value = "登录名称") + private String userName; + + /** + * 用户昵称 + */ + @ExcelProperty(value = "用户名称") + private String nickName; + + /** + * 用户邮箱 + */ + @ExcelProperty(value = "用户邮箱") + private String email; + + /** + * 手机号码 + */ + @ExcelProperty(value = "手机号码") + private String phonenumber; + + /** + * 用户性别 + */ + @ExcelProperty(value = "用户性别", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_user_sex") + private String sex; + + /** + * 帐号状态(0正常 1停用) + */ + @ExcelProperty(value = "帐号状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private String status; + + /** + * 最后登录IP + */ + @ExcelProperty(value = "最后登录IP") + private String loginIp; + + /** + * 最后登录时间 + */ + @ExcelProperty(value = "最后登录时间") + private Date loginDate; + + /** + * 部门名称 + */ + @ExcelProperty(value = "部门名称") + private String deptName; + + /** + * 负责人 + */ + @ExcelProperty(value = "部门负责人") + private String leaderName; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserImportVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserImportVo.java new file mode 100644 index 0000000..6334829 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserImportVo.java @@ -0,0 +1,76 @@ +package org.dromara.system.domain.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.dromara.common.excel.annotation.ExcelDictFormat; +import org.dromara.common.excel.convert.ExcelDictConvert; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 用户对象导入VO + * + * @author pengles + */ + +@Data +@NoArgsConstructor +// @Accessors(chain = true) // 导入不允许使用 会找不到set方法 +public class SysUserImportVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + @ExcelProperty(value = "用户序号") + private Long userId; + + /** + * 部门ID + */ + @ExcelProperty(value = "部门编号") + private Long deptId; + + /** + * 用户账号 + */ + @ExcelProperty(value = "登录名称") + private String userName; + + /** + * 用户昵称 + */ + @ExcelProperty(value = "用户名称") + private String nickName; + + /** + * 用户邮箱 + */ + @ExcelProperty(value = "用户邮箱") + private String email; + + /** + * 手机号码 + */ + @ExcelProperty(value = "手机号码") + private String phonenumber; + + /** + * 用户性别 + */ + @ExcelProperty(value = "用户性别", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_user_sex") + private String sex; + + /** + * 帐号状态(0正常 1停用) + */ + @ExcelProperty(value = "帐号状态", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "sys_normal_disable") + private String status; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserInfoVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserInfoVo.java new file mode 100644 index 0000000..88e0a6e --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserInfoVo.java @@ -0,0 +1,40 @@ +package org.dromara.system.domain.vo; + +import lombok.Data; + +import java.util.List; + +/** + * 用户信息 + * + * @author pengles + */ +@Data +public class SysUserInfoVo { + + /** + * 用户信息 + */ + private SysUserVo user; + + /** + * 角色ID列表 + */ + private List roleIds; + + /** + * 角色列表 + */ + private List roles; + + /** + * 岗位ID列表 + */ + private List postIds; + + /** + * 岗位列表 + */ + private List posts; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java new file mode 100644 index 0000000..d28fa08 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java @@ -0,0 +1,137 @@ +package org.dromara.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.sensitive.annotation.Sensitive; +import org.dromara.common.sensitive.core.SensitiveStrategy; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.common.translation.constant.TransConstant; +import org.dromara.system.domain.SysUser; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + + +/** + * 用户信息视图对象 sys_user + * + * @author pengles + */ +@Data +@AutoMapper(target = SysUser.class) +public class SysUserVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + private Long userId; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 用户账号 + */ + private String userName; + + /** + * 用户昵称 + */ + private String nickName; + + /** + * 用户类型(sys_user系统用户) + */ + private String userType; + + /** + * 用户邮箱 + */ + @Sensitive(strategy = SensitiveStrategy.EMAIL, perms = "system:user:edit") + private String email; + + /** + * 手机号码 + */ + @Sensitive(strategy = SensitiveStrategy.PHONE, perms = "system:user:edit") + private String phonenumber; + + /** + * 用户性别(0男 1女 2未知) + */ + private String sex; + + /** + * 头像地址 + */ + @Translation(type = TransConstant.OSS_ID_TO_URL) + private Long avatar; + + /** + * 密码 + */ + @JsonIgnore + @JsonProperty + private String password; + + /** + * 帐号状态(0正常 1停用) + */ + private String status; + + /** + * 最后登录IP + */ + private String loginIp; + + /** + * 最后登录时间 + */ + private Date loginDate; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 部门名 + */ + @Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId") + private String deptName; + + /** + * 角色对象 + */ + private List roles; + + /** + * 角色组 + */ + private Long[] roleIds; + + /** + * 岗位组 + */ + private Long[] postIds; + + /** + * 数据权限 当前角色ID + */ + private Long roleId; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/UserInfoVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/UserInfoVo.java new file mode 100644 index 0000000..9bfeb8b --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/UserInfoVo.java @@ -0,0 +1,30 @@ +package org.dromara.system.domain.vo; + +import lombok.Data; + +import java.util.Set; + +/** + * 登录用户信息 + * + * @author pengles + */ +@Data +public class UserInfoVo { + + /** + * 用户基本信息 + */ + private SysUserVo user; + + /** + * 菜单权限 + */ + private Set permissions; + + /** + * 角色权限 + */ + private Set roles; + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/listener/SysUserImportListener.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/listener/SysUserImportListener.java new file mode 100644 index 0000000..4e95a6e --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/listener/SysUserImportListener.java @@ -0,0 +1,126 @@ +package org.dromara.system.listener; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.crypto.digest.BCrypt; +import cn.hutool.http.HtmlUtil; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.event.AnalysisEventListener; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.excel.core.ExcelListener; +import org.dromara.common.excel.core.ExcelResult; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.bo.SysUserBo; +import org.dromara.system.domain.vo.SysUserImportVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.service.SysConfigService; +import org.dromara.system.service.SysUserService; + +import java.util.List; + +/** + * 系统用户自定义导入 + * + * @author pengles + */ +@Slf4j +public class SysUserImportListener extends AnalysisEventListener implements ExcelListener { + + private final SysUserService userService; + + private final String password; + + private final Boolean isUpdateSupport; + + private final Long operUserId; + private final StringBuilder successMsg = new StringBuilder(); + private final StringBuilder failureMsg = new StringBuilder(); + private int successNum = 0; + private int failureNum = 0; + + public SysUserImportListener(Boolean isUpdateSupport) { + String initPassword = SpringUtils.getBean(SysConfigService.class).selectConfigByKey("sys.user.initPassword"); + this.userService = SpringUtils.getBean(SysUserService.class); + this.password = BCrypt.hashpw(initPassword); + this.isUpdateSupport = isUpdateSupport; + this.operUserId = LoginHelper.getUserId(); + } + + @Override + public void invoke(SysUserImportVo userVo, AnalysisContext context) { + SysUserVo sysUser = this.userService.selectUserByUserName(userVo.getUserName()); + try { + // 验证是否存在这个用户 + if (ObjectUtil.isNull(sysUser)) { + SysUserBo user = BeanUtil.toBean(userVo, SysUserBo.class); + ValidatorUtils.validate(user); + user.setPassword(password); + user.setCreateBy(operUserId); + userService.insertUser(user); + successNum++; + successMsg.append("
").append(successNum).append("、账号 ").append(user.getUserName()).append(" 导入成功"); + } else if (isUpdateSupport) { + Long userId = sysUser.getUserId(); + SysUserBo user = BeanUtil.toBean(userVo, SysUserBo.class); + user.setUserId(userId); + ValidatorUtils.validate(user); + userService.checkUserAllowed(user.getUserId()); + userService.checkUserDataScope(user.getUserId()); + user.setUpdateBy(operUserId); + userService.updateUser(user); + successNum++; + successMsg.append("
").append(successNum).append("、账号 ").append(user.getUserName()).append(" 更新成功"); + } else { + failureNum++; + failureMsg.append("
").append(failureNum).append("、账号 ").append(sysUser.getUserName()).append(" 已存在"); + } + } catch (Exception e) { + failureNum++; + String msg = "
" + failureNum + "、账号 " + HtmlUtil.cleanHtmlTag(userVo.getUserName()) + " 导入失败:"; + String message = e.getMessage(); + if (e instanceof ConstraintViolationException cvException) { + message = StreamUtils.join(cvException.getConstraintViolations(), ConstraintViolation::getMessage, ", "); + } + failureMsg.append(msg).append(message); + log.error(msg, e); + } + } + + @Override + public void doAfterAllAnalysed(AnalysisContext context) { + + } + + @Override + public ExcelResult getExcelResult() { + return new ExcelResult<>() { + + @Override + public String getAnalysis() { + if (failureNum > 0) { + failureMsg.insert(0, "很抱歉,导入失败!共 " + failureNum + " 条数据格式不正确,错误如下:"); + throw new ServiceException(failureMsg.toString()); + } else { + successMsg.insert(0, "恭喜您,数据已全部导入成功!共 " + successNum + " 条,数据如下:"); + } + return successMsg.toString(); + } + + @Override + public List getList() { + return null; + } + + @Override + public List getErrorList() { + return null; + } + }; + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysClientMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysClientMapper.java new file mode 100644 index 0000000..99740e0 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysClientMapper.java @@ -0,0 +1,15 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysClient; +import org.dromara.system.domain.vo.SysClientVo; + +/** + * 授权管理Mapper接口 + * + * @author pengles + * @date 2023-05-15 + */ +public interface SysClientMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysConfigMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysConfigMapper.java new file mode 100644 index 0000000..e847753 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysConfigMapper.java @@ -0,0 +1,14 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysConfig; +import org.dromara.system.domain.vo.SysConfigVo; + +/** + * 参数配置 数据层 + * + * @author pengles + */ +public interface SysConfigMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDeptMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDeptMapper.java new file mode 100644 index 0000000..5042726 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDeptMapper.java @@ -0,0 +1,84 @@ +package org.dromara.system.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Param; +import org.dromara.common.mybatis.annotation.DataColumn; +import org.dromara.common.mybatis.annotation.DataPermission; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.common.mybatis.helper.DataBaseHelper; +import org.dromara.system.domain.SysDept; +import org.dromara.system.domain.vo.SysDeptVo; + +import java.util.List; + +/** + * 部门管理 数据层 + * + * @author pengles + */ +public interface SysDeptMapper extends BaseMapperPlus { + + /** + * 查询部门管理数据 + * + * @param queryWrapper 查询条件 + * @return 部门信息集合 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id") + }) + default List selectDeptList(Wrapper queryWrapper) { + return this.selectVoList(queryWrapper); + } + + /** + * 分页查询部门管理数据 + * + * @param page 分页信息 + * @param queryWrapper 查询条件 + * @return 部门信息集合 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id"), + }) + default Page selectPageDeptList(Page page, Wrapper queryWrapper) { + return this.selectVoPage(page, queryWrapper); + } + + /** + * 统计指定部门ID的部门数量 + * + * @param deptId 部门ID + * @return 该部门ID的部门数量 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id") + }) + default long countDeptById(Long deptId) { + return this.selectCount(new LambdaQueryWrapper().eq(SysDept::getDeptId, deptId)); + } + + /** + * 根据父部门ID查询其所有子部门的列表 + * + * @param parentId 父部门ID + * @return 包含子部门的列表 + */ + default List selectListByParentId(Long parentId) { + return this.selectList(new LambdaQueryWrapper() + .select(SysDept::getDeptId) + .apply(DataBaseHelper.findInSet(parentId, "ancestors"))); + } + + /** + * 根据角色ID查询部门树信息 + * + * @param roleId 角色ID + * @param deptCheckStrictly 部门树选择项是否关联显示 + * @return 选中部门列表 + */ + List selectDeptListByRoleId(@Param("roleId") Long roleId, @Param("deptCheckStrictly") boolean deptCheckStrictly); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDictDataMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDictDataMapper.java new file mode 100644 index 0000000..d55cdb8 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDictDataMapper.java @@ -0,0 +1,29 @@ +package org.dromara.system.mapper; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysDictData; +import org.dromara.system.domain.vo.SysDictDataVo; + +import java.util.List; + +/** + * 字典表 数据层 + * + * @author pengles + */ +public interface SysDictDataMapper extends BaseMapperPlus { + + /** + * 根据字典类型查询字典数据列表 + * + * @param dictType 字典类型 + * @return 符合条件的字典数据列表 + */ + default List selectDictDataByType(String dictType) { + return selectVoList( + new LambdaQueryWrapper() + .eq(SysDictData::getDictType, dictType) + .orderByAsc(SysDictData::getDictSort)); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDictTypeMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDictTypeMapper.java new file mode 100644 index 0000000..c2b7d07 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysDictTypeMapper.java @@ -0,0 +1,14 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysDictType; +import org.dromara.system.domain.vo.SysDictTypeVo; + +/** + * 字典表 数据层 + * + * @author pengles + */ +public interface SysDictTypeMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysLogininforMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysLogininforMapper.java new file mode 100644 index 0000000..8be7831 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysLogininforMapper.java @@ -0,0 +1,14 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysLogininfor; +import org.dromara.system.domain.vo.SysLogininforVo; + +/** + * 系统访问日志情况信息 数据层 + * + * @author pengles + */ +public interface SysLogininforMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysMenuMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysMenuMapper.java new file mode 100644 index 0000000..26bb4bc --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysMenuMapper.java @@ -0,0 +1,76 @@ +package org.dromara.system.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; +import org.apache.ibatis.annotations.Param; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysMenu; +import org.dromara.system.domain.vo.SysMenuVo; + +import java.util.List; + +/** + * 菜单表 数据层 + * + * @author pengles + */ +public interface SysMenuMapper extends BaseMapperPlus { + + /** + * 根据用户查询系统菜单列表 + * + * @param queryWrapper 查询条件 + * @return 菜单列表 + */ + List selectMenuListByUserId(@Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 根据用户ID查询权限 + * + * @param userId 用户ID + * @return 权限列表 + */ + List selectMenuPermsByUserId(Long userId); + + /** + * 根据角色ID查询权限 + * + * @param roleId 角色ID + * @return 权限列表 + */ + List selectMenuPermsByRoleId(Long roleId); + + /** + * 根据用户ID查询菜单 + * + * @return 菜单列表 + */ + default List selectMenuTreeAll() { + LambdaQueryWrapper lqw = new LambdaQueryWrapper() + .in(SysMenu::getMenuType, SystemConstants.TYPE_DIR, SystemConstants.TYPE_MENU) + .eq(SysMenu::getStatus, SystemConstants.NORMAL) + .orderByAsc(SysMenu::getParentId) + .orderByAsc(SysMenu::getOrderNum); + return this.selectList(lqw); + } + + /** + * 根据用户ID查询菜单 + * + * @param userId 用户ID + * @return 菜单列表 + */ + List selectMenuTreeByUserId(Long userId); + + /** + * 根据角色ID查询菜单树信息 + * + * @param roleId 角色ID + * @param menuCheckStrictly 菜单树选择项是否关联显示 + * @return 选中菜单列表 + */ + List selectMenuListByRoleId(@Param("roleId") Long roleId, @Param("menuCheckStrictly") boolean menuCheckStrictly); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysNoticeMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysNoticeMapper.java new file mode 100644 index 0000000..7f9ab7e --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysNoticeMapper.java @@ -0,0 +1,14 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysNotice; +import org.dromara.system.domain.vo.SysNoticeVo; + +/** + * 通知公告表 数据层 + * + * @author pengles + */ +public interface SysNoticeMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOperLogMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOperLogMapper.java new file mode 100644 index 0000000..0989bab --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOperLogMapper.java @@ -0,0 +1,14 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysOperLog; +import org.dromara.system.domain.vo.SysOperLogVo; + +/** + * 操作日志 数据层 + * + * @author pengles + */ +public interface SysOperLogMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOssConfigMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOssConfigMapper.java new file mode 100644 index 0000000..bd29ca9 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOssConfigMapper.java @@ -0,0 +1,14 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysOssConfig; +import org.dromara.system.domain.vo.SysOssConfigVo; + +/** + * 对象存储配置Mapper接口 + * + * @author pengles + */ +public interface SysOssConfigMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOssMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOssMapper.java new file mode 100644 index 0000000..d560ef2 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysOssMapper.java @@ -0,0 +1,13 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysOss; +import org.dromara.system.domain.vo.SysOssVo; + +/** + * 文件上传 数据层 + * + * @author pengles + */ +public interface SysOssMapper extends BaseMapperPlus { +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysPostMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysPostMapper.java new file mode 100644 index 0000000..fa4b963 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysPostMapper.java @@ -0,0 +1,43 @@ +package org.dromara.system.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.dromara.common.mybatis.annotation.DataColumn; +import org.dromara.common.mybatis.annotation.DataPermission; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysPost; +import org.dromara.system.domain.vo.SysPostVo; + +import java.util.List; + +/** + * 岗位信息 数据层 + * + * @author pengles + */ +public interface SysPostMapper extends BaseMapperPlus { + + /** + * 分页查询岗位列表 + * + * @param page 分页对象 + * @param queryWrapper 查询条件 + * @return 包含岗位信息的分页结果 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id"), + @DataColumn(key = "userName", value = "create_by") + }) + default Page selectPagePostList(Page page, Wrapper queryWrapper) { + return this.selectVoPage(page, queryWrapper); + } + + /** + * 查询用户所属岗位组 + * + * @param userId 用户ID + * @return 结果 + */ + List selectPostsByUserId(Long userId); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleDeptMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleDeptMapper.java new file mode 100644 index 0000000..48ac461 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleDeptMapper.java @@ -0,0 +1,13 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysRoleDept; + +/** + * 角色与部门关联表 数据层 + * + * @author pengles + */ +public interface SysRoleDeptMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleMapper.java new file mode 100644 index 0000000..10da450 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleMapper.java @@ -0,0 +1,75 @@ +package org.dromara.system.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Param; +import org.dromara.common.mybatis.annotation.DataColumn; +import org.dromara.common.mybatis.annotation.DataPermission; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysRole; +import org.dromara.system.domain.vo.SysRoleVo; + +import java.util.List; + +/** + * 角色表 数据层 + * + * @author pengles + */ +public interface SysRoleMapper extends BaseMapperPlus { + + /** + * 分页查询角色列表 + * + * @param page 分页对象 + * @param queryWrapper 查询条件 + * @return 包含角色信息的分页结果 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "d.dept_id"), + @DataColumn(key = "userName", value = "r.create_by") + }) + Page selectPageRoleList(@Param("page") Page page, @Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 根据条件分页查询角色数据 + * + * @param queryWrapper 查询条件 + * @return 角色数据集合信息 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "d.dept_id"), + @DataColumn(key = "userName", value = "r.create_by") + }) + List selectRoleList(@Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 根据角色ID查询角色信息 + * + * @param roleId 角色ID + * @return 对应的角色信息 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "d.dept_id"), + @DataColumn(key = "userName", value = "r.create_by") + }) + SysRoleVo selectRoleById(Long roleId); + + /** + * 根据用户ID查询角色 + * + * @param userId 用户ID + * @return 角色列表 + */ + List selectRolePermissionByUserId(Long userId); + + /** + * 根据用户ID查询角色 + * + * @param userId 用户ID + * @return 角色列表 + */ + List selectRolesByUserId(Long userId); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleMenuMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleMenuMapper.java new file mode 100644 index 0000000..13567f0 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysRoleMenuMapper.java @@ -0,0 +1,26 @@ +package org.dromara.system.mapper; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysRoleMenu; + +import java.util.List; + +/** + * 角色与菜单关联表 数据层 + * + * @author pengles + */ +public interface SysRoleMenuMapper extends BaseMapperPlus { + + /** + * 根据菜单ID串删除关联关系 + * + * @param menuIds 菜单ID串 + * @return 结果 + */ + default int deleteByMenuIds(List menuIds) { + return this.delete(new LambdaUpdateWrapper().in(SysRoleMenu::getMenuId, menuIds)); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysSocialMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysSocialMapper.java new file mode 100644 index 0000000..eb2bd2f --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysSocialMapper.java @@ -0,0 +1,16 @@ +package org.dromara.system.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysSocial; +import org.dromara.system.domain.vo.SysSocialVo; + +/** + * 社会化关系Mapper接口 + * + * @author thiszhc + */ +@Mapper +public interface SysSocialMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserMapper.java new file mode 100644 index 0000000..a0e67f6 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserMapper.java @@ -0,0 +1,133 @@ +package org.dromara.system.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.dromara.common.mybatis.annotation.DataColumn; +import org.dromara.common.mybatis.annotation.DataPermission; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysUser; +import org.dromara.system.domain.vo.SysUserExportVo; +import org.dromara.system.domain.vo.SysUserVo; + +import java.util.List; + +/** + * 用户表 数据层 + * + * @author pengles + */ +@Mapper +public interface SysUserMapper extends BaseMapperPlus { + + /** + * 分页查询用户列表,并进行数据权限控制 + * + * @param page 分页参数 + * @param queryWrapper 查询条件 + * @return 分页的用户信息 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id"), + @DataColumn(key = "userName", value = "user_id") + }) + default Page selectPageUserList(Page page, Wrapper queryWrapper) { + return this.selectVoPage(page, queryWrapper); + } + + /** + * 查询用户列表,并进行数据权限控制 + * + * @param queryWrapper 查询条件 + * @return 用户信息集合 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id"), + @DataColumn(key = "userName", value = "user_id") + }) + default List selectUserList(Wrapper queryWrapper) { + return this.selectVoList(queryWrapper); + } + + /** + * 根据条件分页查询用户列表 + * + * @param queryWrapper 查询条件 + * @return 用户信息集合信息 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "d.dept_id"), + @DataColumn(key = "userName", value = "u.user_id") + }) + List selectUserExportList(@Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 根据条件分页查询已配用户角色列表 + * + * @param page 分页信息 + * @param queryWrapper 查询条件 + * @return 用户信息集合信息 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "d.dept_id"), + @DataColumn(key = "userName", value = "u.user_id") + }) + Page selectAllocatedList(@Param("page") Page page, @Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 根据条件分页查询未分配用户角色列表 + * + * @param queryWrapper 查询条件 + * @return 用户信息集合信息 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "d.dept_id"), + @DataColumn(key = "userName", value = "u.user_id") + }) + Page selectUnallocatedList(@Param("page") Page page, @Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 根据用户ID统计用户数量 + * + * @param userId 用户ID + * @return 用户数量 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id"), + @DataColumn(key = "userName", value = "user_id") + }) + default long countUserById(Long userId) { + return this.selectCount(new LambdaQueryWrapper().eq(SysUser::getUserId, userId)); + } + + /** + * 根据条件更新用户数据 + * + * @param user 要更新的用户实体 + * @param updateWrapper 更新条件封装器 + * @return 更新操作影响的行数 + */ + @Override + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id"), + @DataColumn(key = "userName", value = "user_id") + }) + int update(@Param(Constants.ENTITY) SysUser user, @Param(Constants.WRAPPER) Wrapper updateWrapper); + + /** + * 根据用户ID更新用户数据 + * + * @param user 要更新的用户实体 + * @return 更新操作影响的行数 + */ + @Override + @DataPermission({ + @DataColumn(key = "deptName", value = "dept_id"), + @DataColumn(key = "userName", value = "user_id") + }) + int updateById(@Param(Constants.ENTITY) SysUser user); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserPostMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserPostMapper.java new file mode 100644 index 0000000..1fb9c8f --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserPostMapper.java @@ -0,0 +1,13 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysUserPost; + +/** + * 用户与岗位关联表 数据层 + * + * @author pengles + */ +public interface SysUserPostMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserRoleMapper.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserRoleMapper.java new file mode 100644 index 0000000..159d17c --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserRoleMapper.java @@ -0,0 +1,23 @@ +package org.dromara.system.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.system.domain.SysUserRole; + +import java.util.List; + +/** + * 用户与角色关联表 数据层 + * + * @author pengles + */ +public interface SysUserRoleMapper extends BaseMapperPlus { + + /** + * 根据角色ID查询关联的用户ID列表 + * + * @param roleId 角色ID + * @return 关联到指定角色的用户ID列表 + */ + List selectUserIdsByRoleId(Long roleId); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/runner/SystemApplicationRunner.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/runner/SystemApplicationRunner.java new file mode 100644 index 0000000..6aa7ceb --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/runner/SystemApplicationRunner.java @@ -0,0 +1,28 @@ +package org.dromara.system.runner; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.system.service.SysOssConfigService; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * 初始化 system 模块对应业务数据 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class SystemApplicationRunner implements ApplicationRunner { + + private final SysOssConfigService ossConfigService; + + @Override + public void run(ApplicationArguments args) throws Exception { + ossConfigService.init(); + log.info("初始化OSS配置成功"); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysClientService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysClientService.java new file mode 100644 index 0000000..9d85025 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysClientService.java @@ -0,0 +1,58 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysClientBo; +import org.dromara.system.domain.vo.SysClientVo; + +import java.util.Collection; +import java.util.List; + +/** + * 客户端管理Service接口 + * + * @author pengles + */ +public interface SysClientService { + + /** + * 查询客户端管理 + */ + SysClientVo queryById(Long id); + + /** + * 查询客户端信息基于客户端id + */ + SysClientVo queryByClientId(String clientId); + + /** + * 查询客户端管理列表 + */ + TableDataInfo queryPageList(SysClientBo bo, PageQuery pageQuery); + + /** + * 查询客户端管理列表 + */ + List queryList(SysClientBo bo); + + /** + * 新增客户端管理 + */ + Boolean insertByBo(SysClientBo bo); + + /** + * 修改客户端管理 + */ + Boolean updateByBo(SysClientBo bo); + + /** + * 修改状态 + */ + int updateClientStatus(String clientId, String status); + + /** + * 校验并批量删除客户端管理信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysConfigService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysConfigService.java new file mode 100644 index 0000000..a196288 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysConfigService.java @@ -0,0 +1,87 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysConfigBo; +import org.dromara.system.domain.vo.SysConfigVo; + +import java.util.List; + +/** + * 参数配置 服务层 + * + * @author pengles + */ +public interface SysConfigService { + + + TableDataInfo selectPageConfigList(SysConfigBo config, PageQuery pageQuery); + + /** + * 查询参数配置信息 + * + * @param configId 参数配置ID + * @return 参数配置信息 + */ + SysConfigVo selectConfigById(Long configId); + + /** + * 根据键名查询参数配置信息 + * + * @param configKey 参数键名 + * @return 参数键值 + */ + String selectConfigByKey(String configKey); + + /** + * 获取注册开关 + * + * @return true开启,false关闭 + */ + boolean selectRegisterEnabled(); + + /** + * 查询参数配置列表 + * + * @param config 参数配置信息 + * @return 参数配置集合 + */ + List selectConfigList(SysConfigBo config); + + /** + * 新增参数配置 + * + * @param bo 参数配置信息 + * @return 结果 + */ + String insertConfig(SysConfigBo bo); + + /** + * 修改参数配置 + * + * @param bo 参数配置信息 + * @return 结果 + */ + String updateConfig(SysConfigBo bo); + + /** + * 批量删除参数信息 + * + * @param configIds 需要删除的参数ID + */ + void deleteConfigByIds(List configIds); + + /** + * 重置参数缓存数据 + */ + void resetConfigCache(); + + /** + * 校验参数键名是否唯一 + * + * @param config 参数信息 + * @return 结果 + */ + boolean checkConfigKeyUnique(SysConfigBo config); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDataScopeService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDataScopeService.java new file mode 100644 index 0000000..fe1306f --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDataScopeService.java @@ -0,0 +1,26 @@ +package org.dromara.system.service; + +/** + * 通用 数据权限 服务 + * + * @author pengles + */ +public interface SysDataScopeService { + + /** + * 获取角色自定义权限 + * + * @param roleId 角色id + * @return 部门id组 + */ + String getRoleCustom(Long roleId); + + /** + * 获取部门及以下权限 + * + * @param deptId 部门id + * @return 部门id组 + */ + String getDeptAndChild(Long deptId); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDeptService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDeptService.java new file mode 100644 index 0000000..b708d4c --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDeptService.java @@ -0,0 +1,137 @@ +package org.dromara.system.service; + +import cn.hutool.core.lang.tree.Tree; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysDeptBo; +import org.dromara.system.domain.vo.SysDeptVo; + +import java.util.List; + +/** + * 部门管理 服务层 + * + * @author pengles + */ +public interface SysDeptService { + + /** + * 分页查询部门管理数据 + * + * @param dept 部门信息 + * @param pageQuery 分页对象 + * @return 部门信息集合 + */ + TableDataInfo selectPageDeptList(SysDeptBo dept, PageQuery pageQuery); + + /** + * 查询部门管理数据 + * + * @param dept 部门信息 + * @return 部门信息集合 + */ + List selectDeptList(SysDeptBo dept); + + /** + * 查询部门树结构信息 + * + * @param dept 部门信息 + * @return 部门树信息集合 + */ + List> selectDeptTreeList(SysDeptBo dept); + + /** + * 构建前端所需要下拉树结构 + * + * @param depts 部门列表 + * @return 下拉树结构列表 + */ + List> buildDeptTreeSelect(List depts); + + /** + * 根据角色ID查询部门树信息 + * + * @param roleId 角色ID + * @return 选中部门列表 + */ + List selectDeptListByRoleId(Long roleId); + + /** + * 根据部门ID查询信息 + * + * @param deptId 部门ID + * @return 部门信息 + */ + SysDeptVo selectDeptById(Long deptId); + + /** + * 通过部门ID串查询部门 + * + * @param deptIds 部门id串 + * @return 部门列表信息 + */ + List selectDeptByIds(List deptIds); + + /** + * 根据ID查询所有子部门数(正常状态) + * + * @param deptId 部门ID + * @return 子部门数 + */ + long selectNormalChildrenDeptById(Long deptId); + + /** + * 是否存在部门子节点 + * + * @param deptId 部门ID + * @return 结果 + */ + boolean hasChildByDeptId(Long deptId); + + /** + * 查询部门是否存在用户 + * + * @param deptId 部门ID + * @return 结果 true 存在 false 不存在 + */ + boolean checkDeptExistUser(Long deptId); + + /** + * 校验部门名称是否唯一 + * + * @param dept 部门信息 + * @return 结果 + */ + boolean checkDeptNameUnique(SysDeptBo dept); + + /** + * 校验部门是否有数据权限 + * + * @param deptId 部门id + */ + void checkDeptDataScope(Long deptId); + + /** + * 新增保存部门信息 + * + * @param bo 部门信息 + * @return 结果 + */ + int insertDept(SysDeptBo bo); + + /** + * 修改保存部门信息 + * + * @param bo 部门信息 + * @return 结果 + */ + int updateDept(SysDeptBo bo); + + /** + * 删除部门管理信息 + * + * @param deptId 部门ID + * @return 结果 + */ + int deleteDeptById(Long deptId); +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDictDataService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDictDataService.java new file mode 100644 index 0000000..5b62d6a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDictDataService.java @@ -0,0 +1,76 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysDictDataBo; +import org.dromara.system.domain.vo.SysDictDataVo; + +import java.util.List; + +/** + * 字典 业务层 + * + * @author pengles + */ +public interface SysDictDataService { + + + TableDataInfo selectPageDictDataList(SysDictDataBo dictData, PageQuery pageQuery); + + /** + * 根据条件分页查询字典数据 + * + * @param dictData 字典数据信息 + * @return 字典数据集合信息 + */ + List selectDictDataList(SysDictDataBo dictData); + + /** + * 根据字典类型和字典键值查询字典数据信息 + * + * @param dictType 字典类型 + * @param dictValue 字典键值 + * @return 字典标签 + */ + String selectDictLabel(String dictType, String dictValue); + + /** + * 根据字典数据ID查询信息 + * + * @param dictCode 字典数据ID + * @return 字典数据 + */ + SysDictDataVo selectDictDataById(Long dictCode); + + /** + * 批量删除字典数据信息 + * + * @param dictCodes 需要删除的字典数据ID + */ + void deleteDictDataByIds(List dictCodes); + + /** + * 新增保存字典数据信息 + * + * @param bo 字典数据信息 + * @return 结果 + */ + List insertDictData(SysDictDataBo bo); + + /** + * 修改保存字典数据信息 + * + * @param bo 字典数据信息 + * @return 结果 + */ + List updateDictData(SysDictDataBo bo); + + /** + * 校验字典键值是否唯一 + * + * @param dict 字典数据 + * @return 结果 + */ + boolean checkDictDataUnique(SysDictDataBo dict); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDictTypeService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDictTypeService.java new file mode 100644 index 0000000..b6a0a51 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysDictTypeService.java @@ -0,0 +1,95 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysDictTypeBo; +import org.dromara.system.domain.vo.SysDictDataVo; +import org.dromara.system.domain.vo.SysDictTypeVo; + +import java.util.List; + +/** + * 字典 业务层 + * + * @author pengles + */ +public interface SysDictTypeService { + + + TableDataInfo selectPageDictTypeList(SysDictTypeBo dictType, PageQuery pageQuery); + + /** + * 根据条件分页查询字典类型 + * + * @param dictType 字典类型信息 + * @return 字典类型集合信息 + */ + List selectDictTypeList(SysDictTypeBo dictType); + + /** + * 根据所有字典类型 + * + * @return 字典类型集合信息 + */ + List selectDictTypeAll(); + + /** + * 根据字典类型查询字典数据 + * + * @param dictType 字典类型 + * @return 字典数据集合信息 + */ + List selectDictDataByType(String dictType); + + /** + * 根据字典类型ID查询信息 + * + * @param dictId 字典类型ID + * @return 字典类型 + */ + SysDictTypeVo selectDictTypeById(Long dictId); + + /** + * 根据字典类型查询信息 + * + * @param dictType 字典类型 + * @return 字典类型 + */ + SysDictTypeVo selectDictTypeByType(String dictType); + + /** + * 批量删除字典信息 + * + * @param dictIds 需要删除的字典ID + */ + void deleteDictTypeByIds(List dictIds); + + /** + * 重置字典缓存数据 + */ + void resetDictCache(); + + /** + * 新增保存字典类型信息 + * + * @param bo 字典类型信息 + * @return 结果 + */ + List insertDictType(SysDictTypeBo bo); + + /** + * 修改保存字典类型信息 + * + * @param bo 字典类型信息 + * @return 结果 + */ + List updateDictType(SysDictTypeBo bo); + + /** + * 校验字典类型称是否唯一 + * + * @param dictType 字典类型 + * @return 结果 + */ + boolean checkDictTypeUnique(SysDictTypeBo dictType); +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysLogininforService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysLogininforService.java new file mode 100644 index 0000000..c21f1b6 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysLogininforService.java @@ -0,0 +1,47 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysLogininforBo; +import org.dromara.system.domain.vo.SysLogininforVo; + +import java.util.List; + +/** + * 系统访问日志情况信息 服务层 + * + * @author pengles + */ +public interface SysLogininforService { + + + TableDataInfo selectPageLogininforList(SysLogininforBo logininfor, PageQuery pageQuery); + + /** + * 新增系统登录日志 + * + * @param bo 访问日志对象 + */ + void insertLogininfor(SysLogininforBo bo); + + /** + * 查询系统登录日志集合 + * + * @param logininfor 访问日志对象 + * @return 登录记录集合 + */ + List selectLogininforList(SysLogininforBo logininfor); + + /** + * 批量删除系统登录日志 + * + * @param infoIds 需要删除的登录日志ID + * @return 结果 + */ + int deleteLogininforByIds(Long[] infoIds); + + /** + * 清空系统登录日志 + */ + void cleanLogininfor(); +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysMenuService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysMenuService.java new file mode 100644 index 0000000..755baad --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysMenuService.java @@ -0,0 +1,155 @@ +package org.dromara.system.service; + +import cn.hutool.core.lang.tree.Tree; +import org.dromara.system.domain.SysMenu; +import org.dromara.system.domain.bo.SysMenuBo; +import org.dromara.system.domain.vo.RouterVo; +import org.dromara.system.domain.vo.SysMenuVo; + +import java.util.List; +import java.util.Set; + +/** + * 菜单 业务层 + * + * @author pengles + */ +public interface SysMenuService { + + /** + * 根据用户查询系统菜单列表 + * + * @param userId 用户ID + * @return 菜单列表 + */ + List selectMenuList(Long userId); + + /** + * 根据用户查询系统菜单列表 + * + * @param menu 菜单信息 + * @param userId 用户ID + * @return 菜单列表 + */ + List selectMenuList(SysMenuBo menu, Long userId); + + /** + * 根据用户ID查询权限 + * + * @param userId 用户ID + * @return 权限列表 + */ + Set selectMenuPermsByUserId(Long userId); + + /** + * 根据角色ID查询权限 + * + * @param roleId 角色ID + * @return 权限列表 + */ + Set selectMenuPermsByRoleId(Long roleId); + + /** + * 根据用户ID查询菜单树信息 + * + * @param userId 用户ID + * @return 菜单列表 + */ + List selectMenuTreeByUserId(Long userId); + + /** + * 根据角色ID查询菜单树信息 + * + * @param roleId 角色ID + * @return 选中菜单列表 + */ + List selectMenuListByRoleId(Long roleId); + + /** + * 构建前端路由所需要的菜单 + * + * @param menus 菜单列表 + * @return 路由列表 + */ + List buildMenus(List menus); + + /** + * 构建前端所需要下拉树结构 + * + * @param menus 菜单列表 + * @return 下拉树结构列表 + */ + List> buildMenuTreeSelect(List menus); + + /** + * 根据菜单ID查询信息 + * + * @param menuId 菜单ID + * @return 菜单信息 + */ + SysMenuVo selectMenuById(Long menuId); + + /** + * 是否存在菜单子节点 + * + * @param menuId 菜单ID + * @return 结果 true 存在 false 不存在 + */ + boolean hasChildByMenuId(Long menuId); + + /** + * 是否存在菜单子节点 + * + * @param menuIds 菜单ID串 + * @return 结果 true 存在 false 不存在 + */ + boolean hasChildByMenuId(List menuIds); + + /** + * 查询菜单是否存在角色 + * + * @param menuId 菜单ID + * @return 结果 true 存在 false 不存在 + */ + boolean checkMenuExistRole(Long menuId); + + /** + * 新增保存菜单信息 + * + * @param bo 菜单信息 + * @return 结果 + */ + int insertMenu(SysMenuBo bo); + + /** + * 修改保存菜单信息 + * + * @param bo 菜单信息 + * @return 结果 + */ + int updateMenu(SysMenuBo bo); + + /** + * 删除菜单管理信息 + * + * @param menuId 菜单ID + * @return 结果 + */ + int deleteMenuById(Long menuId); + + /** + * 批量删除菜单管理信息 + * + * @param menuIds 菜单ID串 + * @return 结果 + */ + void deleteMenuById(List menuIds); + + /** + * 校验菜单名称是否唯一 + * + * @param menu 菜单信息 + * @return 结果 + */ + boolean checkMenuNameUnique(SysMenuBo menu); +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysNoticeService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysNoticeService.java new file mode 100644 index 0000000..b3e3eb7 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysNoticeService.java @@ -0,0 +1,67 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysNoticeBo; +import org.dromara.system.domain.vo.SysNoticeVo; + +import java.util.List; + +/** + * 公告 服务层 + * + * @author pengles + */ +public interface SysNoticeService { + + + TableDataInfo selectPageNoticeList(SysNoticeBo notice, PageQuery pageQuery); + + /** + * 查询公告信息 + * + * @param noticeId 公告ID + * @return 公告信息 + */ + SysNoticeVo selectNoticeById(Long noticeId); + + /** + * 查询公告列表 + * + * @param notice 公告信息 + * @return 公告集合 + */ + List selectNoticeList(SysNoticeBo notice); + + /** + * 新增公告 + * + * @param bo 公告信息 + * @return 结果 + */ + int insertNotice(SysNoticeBo bo); + + /** + * 修改公告 + * + * @param bo 公告信息 + * @return 结果 + */ + int updateNotice(SysNoticeBo bo); + + /** + * 删除公告信息 + * + * @param noticeId 公告ID + * @return 结果 + */ + int deleteNoticeById(Long noticeId); + + /** + * 批量删除公告信息 + * + * @param noticeIds 需要删除的公告ID + * @return 结果 + */ + int deleteNoticeByIds(Long[] noticeIds); +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOperLogService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOperLogService.java new file mode 100644 index 0000000..0836631 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOperLogService.java @@ -0,0 +1,54 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysOperLogBo; +import org.dromara.system.domain.vo.SysOperLogVo; + +import java.util.List; + +/** + * 操作日志 服务层 + * + * @author pengles + */ +public interface SysOperLogService { + + TableDataInfo selectPageOperLogList(SysOperLogBo operLog, PageQuery pageQuery); + + /** + * 新增操作日志 + * + * @param bo 操作日志对象 + */ + void insertOperlog(SysOperLogBo bo); + + /** + * 查询系统操作日志集合 + * + * @param operLog 操作日志对象 + * @return 操作日志集合 + */ + List selectOperLogList(SysOperLogBo operLog); + + /** + * 批量删除系统操作日志 + * + * @param operIds 需要删除的操作日志ID + * @return 结果 + */ + int deleteOperLogByIds(Long[] operIds); + + /** + * 查询操作日志详细 + * + * @param operId 操作ID + * @return 操作日志对象 + */ + SysOperLogVo selectOperLogById(Long operId); + + /** + * 清空操作日志 + */ + void cleanOperLog(); +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOssConfigService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOssConfigService.java new file mode 100644 index 0000000..7aa7a4f --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOssConfigService.java @@ -0,0 +1,64 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysOssConfigBo; +import org.dromara.system.domain.vo.SysOssConfigVo; + +import java.util.Collection; + +/** + * 对象存储配置Service接口 + * + * @author pengles + * @author 孤舟烟雨 + * @date 2021-08-13 + */ +public interface SysOssConfigService { + + /** + * 初始化OSS配置 + */ + void init(); + + /** + * 查询单个 + */ + SysOssConfigVo queryById(Long ossConfigId); + + /** + * 查询列表 + */ + TableDataInfo queryPageList(SysOssConfigBo bo, PageQuery pageQuery); + + /** + * 根据新增业务对象插入对象存储配置 + * + * @param bo 对象存储配置新增业务对象 + * @return 结果 + */ + Boolean insertByBo(SysOssConfigBo bo); + + /** + * 根据编辑业务对象修改对象存储配置 + * + * @param bo 对象存储配置编辑业务对象 + * @return 结果 + */ + Boolean updateByBo(SysOssConfigBo bo); + + /** + * 校验并删除数据 + * + * @param ids 主键集合 + * @param isValid 是否校验,true-删除前校验,false-不校验 + * @return 结果 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + /** + * 启用停用状态 + */ + int updateOssConfigStatus(SysOssConfigBo bo); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOssService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOssService.java new file mode 100644 index 0000000..9d07de0 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysOssService.java @@ -0,0 +1,80 @@ +package org.dromara.system.service; + +import jakarta.servlet.http.HttpServletResponse; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysOssBo; +import org.dromara.system.domain.vo.SysOssVo; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +/** + * 文件上传 服务层 + * + * @author pengles + */ +public interface SysOssService { + + /** + * 查询OSS对象存储列表 + * + * @param sysOss OSS对象存储分页查询对象 + * @param pageQuery 分页查询实体类 + * @return 结果 + */ + TableDataInfo queryPageList(SysOssBo sysOss, PageQuery pageQuery); + + /** + * 根据一组 ossIds 获取对应的 SysOssVo 列表 + * + * @param ossIds 一组文件在数据库中的唯一标识集合 + * @return 包含 SysOssVo 对象的列表 + */ + List listByIds(Collection ossIds); + + /** + * 根据 ossId 从缓存或数据库中获取 SysOssVo 对象 + * + * @param ossId 文件在数据库中的唯一标识 + * @return SysOssVo 对象,包含文件信息 + */ + SysOssVo getById(Long ossId); + + /** + * 上传 MultipartFile 到对象存储服务,并保存文件信息到数据库 + * + * @param file 要上传的 MultipartFile 对象 + * @return 上传成功后的 SysOssVo 对象,包含文件信息 + */ + SysOssVo upload(MultipartFile file); + + /** + * 上传文件到对象存储服务,并保存文件信息到数据库 + * + * @param file 要上传的文件对象 + * @return 上传成功后的 SysOssVo 对象,包含文件信息 + */ + SysOssVo upload(File file); + + /** + * 文件下载方法,支持一次性下载完整文件 + * + * @param ossId OSS对象ID + * @param response HttpServletResponse对象,用于设置响应头和向客户端发送文件内容 + */ + void download(Long ossId, HttpServletResponse response) throws IOException; + + /** + * 删除OSS对象存储 + * + * @param ids OSS对象ID串 + * @param isValid 判断是否需要校验 + * @return 结果 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysPermissionService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysPermissionService.java new file mode 100644 index 0000000..6c9ab66 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysPermissionService.java @@ -0,0 +1,28 @@ +package org.dromara.system.service; + +import java.util.Set; + +/** + * 用户权限处理 + * + * @author pengles + */ +public interface SysPermissionService { + + /** + * 获取角色数据权限 + * + * @param userId 用户id + * @return 角色权限信息 + */ + Set getRolePermission(Long userId); + + /** + * 获取菜单数据权限 + * + * @param userId 用户id + * @return 菜单权限信息 + */ + Set getMenuPermission(Long userId); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysPostService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysPostService.java new file mode 100644 index 0000000..302a488 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysPostService.java @@ -0,0 +1,135 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysPostBo; +import org.dromara.system.domain.vo.SysPostVo; + +import java.util.List; + +/** + * 岗位信息 服务层 + * + * @author pengles + */ +public interface SysPostService { + + /** + * 分页查询岗位信息集合 + * + * @param post 岗位信息 + * @return 岗位信息集合 + */ + TableDataInfo selectPagePostList(SysPostBo post, PageQuery pageQuery); + + /** + * 查询岗位信息集合 + * + * @param post 岗位信息 + * @return 岗位列表 + */ + List selectPostList(SysPostBo post); + + /** + * 查询用户所属岗位组 + * + * @param userId 用户ID + * @return 岗位ID + */ + List selectPostsByUserId(Long userId); + + /** + * 查询所有岗位 + * + * @return 岗位列表 + */ + List selectPostAll(); + + /** + * 通过岗位ID查询岗位信息 + * + * @param postId 岗位ID + * @return 角色对象信息 + */ + SysPostVo selectPostById(Long postId); + + /** + * 根据用户ID获取岗位选择框列表 + * + * @param userId 用户ID + * @return 选中岗位ID列表 + */ + List selectPostListByUserId(Long userId); + + /** + * 通过岗位ID串查询岗位 + * + * @param postIds 岗位id串 + * @return 岗位列表信息 + */ + List selectPostByIds(List postIds); + + /** + * 校验岗位名称 + * + * @param post 岗位信息 + * @return 结果 + */ + boolean checkPostNameUnique(SysPostBo post); + + /** + * 校验岗位编码 + * + * @param post 岗位信息 + * @return 结果 + */ + boolean checkPostCodeUnique(SysPostBo post); + + /** + * 通过岗位ID查询岗位使用数量 + * + * @param postId 岗位ID + * @return 结果 + */ + long countUserPostById(Long postId); + + /** + * 通过部门ID查询岗位使用数量 + * + * @param deptId 部门id + * @return 结果 + */ + long countPostByDeptId(Long deptId); + + /** + * 删除岗位信息 + * + * @param postId 岗位ID + * @return 结果 + */ + int deletePostById(Long postId); + + /** + * 批量删除岗位信息 + * + * @param postIds 需要删除的岗位ID + * @return 结果 + */ + int deletePostByIds(Long[] postIds); + + /** + * 新增保存岗位信息 + * + * @param bo 岗位信息 + * @return 结果 + */ + int insertPost(SysPostBo bo); + + /** + * 修改保存岗位信息 + * + * @param bo 岗位信息 + * @return 结果 + */ + int updatePost(SysPostBo bo); +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysRoleService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysRoleService.java new file mode 100644 index 0000000..5380d13 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysRoleService.java @@ -0,0 +1,207 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.SysUserRole; +import org.dromara.system.domain.bo.SysRoleBo; +import org.dromara.system.domain.vo.SysRoleVo; + +import java.util.List; +import java.util.Set; + +/** + * 角色业务层 + * + * @author pengles + */ +public interface SysRoleService { + + /** + * 根据条件分页查询角色数据 + * + * @param role 角色信息 + * @return 角色数据集合信息 + */ + TableDataInfo selectPageRoleList(SysRoleBo role, PageQuery pageQuery); + + /** + * 根据条件分页查询角色数据 + * + * @param role 角色信息 + * @return 角色数据集合信息 + */ + List selectRoleList(SysRoleBo role); + + /** + * 根据用户ID查询角色列表 + * + * @param userId 用户ID + * @return 角色列表 + */ + List selectRolesByUserId(Long userId); + + /** + * 根据用户ID查询角色列表(包含被授权状态) + * + * @param userId 用户ID + * @return 角色列表 + */ + List selectRolesAuthByUserId(Long userId); + + /** + * 根据用户ID查询角色权限 + * + * @param userId 用户ID + * @return 权限列表 + */ + Set selectRolePermissionByUserId(Long userId); + + /** + * 查询所有角色 + * + * @return 角色列表 + */ + List selectRoleAll(); + + /** + * 根据用户ID获取角色选择框列表 + * + * @param userId 用户ID + * @return 选中角色ID列表 + */ + List selectRoleListByUserId(Long userId); + + /** + * 通过角色ID查询角色 + * + * @param roleId 角色ID + * @return 角色对象信息 + */ + SysRoleVo selectRoleById(Long roleId); + + /** + * 通过角色ID串查询角色 + * + * @param roleIds 角色ID串 + * @return 角色列表信息 + */ + List selectRoleByIds(List roleIds); + + /** + * 校验角色名称是否唯一 + * + * @param role 角色信息 + * @return 结果 + */ + boolean checkRoleNameUnique(SysRoleBo role); + + /** + * 校验角色权限是否唯一 + * + * @param role 角色信息 + * @return 结果 + */ + boolean checkRoleKeyUnique(SysRoleBo role); + + /** + * 校验角色是否允许操作 + * + * @param role 角色信息 + */ + void checkRoleAllowed(SysRoleBo role); + + /** + * 校验角色是否有数据权限 + * + * @param roleId 角色id + */ + void checkRoleDataScope(Long roleId); + + /** + * 通过角色ID查询角色使用数量 + * + * @param roleId 角色ID + * @return 结果 + */ + long countUserRoleByRoleId(Long roleId); + + /** + * 新增保存角色信息 + * + * @param bo 角色信息 + * @return 结果 + */ + int insertRole(SysRoleBo bo); + + /** + * 修改保存角色信息 + * + * @param bo 角色信息 + * @return 结果 + */ + int updateRole(SysRoleBo bo); + + /** + * 修改角色状态 + * + * @param roleId 角色ID + * @param status 角色状态 + * @return 结果 + */ + int updateRoleStatus(Long roleId, String status); + + /** + * 修改数据权限信息 + * + * @param bo 角色信息 + * @return 结果 + */ + int authDataScope(SysRoleBo bo); + + /** + * 通过角色ID删除角色 + * + * @param roleId 角色ID + * @return 结果 + */ + int deleteRoleById(Long roleId); + + /** + * 批量删除角色信息 + * + * @param roleIds 需要删除的角色ID + * @return 结果 + */ + int deleteRoleByIds(Long[] roleIds); + + /** + * 取消授权用户角色 + * + * @param userRole 用户和角色关联信息 + * @return 结果 + */ + int deleteAuthUser(SysUserRole userRole); + + /** + * 批量取消授权用户角色 + * + * @param roleId 角色ID + * @param userIds 需要取消授权的用户数据ID + * @return 结果 + */ + int deleteAuthUsers(Long roleId, Long[] userIds); + + /** + * 批量选择授权用户角色 + * + * @param roleId 角色ID + * @param userIds 需要删除的用户数据ID + * @return 结果 + */ + int insertAuthUsers(Long roleId, Long[] userIds); + + void cleanOnlineUserByRole(Long roleId); + + void cleanOnlineUser(List userIds); + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysSocialService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysSocialService.java new file mode 100644 index 0000000..44dd4a4 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysSocialService.java @@ -0,0 +1,53 @@ +package org.dromara.system.service; + +import org.dromara.system.domain.bo.SysSocialBo; +import org.dromara.system.domain.vo.SysSocialVo; + +import java.util.List; + +/** + * 社会化关系Service接口 + * + * @author pengles + */ +public interface SysSocialService { + + + /** + * 查询社会化关系 + */ + SysSocialVo queryById(String id); + + /** + * 查询社会化关系列表 + */ + List queryList(SysSocialBo bo); + + /** + * 查询社会化关系列表 + */ + List queryListByUserId(Long userId); + + /** + * 新增授权关系 + */ + Boolean insertByBo(SysSocialBo bo); + + /** + * 更新社会化关系 + */ + Boolean updateByBo(SysSocialBo bo); + + /** + * 删除社会化关系信息 + */ + Boolean deleteWithValidById(Long id); + + + /** + * 根据 authId 查询 + */ + List selectByAuthId(String authId); + + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysUserService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysUserService.java new file mode 100644 index 0000000..ec98e6f --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysUserService.java @@ -0,0 +1,247 @@ +package org.dromara.system.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysUserBo; +import org.dromara.system.domain.vo.SysUserExportVo; +import org.dromara.system.domain.vo.SysUserVo; + +import java.util.List; + +/** + * 用户 业务层 + * + * @author pengles + */ +public interface SysUserService { + + + /** + * 根据条件分页查询用户列表 + * + * @param user 用户信息 + * @param pageQuery 发呢也 + * @return 用户信息 + */ + TableDataInfo selectPageUserList(SysUserBo user, PageQuery pageQuery); + + /** + * 导出用户列表 + * + * @param user 用户信息 + * @return 用户信息集合信息 + */ + List selectUserExportList(SysUserBo user); + + /** + * 根据条件分页查询已分配用户角色列表 + * + * @param user 用户信息 + * @param pageQuery 分页 + * @return 用户信息集合信息 + */ + TableDataInfo selectAllocatedList(SysUserBo user, PageQuery pageQuery); + + /** + * 根据条件分页查询未分配用户角色列表 + * + * @param user 用户信息 + * @param pageQuery 分页 + * @return 用户信息集合信息 + */ + TableDataInfo selectUnallocatedList(SysUserBo user, PageQuery pageQuery); + + /** + * 通过用户名查询用户 + * + * @param userName 用户名 + * @return 用户对象信息 + */ + SysUserVo selectUserByUserName(String userName); + + /** + * 通过手机号查询用户 + * + * @param phonenumber 手机号 + * @return 用户对象信息 + */ + SysUserVo selectUserByPhonenumber(String phonenumber); + + /** + * 通过邮箱查询用户 + * + * @param email 邮箱 + * @return 用户对象信息 + */ + SysUserVo selectUserByEmail(String email); + + /** + * 更新用户登录信息 + * + * @param userId 用户ID + * @param ip 登录IP + */ + void updateLoginInfo(Long userId, String ip); + + /** + * 通过用户ID查询用户 + * + * @param userId 用户ID + * @return 用户对象信息 + */ + SysUserVo selectUserById(Long userId); + + /** + * 通过用户ID串查询用户 + * + * @param userIds 用户ID串 + * @param deptId 部门id + * @return 用户列表信息 + */ + List selectUserByIds(List userIds, Long deptId); + + /** + * 根据用户ID查询用户所属角色组 + * + * @param userId 用户ID + * @return 结果 + */ + String selectUserRoleGroup(Long userId); + + /** + * 根据用户ID查询用户所属岗位组 + * + * @param userId 用户ID + * @return 结果 + */ + String selectUserPostGroup(Long userId); + + /** + * 校验用户名称是否唯一 + * + * @param user 用户信息 + * @return 结果 + */ + boolean checkUserNameUnique(SysUserBo user); + + /** + * 校验手机号码是否唯一 + * + * @param user 用户信息 + * @return 结果 + */ + boolean checkPhoneUnique(SysUserBo user); + + /** + * 校验email是否唯一 + * + * @param user 用户信息 + * @return 结果 + */ + boolean checkEmailUnique(SysUserBo user); + + /** + * 校验用户是否允许操作 + * + * @param userId 用户ID + */ + void checkUserAllowed(Long userId); + + /** + * 校验用户是否有数据权限 + * + * @param userId 用户id + */ + void checkUserDataScope(Long userId); + + /** + * 新增用户信息 + * + * @param user 用户信息 + * @return 结果 + */ + int insertUser(SysUserBo user); + + /** + * 注册用户信息 + * + * @param user 用户信息 + * @return 结果 + */ + boolean registerUser(SysUserBo user); + + /** + * 修改用户信息 + * + * @param user 用户信息 + * @return 结果 + */ + int updateUser(SysUserBo user); + + /** + * 用户授权角色 + * + * @param userId 用户ID + * @param roleIds 角色组 + */ + void insertUserAuth(Long userId, Long[] roleIds); + + /** + * 修改用户状态 + * + * @param userId 用户ID + * @param status 帐号状态 + * @return 结果 + */ + int updateUserStatus(Long userId, String status); + + /** + * 修改用户基本信息 + * + * @param user 用户信息 + * @return 结果 + */ + int updateUserProfile(SysUserBo user); + + /** + * 修改用户头像 + * + * @param userId 用户ID + * @param avatar 头像地址 + * @return 结果 + */ + boolean updateUserAvatar(Long userId, Long avatar); + + /** + * 重置用户密码 + * + * @param userId 用户ID + * @param password 密码 + * @return 结果 + */ + int resetUserPwd(Long userId, String password); + + /** + * 通过用户ID删除用户 + * + * @param userId 用户ID + * @return 结果 + */ + int deleteUserById(Long userId); + + /** + * 批量删除用户信息 + * + * @param userIds 需要删除的用户ID + * @return 结果 + */ + int deleteUserByIds(Long[] userIds); + + /** + * 通过部门id查询当前部门所有用户 + * + * @param deptId 部门id + * @return 结果 + */ + List selectUserListByDept(Long deptId); +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysClientServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysClientServiceImpl.java new file mode 100644 index 0000000..3920e9a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysClientServiceImpl.java @@ -0,0 +1,138 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.crypto.SecureUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.SysClient; +import org.dromara.system.domain.bo.SysClientBo; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.mapper.SysClientMapper; +import org.dromara.system.service.SysClientService; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; + +/** + * 客户端管理Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class SysClientServiceImpl implements SysClientService { + + private final SysClientMapper baseMapper; + + /** + * 查询客户端管理 + */ + @Override + public SysClientVo queryById(Long id) { + SysClientVo vo = baseMapper.selectVoById(id); + vo.setGrantTypeList(StringUtils.splitList(vo.getGrantType())); + return vo; + } + + /** + * 查询客户端管理 + */ + @Cacheable(cacheNames = CacheNames.SYS_CLIENT, key = "#clientId") + @Override + public SysClientVo queryByClientId(String clientId) { + return baseMapper.selectVoOne(new LambdaQueryWrapper().eq(SysClient::getClientId, clientId)); + } + + /** + * 查询客户端管理列表 + */ + @Override + public TableDataInfo queryPageList(SysClientBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + result.getRecords().forEach(r -> r.setGrantTypeList(StringUtils.splitList(r.getGrantType()))); + return TableDataInfo.build(result); + } + + /** + * 查询客户端管理列表 + */ + @Override + public List queryList(SysClientBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(SysClientBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getClientId()), SysClient::getClientId, bo.getClientId()); + lqw.eq(StringUtils.isNotBlank(bo.getClientKey()), SysClient::getClientKey, bo.getClientKey()); + lqw.eq(StringUtils.isNotBlank(bo.getClientSecret()), SysClient::getClientSecret, bo.getClientSecret()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), SysClient::getStatus, bo.getStatus()); + lqw.orderByAsc(SysClient::getId); + return lqw; + } + + /** + * 新增客户端管理 + */ + @Override + public Boolean insertByBo(SysClientBo bo) { + SysClient add = MapstructUtils.convert(bo, SysClient.class); + add.setGrantType(CollUtil.join(bo.getGrantTypeList(), StringUtils.SEPARATOR)); + // 生成clientid + String clientKey = bo.getClientKey(); + String clientSecret = bo.getClientSecret(); + add.setClientId(SecureUtil.md5(clientKey + clientSecret)); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改客户端管理 + */ + @CacheEvict(cacheNames = CacheNames.SYS_CLIENT, key = "#bo.clientId") + @Override + public Boolean updateByBo(SysClientBo bo) { + SysClient update = MapstructUtils.convert(bo, SysClient.class); + update.setGrantType(String.join(",", bo.getGrantTypeList())); + return baseMapper.updateById(update) > 0; + } + + /** + * 修改状态 + */ + @CacheEvict(cacheNames = CacheNames.SYS_CLIENT, key = "#clientId") + @Override + public int updateClientStatus(String clientId, String status) { + return baseMapper.update(null, + new LambdaUpdateWrapper() + .set(SysClient::getStatus, status) + .eq(SysClient::getClientId, clientId)); + } + + /** + * 批量删除客户端管理 + */ + @CacheEvict(cacheNames = CacheNames.SYS_CLIENT, allEntries = true) + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysConfigServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysConfigServiceImpl.java new file mode 100644 index 0000000..716493d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysConfigServiceImpl.java @@ -0,0 +1,205 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.ConfigService; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.ObjectUtils; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.redis.utils.CacheUtils; +import org.dromara.system.domain.SysConfig; +import org.dromara.system.domain.bo.SysConfigBo; +import org.dromara.system.domain.vo.SysConfigVo; +import org.dromara.system.mapper.SysConfigMapper; +import org.dromara.system.service.SysConfigService; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * 参数配置 服务层实现 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysConfigServiceImpl implements SysConfigService, ConfigService { + + private final SysConfigMapper baseMapper; + + @Override + public TableDataInfo selectPageConfigList(SysConfigBo config, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(config); + Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + /** + * 查询参数配置信息 + * + * @param configId 参数配置ID + * @return 参数配置信息 + */ + @Override + public SysConfigVo selectConfigById(Long configId) { + return baseMapper.selectVoById(configId); + } + + /** + * 根据键名查询参数配置信息 + * + * @param configKey 参数key + * @return 参数键值 + */ + @Cacheable(cacheNames = CacheNames.SYS_CONFIG, key = "#configKey") + @Override + public String selectConfigByKey(String configKey) { + SysConfig retConfig = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(SysConfig::getConfigKey, configKey)); + return ObjectUtils.notNullGetter(retConfig, SysConfig::getConfigValue, StringUtils.EMPTY); + } + + /** + * 获取注册开关 + * + * @return true开启,false关闭 + */ + @Override + public boolean selectRegisterEnabled() { + String configValue = this.selectConfigByKey("sys.account.registerUser"); + return Convert.toBool(configValue); + } + + /** + * 查询参数配置列表 + * + * @param config 参数配置信息 + * @return 参数配置集合 + */ + @Override + public List selectConfigList(SysConfigBo config) { + LambdaQueryWrapper lqw = buildQueryWrapper(config); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(SysConfigBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getConfigName()), SysConfig::getConfigName, bo.getConfigName()); + lqw.eq(StringUtils.isNotBlank(bo.getConfigType()), SysConfig::getConfigType, bo.getConfigType()); + lqw.like(StringUtils.isNotBlank(bo.getConfigKey()), SysConfig::getConfigKey, bo.getConfigKey()); + lqw.between(params.get("beginTime") != null && params.get("endTime") != null, + SysConfig::getCreateTime, params.get("beginTime"), params.get("endTime")); + lqw.orderByAsc(SysConfig::getConfigId); + return lqw; + } + + /** + * 新增参数配置 + * + * @param bo 参数配置信息 + * @return 结果 + */ + @CachePut(cacheNames = CacheNames.SYS_CONFIG, key = "#bo.configKey") + @Override + public String insertConfig(SysConfigBo bo) { + SysConfig config = MapstructUtils.convert(bo, SysConfig.class); + int row = baseMapper.insert(config); + if (row > 0) { + return config.getConfigValue(); + } + throw new ServiceException("操作失败"); + } + + /** + * 修改参数配置 + * + * @param bo 参数配置信息 + * @return 结果 + */ + @CachePut(cacheNames = CacheNames.SYS_CONFIG, key = "#bo.configKey") + @Override + public String updateConfig(SysConfigBo bo) { + int row = 0; + SysConfig config = MapstructUtils.convert(bo, SysConfig.class); + if (config.getConfigId() != null) { + SysConfig temp = baseMapper.selectById(config.getConfigId()); + if (!StringUtils.equals(temp.getConfigKey(), config.getConfigKey())) { + CacheUtils.evict(CacheNames.SYS_CONFIG, temp.getConfigKey()); + } + row = baseMapper.updateById(config); + } else { + CacheUtils.evict(CacheNames.SYS_CONFIG, config.getConfigKey()); + row = baseMapper.update(config, new LambdaQueryWrapper() + .eq(SysConfig::getConfigKey, config.getConfigKey())); + } + if (row > 0) { + return config.getConfigValue(); + } + throw new ServiceException("操作失败"); + } + + /** + * 批量删除参数信息 + * + * @param configIds 需要删除的参数ID + */ + @Override + public void deleteConfigByIds(List configIds) { + List list = baseMapper.selectByIds(configIds); + list.forEach(config -> { + if (StringUtils.equals(SystemConstants.YES, config.getConfigType())) { + throw new ServiceException(String.format("内置参数【%s】不能删除", config.getConfigKey())); + } + CacheUtils.evict(CacheNames.SYS_CONFIG, config.getConfigKey()); + }); + baseMapper.deleteByIds(configIds); + } + + /** + * 重置参数缓存数据 + */ + @Override + public void resetConfigCache() { + CacheUtils.clear(CacheNames.SYS_CONFIG); + } + + /** + * 校验参数键名是否唯一 + * + * @param config 参数配置信息 + * @return 结果 + */ + @Override + public boolean checkConfigKeyUnique(SysConfigBo config) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysConfig::getConfigKey, config.getConfigKey()) + .ne(ObjectUtil.isNotNull(config.getConfigId()), SysConfig::getConfigId, config.getConfigId())); + return !exist; + } + + /** + * 根据参数 key 获取参数值 + * + * @param configKey 参数 key + * @return 参数值 + */ + @Override + public String getConfigValue(String configKey) { + return SpringUtils.getAopProxy(this).selectConfigByKey(configKey); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDataScopeServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDataScopeServiceImpl.java new file mode 100644 index 0000000..a742dc5 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDataScopeServiceImpl.java @@ -0,0 +1,78 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.system.domain.SysDept; +import org.dromara.system.domain.SysRoleDept; +import org.dromara.system.mapper.SysDeptMapper; +import org.dromara.system.mapper.SysRoleDeptMapper; +import org.dromara.system.service.SysDataScopeService; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 数据权限 实现 + *

+ * 注意: 此Service内不允许调用标注`数据权限`注解的方法 + * 例如: deptMapper.selectList 此 selectList 方法标注了`数据权限`注解 会出现循环解析的问题 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service("sdss") +public class SysDataScopeServiceImpl implements SysDataScopeService { + + private final SysRoleDeptMapper roleDeptMapper; + private final SysDeptMapper deptMapper; + + /** + * 获取角色自定义权限 + * + * @param roleId 角色Id + * @return 部门Id组 + */ + @Cacheable(cacheNames = CacheNames.SYS_ROLE_CUSTOM, key = "#roleId", condition = "#roleId != null") + @Override + public String getRoleCustom(Long roleId) { + if (ObjectUtil.isNull(roleId)) { + return "-1"; + } + List list = roleDeptMapper.selectList( + new LambdaQueryWrapper() + .select(SysRoleDept::getDeptId) + .eq(SysRoleDept::getRoleId, roleId)); + if (CollUtil.isNotEmpty(list)) { + return StreamUtils.join(list, rd -> Convert.toStr(rd.getDeptId())); + } + return "-1"; + } + + /** + * 获取部门及以下权限 + * + * @param deptId 部门Id + * @return 部门Id组 + */ + @Cacheable(cacheNames = CacheNames.SYS_DEPT_AND_CHILD, key = "#deptId", condition = "#deptId != null") + @Override + public String getDeptAndChild(Long deptId) { + if (ObjectUtil.isNull(deptId)) { + return "-1"; + } + List deptList = deptMapper.selectListByParentId(deptId); + List ids = StreamUtils.toList(deptList, SysDept::getDeptId); + ids.add(deptId); + if (CollUtil.isNotEmpty(ids)) { + return StreamUtils.join(ids, Convert::toStr); + } + return "-1"; + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDeptServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDeptServiceImpl.java new file mode 100644 index 0000000..fc29ebd --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDeptServiceImpl.java @@ -0,0 +1,412 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.tree.Tree; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.dto.DeptDTO; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.DeptService; +import org.dromara.common.core.utils.*; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.mybatis.helper.DataBaseHelper; +import org.dromara.common.redis.utils.CacheUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.SysDept; +import org.dromara.system.domain.SysRole; +import org.dromara.system.domain.SysUser; +import org.dromara.system.domain.bo.SysDeptBo; +import org.dromara.system.domain.vo.SysDeptVo; +import org.dromara.system.mapper.SysDeptMapper; +import org.dromara.system.mapper.SysRoleMapper; +import org.dromara.system.mapper.SysUserMapper; +import org.dromara.system.service.SysDeptService; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * 部门管理 服务实现 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysDeptServiceImpl implements SysDeptService, DeptService { + + private final SysDeptMapper baseMapper; + private final SysRoleMapper roleMapper; + private final SysUserMapper userMapper; + + /** + * 分页查询部门管理数据 + * + * @param dept 部门信息 + * @param pageQuery 分页对象 + * @return 部门信息集合 + */ + @Override + public TableDataInfo selectPageDeptList(SysDeptBo dept, PageQuery pageQuery) { + Page page = baseMapper.selectPageDeptList(pageQuery.build(), buildQueryWrapper(dept)); + return TableDataInfo.build(page); + } + + /** + * 查询部门管理数据 + * + * @param dept 部门信息 + * @return 部门信息集合 + */ + @Override + public List selectDeptList(SysDeptBo dept) { + LambdaQueryWrapper lqw = buildQueryWrapper(dept); + return baseMapper.selectDeptList(lqw); + } + + /** + * 查询部门树结构信息 + * + * @param bo 部门信息 + * @return 部门树信息集合 + */ + @Override + public List> selectDeptTreeList(SysDeptBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + List depts = baseMapper.selectDeptList(lqw); + return buildDeptTreeSelect(depts); + } + + private LambdaQueryWrapper buildQueryWrapper(SysDeptBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(SysDept::getDelFlag, SystemConstants.NORMAL); + lqw.eq(ObjectUtil.isNotNull(bo.getDeptId()), SysDept::getDeptId, bo.getDeptId()); + lqw.eq(ObjectUtil.isNotNull(bo.getParentId()), SysDept::getParentId, bo.getParentId()); + lqw.like(StringUtils.isNotBlank(bo.getDeptName()), SysDept::getDeptName, bo.getDeptName()); + lqw.like(StringUtils.isNotBlank(bo.getDeptCategory()), SysDept::getDeptCategory, bo.getDeptCategory()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), SysDept::getStatus, bo.getStatus()); + lqw.between(params.get("beginTime") != null && params.get("endTime") != null, + SysDept::getCreateTime, params.get("beginTime"), params.get("endTime")); + lqw.orderByAsc(SysDept::getAncestors); + lqw.orderByAsc(SysDept::getParentId); + lqw.orderByAsc(SysDept::getOrderNum); + lqw.orderByAsc(SysDept::getDeptId); + if (ObjectUtil.isNotNull(bo.getBelongDeptId())) { + //部门树搜索 + lqw.and(x -> { + Long parentId = bo.getBelongDeptId(); + List deptList = baseMapper.selectListByParentId(parentId); + List deptIds = StreamUtils.toList(deptList, SysDept::getDeptId); + deptIds.add(parentId); + x.in(SysDept::getDeptId, deptIds); + }); + } + return lqw; + } + + /** + * 构建前端所需要下拉树结构 + * + * @param depts 部门列表 + * @return 下拉树结构列表 + */ + @Override + public List> buildDeptTreeSelect(List depts) { + if (CollUtil.isEmpty(depts)) { + return CollUtil.newArrayList(); + } + return TreeBuildUtils.buildMultiRoot( + depts, + SysDeptVo::getDeptId, + SysDeptVo::getParentId, + (node, treeNode) -> treeNode + .setId(node.getDeptId()) + .setParentId(node.getParentId()) + .setName(node.getDeptName()) + .setWeight(node.getOrderNum()) + .putExtra("disabled", SystemConstants.DISABLE.equals(node.getStatus())) + ); + } + + /** + * 根据角色ID查询部门树信息 + * + * @param roleId 角色ID + * @return 选中部门列表 + */ + @Override + public List selectDeptListByRoleId(Long roleId) { + SysRole role = roleMapper.selectById(roleId); + return baseMapper.selectDeptListByRoleId(roleId, role.getDeptCheckStrictly()); + } + + /** + * 根据部门ID查询信息 + * + * @param deptId 部门ID + * @return 部门信息 + */ + @Cacheable(cacheNames = CacheNames.SYS_DEPT, key = "#deptId") + @Override + public SysDeptVo selectDeptById(Long deptId) { + SysDeptVo dept = baseMapper.selectVoById(deptId); + if (ObjectUtil.isNull(dept)) { + return null; + } + SysDeptVo parentDept = baseMapper.selectVoOne(new LambdaQueryWrapper() + .select(SysDept::getDeptName).eq(SysDept::getDeptId, dept.getParentId())); + dept.setParentName(ObjectUtils.notNullGetter(parentDept, SysDeptVo::getDeptName)); + return dept; + } + + @Override + public List selectDeptByIds(List deptIds) { + return baseMapper.selectDeptList(new LambdaQueryWrapper() + .select(SysDept::getDeptId, SysDept::getDeptName, SysDept::getLeader) + .eq(SysDept::getStatus, SystemConstants.NORMAL) + .in(CollUtil.isNotEmpty(deptIds), SysDept::getDeptId, deptIds)); + } + + /** + * 通过部门ID查询部门名称 + * + * @param deptIds 部门ID串逗号分隔 + * @return 部门名称串逗号分隔 + */ + @Override + public String selectDeptNameByIds(String deptIds) { + List list = new ArrayList<>(); + for (Long id : StringUtils.splitTo(deptIds, Convert::toLong)) { + SysDeptVo vo = SpringUtils.getAopProxy(this).selectDeptById(id); + if (ObjectUtil.isNotNull(vo)) { + list.add(vo.getDeptName()); + } + } + return String.join(StringUtils.SEPARATOR, list); + } + + /** + * 根据部门ID查询部门负责人 + * + * @param deptId 部门ID,用于指定需要查询的部门 + * @return 返回该部门的负责人ID + */ + @Override + public Long selectDeptLeaderById(Long deptId) { + SysDeptVo vo = SpringUtils.getAopProxy(this).selectDeptById(deptId); + return vo.getLeader(); + } + + /** + * 查询部门 + * + * @return 部门列表 + */ + @Override + public List selectDeptsByList() { + List list = baseMapper.selectDeptList(new LambdaQueryWrapper() + .select(SysDept::getDeptId, SysDept::getDeptName, SysDept::getParentId) + .eq(SysDept::getStatus, SystemConstants.NORMAL)); + return BeanUtil.copyToList(list, DeptDTO.class); + } + + /** + * 根据ID查询所有子部门数(正常状态) + * + * @param deptId 部门ID + * @return 子部门数 + */ + @Override + public long selectNormalChildrenDeptById(Long deptId) { + return baseMapper.selectCount(new LambdaQueryWrapper() + .eq(SysDept::getStatus, SystemConstants.NORMAL) + .apply(DataBaseHelper.findInSet(deptId, "ancestors"))); + } + + /** + * 是否存在子节点 + * + * @param deptId 部门ID + * @return 结果 + */ + @Override + public boolean hasChildByDeptId(Long deptId) { + return baseMapper.exists(new LambdaQueryWrapper() + .eq(SysDept::getParentId, deptId)); + } + + /** + * 查询部门是否存在用户 + * + * @param deptId 部门ID + * @return 结果 true 存在 false 不存在 + */ + @Override + public boolean checkDeptExistUser(Long deptId) { + return userMapper.exists(new LambdaQueryWrapper() + .eq(SysUser::getDeptId, deptId)); + } + + /** + * 校验部门名称是否唯一 + * + * @param dept 部门信息 + * @return 结果 + */ + @Override + public boolean checkDeptNameUnique(SysDeptBo dept) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysDept::getDeptName, dept.getDeptName()) + .eq(SysDept::getParentId, dept.getParentId()) + .ne(ObjectUtil.isNotNull(dept.getDeptId()), SysDept::getDeptId, dept.getDeptId())); + return !exist; + } + + /** + * 校验部门是否有数据权限 + * + * @param deptId 部门id + */ + @Override + public void checkDeptDataScope(Long deptId) { + if (ObjectUtil.isNull(deptId)) { + return; + } + if (LoginHelper.isSuperAdmin()) { + return; + } + if (baseMapper.countDeptById(deptId) == 0) { + throw new ServiceException("没有权限访问部门数据!"); + } + } + + /** + * 新增保存部门信息 + * + * @param bo 部门信息 + * @return 结果 + */ + @CacheEvict(cacheNames = CacheNames.SYS_DEPT_AND_CHILD, allEntries = true) + @Override + public int insertDept(SysDeptBo bo) { + SysDept info = baseMapper.selectById(bo.getParentId()); + // 如果父节点不为正常状态,则不允许新增子节点 + if (!SystemConstants.NORMAL.equals(info.getStatus())) { + throw new ServiceException("部门停用,不允许新增"); + } + SysDept dept = MapstructUtils.convert(bo, SysDept.class); + dept.setAncestors(info.getAncestors() + StringUtils.SEPARATOR + dept.getParentId()); + return baseMapper.insert(dept); + } + + /** + * 修改保存部门信息 + * + * @param bo 部门信息 + * @return 结果 + */ + @Caching(evict = { + @CacheEvict(cacheNames = CacheNames.SYS_DEPT, key = "#bo.deptId"), + @CacheEvict(cacheNames = CacheNames.SYS_DEPT_AND_CHILD, allEntries = true) + }) + @Override + @Transactional(rollbackFor = Exception.class) + public int updateDept(SysDeptBo bo) { + SysDept dept = MapstructUtils.convert(bo, SysDept.class); + SysDept oldDept = baseMapper.selectById(dept.getDeptId()); + if (ObjectUtil.isNull(oldDept)) { + throw new ServiceException("部门不存在,无法修改"); + } + if (!oldDept.getParentId().equals(dept.getParentId())) { + // 如果是新父部门 则校验是否具有新父部门权限 避免越权 + this.checkDeptDataScope(dept.getParentId()); + SysDept newParentDept = baseMapper.selectById(dept.getParentId()); + if (ObjectUtil.isNotNull(newParentDept)) { + String newAncestors = newParentDept.getAncestors() + StringUtils.SEPARATOR + newParentDept.getDeptId(); + String oldAncestors = oldDept.getAncestors(); + dept.setAncestors(newAncestors); + updateDeptChildren(dept.getDeptId(), newAncestors, oldAncestors); + } + } else { + dept.setAncestors(oldDept.getAncestors()); + } + int result = baseMapper.updateById(dept); + // 如果部门状态为启用,且部门祖级列表不为空,且部门祖级列表不等于根部门祖级列表(如果部门祖级列表不等于根部门祖级列表,则说明存在上级部门) + if (SystemConstants.NORMAL.equals(dept.getStatus()) + && StringUtils.isNotEmpty(dept.getAncestors()) + && !StringUtils.equals(SystemConstants.ROOT_DEPT_ANCESTORS, dept.getAncestors())) { + // 如果该部门是启用状态,则启用该部门的所有上级部门 + updateParentDeptStatusNormal(dept); + } + return result; + } + + /** + * 修改该部门的父级部门状态 + * + * @param dept 当前部门 + */ + private void updateParentDeptStatusNormal(SysDept dept) { + String ancestors = dept.getAncestors(); + Long[] deptIds = Convert.toLongArray(ancestors); + baseMapper.update(null, new LambdaUpdateWrapper() + .set(SysDept::getStatus, SystemConstants.NORMAL) + .in(SysDept::getDeptId, Arrays.asList(deptIds))); + } + + /** + * 修改子元素关系 + * + * @param deptId 被修改的部门ID + * @param newAncestors 新的父ID集合 + * @param oldAncestors 旧的父ID集合 + */ + private void updateDeptChildren(Long deptId, String newAncestors, String oldAncestors) { + List children = baseMapper.selectList(new LambdaQueryWrapper() + .apply(DataBaseHelper.findInSet(deptId, "ancestors"))); + List list = new ArrayList<>(); + for (SysDept child : children) { + SysDept dept = new SysDept(); + dept.setDeptId(child.getDeptId()); + dept.setAncestors(child.getAncestors().replaceFirst(oldAncestors, newAncestors)); + list.add(dept); + } + if (CollUtil.isNotEmpty(list)) { + if (baseMapper.updateBatchById(list)) { + list.forEach(dept -> CacheUtils.evict(CacheNames.SYS_DEPT, dept.getDeptId())); + } + } + } + + /** + * 删除部门管理信息 + * + * @param deptId 部门ID + * @return 结果 + */ + @Caching(evict = { + @CacheEvict(cacheNames = CacheNames.SYS_DEPT, key = "#deptId"), + @CacheEvict(cacheNames = CacheNames.SYS_DEPT_AND_CHILD, key = "#deptId") + }) + @Override + public int deleteDeptById(Long deptId) { + return baseMapper.deleteById(deptId); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDictDataServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDictDataServiceImpl.java new file mode 100644 index 0000000..972b774 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDictDataServiceImpl.java @@ -0,0 +1,152 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.redis.utils.CacheUtils; +import org.dromara.system.domain.SysDictData; +import org.dromara.system.domain.bo.SysDictDataBo; +import org.dromara.system.domain.vo.SysDictDataVo; +import org.dromara.system.mapper.SysDictDataMapper; +import org.dromara.system.service.SysDictDataService; +import org.springframework.cache.annotation.CachePut; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 字典 业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysDictDataServiceImpl implements SysDictDataService { + + private final SysDictDataMapper baseMapper; + + @Override + public TableDataInfo selectPageDictDataList(SysDictDataBo dictData, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(dictData); + Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + /** + * 根据条件分页查询字典数据 + * + * @param dictData 字典数据信息 + * @return 字典数据集合信息 + */ + @Override + public List selectDictDataList(SysDictDataBo dictData) { + LambdaQueryWrapper lqw = buildQueryWrapper(dictData); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(SysDictDataBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getDictSort() != null, SysDictData::getDictSort, bo.getDictSort()); + lqw.like(StringUtils.isNotBlank(bo.getDictLabel()), SysDictData::getDictLabel, bo.getDictLabel()); + lqw.eq(StringUtils.isNotBlank(bo.getDictType()), SysDictData::getDictType, bo.getDictType()); + lqw.orderByAsc(SysDictData::getDictSort, SysDictData::getDictCode); + return lqw; + } + + /** + * 根据字典类型和字典键值查询字典数据信息 + * + * @param dictType 字典类型 + * @param dictValue 字典键值 + * @return 字典标签 + */ + @Override + public String selectDictLabel(String dictType, String dictValue) { + return baseMapper.selectOne(new LambdaQueryWrapper() + .select(SysDictData::getDictLabel) + .eq(SysDictData::getDictType, dictType) + .eq(SysDictData::getDictValue, dictValue)) + .getDictLabel(); + } + + /** + * 根据字典数据ID查询信息 + * + * @param dictCode 字典数据ID + * @return 字典数据 + */ + @Override + public SysDictDataVo selectDictDataById(Long dictCode) { + return baseMapper.selectVoById(dictCode); + } + + /** + * 批量删除字典数据信息 + * + * @param dictCodes 需要删除的字典数据ID + */ + @Override + public void deleteDictDataByIds(List dictCodes) { + List list = baseMapper.selectByIds(dictCodes); + baseMapper.deleteByIds(dictCodes); + list.forEach(x -> CacheUtils.evict(CacheNames.SYS_DICT, x.getDictType())); + } + + /** + * 新增保存字典数据信息 + * + * @param bo 字典数据信息 + * @return 结果 + */ + @CachePut(cacheNames = CacheNames.SYS_DICT, key = "#bo.dictType") + @Override + public List insertDictData(SysDictDataBo bo) { + SysDictData data = MapstructUtils.convert(bo, SysDictData.class); + int row = baseMapper.insert(data); + if (row > 0) { + return baseMapper.selectDictDataByType(data.getDictType()); + } + throw new ServiceException("操作失败"); + } + + /** + * 修改保存字典数据信息 + * + * @param bo 字典数据信息 + * @return 结果 + */ + @CachePut(cacheNames = CacheNames.SYS_DICT, key = "#bo.dictType") + @Override + public List updateDictData(SysDictDataBo bo) { + SysDictData data = MapstructUtils.convert(bo, SysDictData.class); + int row = baseMapper.updateById(data); + if (row > 0) { + return baseMapper.selectDictDataByType(data.getDictType()); + } + throw new ServiceException("操作失败"); + } + + /** + * 校验字典键值是否唯一 + * + * @param dict 字典数据 + * @return 结果 + */ + @Override + public boolean checkDictDataUnique(SysDictDataBo dict) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysDictData::getDictType, dict.getDictType()) + .eq(SysDictData::getDictValue, dict.getDictValue()) + .ne(ObjectUtil.isNotNull(dict.getDictCode()), SysDictData::getDictCode, dict.getDictCode())); + return !exist; + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDictTypeServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDictTypeServiceImpl.java new file mode 100644 index 0000000..75d885c --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysDictTypeServiceImpl.java @@ -0,0 +1,297 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.domain.dto.DictDataDTO; +import org.dromara.common.core.domain.dto.DictTypeDTO; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.DictService; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.redis.utils.CacheUtils; +import org.dromara.system.domain.SysDictData; +import org.dromara.system.domain.SysDictType; +import org.dromara.system.domain.bo.SysDictTypeBo; +import org.dromara.system.domain.vo.SysDictDataVo; +import org.dromara.system.domain.vo.SysDictTypeVo; +import org.dromara.system.mapper.SysDictDataMapper; +import org.dromara.system.mapper.SysDictTypeMapper; +import org.dromara.system.service.SysDictTypeService; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 字典 业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysDictTypeServiceImpl implements SysDictTypeService, DictService { + + private final SysDictTypeMapper baseMapper; + private final SysDictDataMapper dictDataMapper; + + @Override + public TableDataInfo selectPageDictTypeList(SysDictTypeBo dictType, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(dictType); + Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + /** + * 根据条件分页查询字典类型 + * + * @param dictType 字典类型信息 + * @return 字典类型集合信息 + */ + @Override + public List selectDictTypeList(SysDictTypeBo dictType) { + LambdaQueryWrapper lqw = buildQueryWrapper(dictType); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(SysDictTypeBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getDictName()), SysDictType::getDictName, bo.getDictName()); + lqw.like(StringUtils.isNotBlank(bo.getDictType()), SysDictType::getDictType, bo.getDictType()); + lqw.between(params.get("beginTime") != null && params.get("endTime") != null, + SysDictType::getCreateTime, params.get("beginTime"), params.get("endTime")); + lqw.orderByAsc(SysDictType::getDictId); + return lqw; + } + + /** + * 根据所有字典类型 + * + * @return 字典类型集合信息 + */ + @Override + public List selectDictTypeAll() { + return baseMapper.selectVoList(); + } + + /** + * 根据字典类型查询字典数据 + * + * @param dictType 字典类型 + * @return 字典数据集合信息 + */ + @Cacheable(cacheNames = CacheNames.SYS_DICT, key = "#dictType") + @Override + public List selectDictDataByType(String dictType) { + List dictDatas = dictDataMapper.selectDictDataByType(dictType); + return CollUtil.isNotEmpty(dictDatas) ? dictDatas : null; + } + + /** + * 根据字典类型ID查询信息 + * + * @param dictId 字典类型ID + * @return 字典类型 + */ + @Override + public SysDictTypeVo selectDictTypeById(Long dictId) { + return baseMapper.selectVoById(dictId); + } + + /** + * 根据字典类型查询信息 + * + * @param dictType 字典类型 + * @return 字典类型 + */ + @Cacheable(cacheNames = CacheNames.SYS_DICT_TYPE, key = "#dictType") + @Override + public SysDictTypeVo selectDictTypeByType(String dictType) { + return baseMapper.selectVoOne(new LambdaQueryWrapper().eq(SysDictType::getDictType, dictType)); + } + + /** + * 批量删除字典类型信息 + * + * @param dictIds 需要删除的字典ID + */ + @Override + public void deleteDictTypeByIds(List dictIds) { + List list = baseMapper.selectByIds(dictIds); + list.forEach(x -> { + boolean assigned = dictDataMapper.exists(new LambdaQueryWrapper() + .eq(SysDictData::getDictType, x.getDictType())); + if (assigned) { + throw new ServiceException(String.format("%1$s已分配,不能删除", x.getDictName())); + } + }); + baseMapper.deleteByIds(dictIds); + list.forEach(x -> { + CacheUtils.evict(CacheNames.SYS_DICT, x.getDictType()); + CacheUtils.evict(CacheNames.SYS_DICT_TYPE, x.getDictType()); + }); + } + + /** + * 重置字典缓存数据 + */ + @Override + public void resetDictCache() { + CacheUtils.clear(CacheNames.SYS_DICT); + CacheUtils.clear(CacheNames.SYS_DICT_TYPE); + } + + /** + * 新增保存字典类型信息 + * + * @param bo 字典类型信息 + * @return 结果 + */ + @CachePut(cacheNames = CacheNames.SYS_DICT, key = "#bo.dictType") + @Override + public List insertDictType(SysDictTypeBo bo) { + SysDictType dict = MapstructUtils.convert(bo, SysDictType.class); + int row = baseMapper.insert(dict); + if (row > 0) { + // 新增 type 下无 data 数据 返回空防止缓存穿透 + return new ArrayList<>(); + } + throw new ServiceException("操作失败"); + } + + /** + * 修改保存字典类型信息 + * + * @param bo 字典类型信息 + * @return 结果 + */ + @CachePut(cacheNames = CacheNames.SYS_DICT, key = "#bo.dictType") + @Override + @Transactional(rollbackFor = Exception.class) + public List updateDictType(SysDictTypeBo bo) { + SysDictType dict = MapstructUtils.convert(bo, SysDictType.class); + SysDictType oldDict = baseMapper.selectById(dict.getDictId()); + dictDataMapper.update(null, new LambdaUpdateWrapper() + .set(SysDictData::getDictType, dict.getDictType()) + .eq(SysDictData::getDictType, oldDict.getDictType())); + int row = baseMapper.updateById(dict); + if (row > 0) { + CacheUtils.evict(CacheNames.SYS_DICT, oldDict.getDictType()); + CacheUtils.evict(CacheNames.SYS_DICT_TYPE, oldDict.getDictType()); + return dictDataMapper.selectDictDataByType(dict.getDictType()); + } + throw new ServiceException("操作失败"); + } + + /** + * 校验字典类型称是否唯一 + * + * @param dictType 字典类型 + * @return 结果 + */ + @Override + public boolean checkDictTypeUnique(SysDictTypeBo dictType) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysDictType::getDictType, dictType.getDictType()) + .ne(ObjectUtil.isNotNull(dictType.getDictId()), SysDictType::getDictId, dictType.getDictId())); + return !exist; + } + + /** + * 根据字典类型和字典值获取字典标签 + * + * @param dictType 字典类型 + * @param dictValue 字典值 + * @param separator 分隔符 + * @return 字典标签 + */ + @Override + public String getDictLabel(String dictType, String dictValue, String separator) { + List datas = SpringUtils.getAopProxy(this).selectDictDataByType(dictType); + Map map = StreamUtils.toMap(datas, SysDictDataVo::getDictValue, SysDictDataVo::getDictLabel); + if (StringUtils.containsAny(dictValue, separator)) { + return Arrays.stream(dictValue.split(separator)) + .map(v -> map.getOrDefault(v, StringUtils.EMPTY)) + .collect(Collectors.joining(separator)); + } else { + return map.getOrDefault(dictValue, StringUtils.EMPTY); + } + } + + /** + * 根据字典类型和字典标签获取字典值 + * + * @param dictType 字典类型 + * @param dictLabel 字典标签 + * @param separator 分隔符 + * @return 字典值 + */ + @Override + public String getDictValue(String dictType, String dictLabel, String separator) { + List datas = SpringUtils.getAopProxy(this).selectDictDataByType(dictType); + Map map = StreamUtils.toMap(datas, SysDictDataVo::getDictLabel, SysDictDataVo::getDictValue); + if (StringUtils.containsAny(dictLabel, separator)) { + return Arrays.stream(dictLabel.split(separator)) + .map(l -> map.getOrDefault(l, StringUtils.EMPTY)) + .collect(Collectors.joining(separator)); + } else { + return map.getOrDefault(dictLabel, StringUtils.EMPTY); + } + } + + /** + * 获取字典下所有的字典值与标签 + * + * @param dictType 字典类型 + * @return dictValue为key,dictLabel为值组成的Map + */ + @Override + public Map getAllDictByDictType(String dictType) { + List list = SpringUtils.getAopProxy(this).selectDictDataByType(dictType); + // 保证顺序 + LinkedHashMap map = new LinkedHashMap<>(); + for (SysDictDataVo vo : list) { + map.put(vo.getDictValue(), vo.getDictLabel()); + } + return map; + } + + /** + * 根据字典类型查询详细信息 + * + * @param dictType 字典类型 + * @return 字典类型详细信息 + */ + @Override + public DictTypeDTO getDictType(String dictType) { + SysDictTypeVo vo = SpringUtils.getAopProxy(this).selectDictTypeByType(dictType); + return BeanUtil.toBean(vo, DictTypeDTO.class); + } + + /** + * 根据字典类型查询字典数据列表 + * + * @param dictType 字典类型 + * @return 字典数据列表 + */ + @Override + public List getDictData(String dictType) { + List list = SpringUtils.getAopProxy(this).selectDictDataByType(dictType); + return BeanUtil.copyToList(list, DictDataDTO.class); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysLogininforServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysLogininforServiceImpl.java new file mode 100644 index 0000000..9322a6d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysLogininforServiceImpl.java @@ -0,0 +1,169 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.http.useragent.UserAgent; +import cn.hutool.http.useragent.UserAgentUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ip.AddressUtils; +import org.dromara.common.log.event.LogininforEvent; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.SysLogininfor; +import org.dromara.system.domain.bo.SysLogininforBo; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.domain.vo.SysLogininforVo; +import org.dromara.system.mapper.SysLogininforMapper; +import org.dromara.system.service.SysClientService; +import org.dromara.system.service.SysLogininforService; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 系统访问日志情况信息 服务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysLogininforServiceImpl implements SysLogininforService { + + private final SysLogininforMapper baseMapper; + private final SysClientService clientService; + + /** + * 记录登录信息 + * + * @param logininforEvent 登录事件 + */ + @Async + @EventListener + public void recordLogininfor(LogininforEvent logininforEvent) { + HttpServletRequest request = logininforEvent.getRequest(); + final UserAgent userAgent = UserAgentUtil.parse(request.getHeader("User-Agent")); + final String ip = ServletUtils.getClientIP(request); + // 客户端信息 + String clientId = request.getHeader(LoginHelper.CLIENT_KEY); + SysClientVo client = null; + if (StringUtils.isNotBlank(clientId)) { + client = clientService.queryByClientId(clientId); + } + + String address = AddressUtils.getRealAddressByIP(ip); + StringBuilder s = new StringBuilder(); + s.append(getBlock(ip)); + s.append(address); + s.append(getBlock(logininforEvent.getUsername())); + s.append(getBlock(logininforEvent.getStatus())); + s.append(getBlock(logininforEvent.getMessage())); + // 获取客户端操作系统 + String os = userAgent.getOs().getName(); + // 获取客户端浏览器 + String browser = userAgent.getBrowser().getName(); + // 封装对象 + SysLogininforBo logininfor = new SysLogininforBo(); + logininfor.setUserName(logininforEvent.getUsername()); + if (ObjectUtil.isNotNull(client)) { + logininfor.setClientKey(client.getClientKey()); + logininfor.setDeviceType(client.getDeviceType()); + } + logininfor.setIpaddr(ip); + logininfor.setLoginLocation(address); + logininfor.setBrowser(browser); + logininfor.setOs(os); + logininfor.setMsg(logininforEvent.getMessage()); + // 日志状态 + if (StringUtils.equalsAny(logininforEvent.getStatus(), Constants.LOGIN_SUCCESS, Constants.LOGOUT, Constants.REGISTER)) { + logininfor.setStatus(Constants.SUCCESS); + } else if (Constants.LOGIN_FAIL.equals(logininforEvent.getStatus())) { + logininfor.setStatus(Constants.FAIL); + } + // 插入数据 + insertLogininfor(logininfor); + } + + private String getBlock(Object msg) { + if (msg == null) { + msg = ""; + } + return "[" + msg.toString() + "]"; + } + + @Override + public TableDataInfo selectPageLogininforList(SysLogininforBo logininfor, PageQuery pageQuery) { + Map params = logininfor.getParams(); + LambdaQueryWrapper lqw = new LambdaQueryWrapper() + .like(StringUtils.isNotBlank(logininfor.getIpaddr()), SysLogininfor::getIpaddr, logininfor.getIpaddr()) + .eq(StringUtils.isNotBlank(logininfor.getStatus()), SysLogininfor::getStatus, logininfor.getStatus()) + .like(StringUtils.isNotBlank(logininfor.getUserName()), SysLogininfor::getUserName, logininfor.getUserName()) + .between(params.get("beginTime") != null && params.get("endTime") != null, + SysLogininfor::getLoginTime, params.get("beginTime"), params.get("endTime")); + if (StringUtils.isBlank(pageQuery.getOrderByColumn())) { + lqw.orderByDesc(SysLogininfor::getInfoId); + } + Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + /** + * 新增系统登录日志 + * + * @param bo 访问日志对象 + */ + @Override + public void insertLogininfor(SysLogininforBo bo) { + SysLogininfor logininfor = MapstructUtils.convert(bo, SysLogininfor.class); + logininfor.setLoginTime(new Date()); + baseMapper.insert(logininfor); + } + + /** + * 查询系统登录日志集合 + * + * @param logininfor 访问日志对象 + * @return 登录记录集合 + */ + @Override + public List selectLogininforList(SysLogininforBo logininfor) { + Map params = logininfor.getParams(); + return baseMapper.selectVoList(new LambdaQueryWrapper() + .like(StringUtils.isNotBlank(logininfor.getIpaddr()), SysLogininfor::getIpaddr, logininfor.getIpaddr()) + .eq(StringUtils.isNotBlank(logininfor.getStatus()), SysLogininfor::getStatus, logininfor.getStatus()) + .like(StringUtils.isNotBlank(logininfor.getUserName()), SysLogininfor::getUserName, logininfor.getUserName()) + .between(params.get("beginTime") != null && params.get("endTime") != null, + SysLogininfor::getLoginTime, params.get("beginTime"), params.get("endTime")) + .orderByDesc(SysLogininfor::getInfoId)); + } + + /** + * 批量删除系统登录日志 + * + * @param infoIds 需要删除的登录日志ID + * @return 结果 + */ + @Override + public int deleteLogininforByIds(Long[] infoIds) { + return baseMapper.deleteByIds(Arrays.asList(infoIds)); + } + + /** + * 清空系统登录日志 + */ + @Override + public void cleanLogininfor() { + baseMapper.delete(new LambdaQueryWrapper<>()); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysMenuServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysMenuServiceImpl.java new file mode 100644 index 0000000..1b4d34d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysMenuServiceImpl.java @@ -0,0 +1,374 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.tree.Tree; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.TreeBuildUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.SysMenu; +import org.dromara.system.domain.SysRole; +import org.dromara.system.domain.SysRoleMenu; +import org.dromara.system.domain.bo.SysMenuBo; +import org.dromara.system.domain.vo.MetaVo; +import org.dromara.system.domain.vo.RouterVo; +import org.dromara.system.domain.vo.SysMenuVo; +import org.dromara.system.mapper.SysMenuMapper; +import org.dromara.system.mapper.SysRoleMapper; +import org.dromara.system.mapper.SysRoleMenuMapper; +import org.dromara.system.service.SysMenuService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * 菜单 业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysMenuServiceImpl implements SysMenuService { + + private final SysMenuMapper baseMapper; + private final SysRoleMapper roleMapper; + private final SysRoleMenuMapper roleMenuMapper; + + /** + * 根据用户查询系统菜单列表 + * + * @param userId 用户ID + * @return 菜单列表 + */ + @Override + public List selectMenuList(Long userId) { + return selectMenuList(new SysMenuBo(), userId); + } + + /** + * 查询系统菜单列表 + * + * @param menu 菜单信息 + * @return 菜单列表 + */ + @Override + public List selectMenuList(SysMenuBo menu, Long userId) { + List menuList; + // 管理员显示所有菜单信息 + if (LoginHelper.isSuperAdmin(userId)) { + menuList = baseMapper.selectVoList(new LambdaQueryWrapper() + .like(StringUtils.isNotBlank(menu.getMenuName()), SysMenu::getMenuName, menu.getMenuName()) + .eq(StringUtils.isNotBlank(menu.getVisible()), SysMenu::getVisible, menu.getVisible()) + .eq(StringUtils.isNotBlank(menu.getStatus()), SysMenu::getStatus, menu.getStatus()) + .eq(StringUtils.isNotBlank(menu.getMenuType()), SysMenu::getMenuType, menu.getMenuType()) + .eq(ObjectUtil.isNotNull(menu.getParentId()), SysMenu::getParentId, menu.getParentId()) + .orderByAsc(SysMenu::getParentId) + .orderByAsc(SysMenu::getOrderNum)); + } else { + QueryWrapper wrapper = Wrappers.query(); + wrapper.inSql("r.role_id", "select role_id from sys_user_role where user_id = " + userId) + .like(StringUtils.isNotBlank(menu.getMenuName()), "m.menu_name", menu.getMenuName()) + .eq(StringUtils.isNotBlank(menu.getVisible()), "m.visible", menu.getVisible()) + .eq(StringUtils.isNotBlank(menu.getStatus()), "m.status", menu.getStatus()) + .eq(StringUtils.isNotBlank(menu.getMenuType()), "m.menu_type", menu.getMenuType()) + .eq(ObjectUtil.isNotNull(menu.getParentId()), "m.parent_id", menu.getParentId()) + .orderByAsc("m.parent_id") + .orderByAsc("m.order_num"); + List list = baseMapper.selectMenuListByUserId(wrapper); + menuList = MapstructUtils.convert(list, SysMenuVo.class); + } + return menuList; + } + + /** + * 根据用户ID查询权限 + * + * @param userId 用户ID + * @return 权限列表 + */ + @Override + public Set selectMenuPermsByUserId(Long userId) { + List perms = baseMapper.selectMenuPermsByUserId(userId); + Set permsSet = new HashSet<>(); + for (String perm : perms) { + if (StringUtils.isNotEmpty(perm)) { + permsSet.addAll(StringUtils.splitList(perm.trim())); + } + } + return permsSet; + } + + /** + * 根据角色ID查询权限 + * + * @param roleId 角色ID + * @return 权限列表 + */ + @Override + public Set selectMenuPermsByRoleId(Long roleId) { + List perms = baseMapper.selectMenuPermsByRoleId(roleId); + Set permsSet = new HashSet<>(); + for (String perm : perms) { + if (StringUtils.isNotEmpty(perm)) { + permsSet.addAll(StringUtils.splitList(perm.trim())); + } + } + return permsSet; + } + + /** + * 根据用户ID查询菜单 + * + * @param userId 用户名称 + * @return 菜单列表 + */ + @Override + public List selectMenuTreeByUserId(Long userId) { + List menus; + if (LoginHelper.isSuperAdmin(userId)) { + menus = baseMapper.selectMenuTreeAll(); + } else { + menus = baseMapper.selectMenuTreeByUserId(userId); + } + return getChildPerms(menus, Constants.TOP_PARENT_ID); + } + + /** + * 根据角色ID查询菜单树信息 + * + * @param roleId 角色ID + * @return 选中菜单列表 + */ + @Override + public List selectMenuListByRoleId(Long roleId) { + SysRole role = roleMapper.selectById(roleId); + return baseMapper.selectMenuListByRoleId(roleId, role.getMenuCheckStrictly()); + } + + /** + * 构建前端路由所需要的菜单 + * 路由name命名规则 path首字母转大写 + id + * + * @param menus 菜单列表 + * @return 路由列表 + */ + @Override + public List buildMenus(List menus) { + List routers = new LinkedList<>(); + for (SysMenu menu : menus) { + String name = menu.getRouteName() + menu.getMenuId(); + RouterVo router = new RouterVo(); + router.setHidden("1".equals(menu.getVisible())); + router.setName(name); + router.setPath(menu.getRouterPath()); + router.setComponent(menu.getComponentInfo()); + router.setQuery(menu.getQueryParam()); + router.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), StringUtils.equals("1", menu.getIsCache()), menu.getPath())); + List cMenus = menu.getChildren(); + if (CollUtil.isNotEmpty(cMenus) && SystemConstants.TYPE_DIR.equals(menu.getMenuType())) { + router.setAlwaysShow(true); + router.setRedirect("noRedirect"); + router.setChildren(buildMenus(cMenus)); + } else if (menu.isMenuFrame()) { + String frameName = StringUtils.capitalize(menu.getPath()) + menu.getMenuId(); + router.setMeta(null); + List childrenList = new ArrayList<>(); + RouterVo children = new RouterVo(); + children.setPath(menu.getPath()); + children.setComponent(menu.getComponent()); + children.setName(frameName); + children.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), StringUtils.equals("1", menu.getIsCache()), menu.getPath())); + children.setQuery(menu.getQueryParam()); + childrenList.add(children); + router.setChildren(childrenList); + } else if (menu.getParentId().equals(Constants.TOP_PARENT_ID) && menu.isInnerLink()) { + router.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon())); + router.setPath("/"); + List childrenList = new ArrayList<>(); + RouterVo children = new RouterVo(); + String routerPath = SysMenu.innerLinkReplaceEach(menu.getPath()); + String innerLinkName = StringUtils.capitalize(routerPath) + menu.getMenuId(); + children.setPath(routerPath); + children.setComponent(SystemConstants.INNER_LINK); + children.setName(innerLinkName); + children.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), menu.getPath())); + childrenList.add(children); + router.setChildren(childrenList); + } + routers.add(router); + } + return routers; + } + + /** + * 构建前端所需要下拉树结构 + * + * @param menus 菜单列表 + * @return 下拉树结构列表 + */ + @Override + public List> buildMenuTreeSelect(List menus) { + if (CollUtil.isEmpty(menus)) { + return CollUtil.newArrayList(); + } + return TreeBuildUtils.build(menus, (menu, tree) -> { + Tree menuTree = tree.setId(menu.getMenuId()) + .setParentId(menu.getParentId()) + .setName(menu.getMenuName()) + .setWeight(menu.getOrderNum()); + menuTree.put("menuType", menu.getMenuType()); + menuTree.put("icon", menu.getIcon()); + }); + } + + /** + * 根据菜单ID查询信息 + * + * @param menuId 菜单ID + * @return 菜单信息 + */ + @Override + public SysMenuVo selectMenuById(Long menuId) { + return baseMapper.selectVoById(menuId); + } + + /** + * 是否存在菜单子节点 + * + * @param menuId 菜单ID + * @return 结果 + */ + @Override + public boolean hasChildByMenuId(Long menuId) { + return baseMapper.exists(new LambdaQueryWrapper().eq(SysMenu::getParentId, menuId)); + } + + /** + * 是否存在菜单子节点 + * + * @param menuIds 菜单ID串 + * @return 结果 + */ + @Override + public boolean hasChildByMenuId(List menuIds) { + return baseMapper.exists(new LambdaQueryWrapper().in(SysMenu::getParentId, menuIds).notIn(SysMenu::getMenuId, menuIds)); + } + + /** + * 查询菜单使用数量 + * + * @param menuId 菜单ID + * @return 结果 + */ + @Override + public boolean checkMenuExistRole(Long menuId) { + return roleMenuMapper.exists(new LambdaQueryWrapper().eq(SysRoleMenu::getMenuId, menuId)); + } + + /** + * 新增保存菜单信息 + * + * @param bo 菜单信息 + * @return 结果 + */ + @Override + public int insertMenu(SysMenuBo bo) { + SysMenu menu = MapstructUtils.convert(bo, SysMenu.class); + return baseMapper.insert(menu); + } + + /** + * 修改保存菜单信息 + * + * @param bo 菜单信息 + * @return 结果 + */ + @Override + public int updateMenu(SysMenuBo bo) { + SysMenu menu = MapstructUtils.convert(bo, SysMenu.class); + return baseMapper.updateById(menu); + } + + /** + * 删除菜单管理信息 + * + * @param menuId 菜单ID + * @return 结果 + */ + @Override + public int deleteMenuById(Long menuId) { + return baseMapper.deleteById(menuId); + } + + /** + * 批量删除菜单管理信息 + * + * @param menuIds 菜单ID串 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteMenuById(List menuIds) { + baseMapper.deleteByIds(menuIds); + roleMenuMapper.deleteByMenuIds(menuIds); + } + + /** + * 校验菜单名称是否唯一 + * + * @param menu 菜单信息 + * @return 结果 + */ + @Override + public boolean checkMenuNameUnique(SysMenuBo menu) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, menu.getMenuName()) + .eq(SysMenu::getParentId, menu.getParentId()) + .ne(ObjectUtil.isNotNull(menu.getMenuId()), SysMenu::getMenuId, menu.getMenuId())); + return !exist; + } + + /** + * 根据父节点的ID获取所有子节点 + * + * @param list 分类表 + * @param parentId 传入的父节点ID + * @return String + */ + private List getChildPerms(List list, Long parentId) { + List returnList = new ArrayList<>(); + for (SysMenu t : list) { + // 一、根据传入的某个父节点ID,遍历该父节点的所有子节点 + if (t.getParentId().equals(parentId)) { + recursionFn(list, t); + returnList.add(t); + } + } + return returnList; + } + + /** + * 递归列表 + */ + private void recursionFn(List list, SysMenu t) { + // 得到子节点列表 + List childList = StreamUtils.filter(list, n -> n.getParentId().equals(t.getMenuId())); + t.setChildren(childList); + for (SysMenu tChild : childList) { + // 判断是否有子节点 + if (list.stream().anyMatch(n -> n.getParentId().equals(tChild.getMenuId()))) { + recursionFn(list, tChild); + } + } + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysNoticeServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysNoticeServiceImpl.java new file mode 100644 index 0000000..a18a8ab --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysNoticeServiceImpl.java @@ -0,0 +1,124 @@ +package org.dromara.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.ObjectUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.SysNotice; +import org.dromara.system.domain.SysUser; +import org.dromara.system.domain.bo.SysNoticeBo; +import org.dromara.system.domain.vo.SysNoticeVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.mapper.SysNoticeMapper; +import org.dromara.system.mapper.SysUserMapper; +import org.dromara.system.service.SysNoticeService; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +/** + * 公告 服务层实现 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysNoticeServiceImpl implements SysNoticeService { + + private final SysNoticeMapper baseMapper; + private final SysUserMapper userMapper; + + @Override + public TableDataInfo selectPageNoticeList(SysNoticeBo notice, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(notice); + Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + /** + * 查询公告信息 + * + * @param noticeId 公告ID + * @return 公告信息 + */ + @Override + public SysNoticeVo selectNoticeById(Long noticeId) { + return baseMapper.selectVoById(noticeId); + } + + /** + * 查询公告列表 + * + * @param notice 公告信息 + * @return 公告集合 + */ + @Override + public List selectNoticeList(SysNoticeBo notice) { + LambdaQueryWrapper lqw = buildQueryWrapper(notice); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(SysNoticeBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getNoticeTitle()), SysNotice::getNoticeTitle, bo.getNoticeTitle()); + lqw.eq(StringUtils.isNotBlank(bo.getNoticeType()), SysNotice::getNoticeType, bo.getNoticeType()); + if (StringUtils.isNotBlank(bo.getCreateByName())) { + SysUserVo sysUser = userMapper.selectVoOne(new LambdaQueryWrapper().eq(SysUser::getUserName, bo.getCreateByName())); + lqw.eq(SysNotice::getCreateBy, ObjectUtils.notNullGetter(sysUser, SysUserVo::getUserId)); + } + lqw.orderByAsc(SysNotice::getNoticeId); + return lqw; + } + + /** + * 新增公告 + * + * @param bo 公告信息 + * @return 结果 + */ + @Override + public int insertNotice(SysNoticeBo bo) { + SysNotice notice = MapstructUtils.convert(bo, SysNotice.class); + return baseMapper.insert(notice); + } + + /** + * 修改公告 + * + * @param bo 公告信息 + * @return 结果 + */ + @Override + public int updateNotice(SysNoticeBo bo) { + SysNotice notice = MapstructUtils.convert(bo, SysNotice.class); + return baseMapper.updateById(notice); + } + + /** + * 删除公告对象 + * + * @param noticeId 公告ID + * @return 结果 + */ + @Override + public int deleteNoticeById(Long noticeId) { + return baseMapper.deleteById(noticeId); + } + + /** + * 批量删除公告信息 + * + * @param noticeIds 需要删除的公告ID + * @return 结果 + */ + @Override + public int deleteNoticeByIds(Long[] noticeIds) { + return baseMapper.deleteByIds(Arrays.asList(noticeIds)); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOperLogServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOperLogServiceImpl.java new file mode 100644 index 0000000..1694d09 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOperLogServiceImpl.java @@ -0,0 +1,136 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.util.ArrayUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ip.AddressUtils; +import org.dromara.common.log.event.OperLogEvent; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.SysOperLog; +import org.dromara.system.domain.bo.SysOperLogBo; +import org.dromara.system.domain.vo.SysOperLogVo; +import org.dromara.system.mapper.SysOperLogMapper; +import org.dromara.system.service.SysOperLogService; +import org.springframework.beans.BeanUtils; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 操作日志 服务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysOperLogServiceImpl implements SysOperLogService { + + private final SysOperLogMapper baseMapper; + + /** + * 操作日志记录 + * + * @param operLogEvent 操作日志事件 + */ + @Async + @EventListener + public void recordOper(OperLogEvent operLogEvent) { + SysOperLogBo operLog = new SysOperLogBo(); + BeanUtils.copyProperties(operLogEvent, operLog); + // 远程查询操作地点 + operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp())); + insertOperlog(operLog); + } + + @Override + public TableDataInfo selectPageOperLogList(SysOperLogBo operLog, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(operLog); + if (StringUtils.isBlank(pageQuery.getOrderByColumn())) { + lqw.orderByDesc(SysOperLog::getOperId); + } + Page page = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + private LambdaQueryWrapper buildQueryWrapper(SysOperLogBo operLog) { + Map params = operLog.getParams(); + return new LambdaQueryWrapper() + .like(StringUtils.isNotBlank(operLog.getOperIp()), SysOperLog::getOperIp, operLog.getOperIp()) + .like(StringUtils.isNotBlank(operLog.getTitle()), SysOperLog::getTitle, operLog.getTitle()) + .eq(operLog.getBusinessType() != null && operLog.getBusinessType() > 0, + SysOperLog::getBusinessType, operLog.getBusinessType()) + .func(f -> { + if (ArrayUtil.isNotEmpty(operLog.getBusinessTypes())) { + f.in(SysOperLog::getBusinessType, Arrays.asList(operLog.getBusinessTypes())); + } + }) + .eq(operLog.getStatus() != null, + SysOperLog::getStatus, operLog.getStatus()) + .like(StringUtils.isNotBlank(operLog.getOperName()), SysOperLog::getOperName, operLog.getOperName()) + .between(params.get("beginTime") != null && params.get("endTime") != null, + SysOperLog::getOperTime, params.get("beginTime"), params.get("endTime")); + } + + /** + * 新增操作日志 + * + * @param bo 操作日志对象 + */ + @Override + public void insertOperlog(SysOperLogBo bo) { + SysOperLog operLog = MapstructUtils.convert(bo, SysOperLog.class); + operLog.setOperTime(new Date()); + baseMapper.insert(operLog); + } + + /** + * 查询系统操作日志集合 + * + * @param operLog 操作日志对象 + * @return 操作日志集合 + */ + @Override + public List selectOperLogList(SysOperLogBo operLog) { + LambdaQueryWrapper lqw = buildQueryWrapper(operLog); + return baseMapper.selectVoList(lqw.orderByDesc(SysOperLog::getOperId)); + } + + /** + * 批量删除系统操作日志 + * + * @param operIds 需要删除的操作日志ID + * @return 结果 + */ + @Override + public int deleteOperLogByIds(Long[] operIds) { + return baseMapper.deleteByIds(Arrays.asList(operIds)); + } + + /** + * 查询操作日志详细 + * + * @param operId 操作ID + * @return 操作日志对象 + */ + @Override + public SysOperLogVo selectOperLogById(Long operId) { + return baseMapper.selectVoById(operId); + } + + /** + * 清空操作日志 + */ + @Override + public void cleanOperLog() { + baseMapper.delete(new LambdaQueryWrapper<>()); + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssConfigServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssConfigServiceImpl.java new file mode 100644 index 0000000..a2cce4c --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssConfigServiceImpl.java @@ -0,0 +1,175 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.ObjectUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.oss.constant.OssConstant; +import org.dromara.common.redis.utils.CacheUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.system.domain.SysOssConfig; +import org.dromara.system.domain.bo.SysOssConfigBo; +import org.dromara.system.domain.vo.SysOssConfigVo; +import org.dromara.system.mapper.SysOssConfigMapper; +import org.dromara.system.service.SysOssConfigService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +/** + * 对象存储配置Service业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class SysOssConfigServiceImpl implements SysOssConfigService { + + private final SysOssConfigMapper baseMapper; + + /** + * 项目启动时,初始化参数到缓存,加载配置类 + */ + @Override + public void init() { + List list = baseMapper.selectList(); + // 加载OSS初始化配置 + for (SysOssConfig config : list) { + String configKey = config.getConfigKey(); + if ("0".equals(config.getStatus())) { + RedisUtils.setCacheObject(OssConstant.DEFAULT_CONFIG_KEY, configKey); + } + CacheUtils.put(CacheNames.SYS_OSS_CONFIG, config.getConfigKey(), JsonUtils.toJsonString(config)); + } + } + + @Override + public SysOssConfigVo queryById(Long ossConfigId) { + return baseMapper.selectVoById(ossConfigId); + } + + @Override + public TableDataInfo queryPageList(SysOssConfigBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + + private LambdaQueryWrapper buildQueryWrapper(SysOssConfigBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getConfigKey()), SysOssConfig::getConfigKey, bo.getConfigKey()); + lqw.like(StringUtils.isNotBlank(bo.getBucketName()), SysOssConfig::getBucketName, bo.getBucketName()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), SysOssConfig::getStatus, bo.getStatus()); + lqw.orderByAsc(SysOssConfig::getOssConfigId); + return lqw; + } + + @Override + public Boolean insertByBo(SysOssConfigBo bo) { + SysOssConfig config = MapstructUtils.convert(bo, SysOssConfig.class); + validEntityBeforeSave(config); + boolean flag = baseMapper.insert(config) > 0; + if (flag) { + // 从数据库查询完整的数据做缓存 + config = baseMapper.selectById(config.getOssConfigId()); + CacheUtils.put(CacheNames.SYS_OSS_CONFIG, config.getConfigKey(), JsonUtils.toJsonString(config)); + } + return flag; + } + + @Override + public Boolean updateByBo(SysOssConfigBo bo) { + SysOssConfig config = MapstructUtils.convert(bo, SysOssConfig.class); + validEntityBeforeSave(config); + LambdaUpdateWrapper luw = new LambdaUpdateWrapper<>(); + luw.set(ObjectUtil.isNull(config.getPrefix()), SysOssConfig::getPrefix, ""); + luw.set(ObjectUtil.isNull(config.getRegion()), SysOssConfig::getRegion, ""); + luw.set(ObjectUtil.isNull(config.getExt1()), SysOssConfig::getExt1, ""); + luw.set(ObjectUtil.isNull(config.getRemark()), SysOssConfig::getRemark, ""); + luw.eq(SysOssConfig::getOssConfigId, config.getOssConfigId()); + boolean flag = baseMapper.update(config, luw) > 0; + if (flag) { + // 从数据库查询完整的数据做缓存 + config = baseMapper.selectById(config.getOssConfigId()); + CacheUtils.put(CacheNames.SYS_OSS_CONFIG, config.getConfigKey(), JsonUtils.toJsonString(config)); + } + return flag; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(SysOssConfig entity) { + if (StringUtils.isNotEmpty(entity.getConfigKey()) + && !checkConfigKeyUnique(entity)) { + throw new ServiceException("操作配置'" + entity.getConfigKey() + "'失败, 配置key已存在!"); + } + } + + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + if (CollUtil.containsAny(ids, OssConstant.SYSTEM_DATA_IDS)) { + throw new ServiceException("系统内置, 不可删除!"); + } + } + List list = CollUtil.newArrayList(); + for (Long configId : ids) { + SysOssConfig config = baseMapper.selectById(configId); + list.add(config); + } + boolean flag = baseMapper.deleteByIds(ids) > 0; + if (flag) { + list.forEach(sysOssConfig -> + CacheUtils.evict(CacheNames.SYS_OSS_CONFIG, sysOssConfig.getConfigKey())); + } + return flag; + } + + /** + * 判断configKey是否唯一 + */ + private boolean checkConfigKeyUnique(SysOssConfig sysOssConfig) { + long ossConfigId = ObjectUtils.notNull(sysOssConfig.getOssConfigId(), -1L); + SysOssConfig info = baseMapper.selectOne(new LambdaQueryWrapper() + .select(SysOssConfig::getOssConfigId, SysOssConfig::getConfigKey) + .eq(SysOssConfig::getConfigKey, sysOssConfig.getConfigKey())); + if (ObjectUtil.isNotNull(info) && info.getOssConfigId() != ossConfigId) { + return false; + } + return true; + } + + /** + * 启用禁用状态 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int updateOssConfigStatus(SysOssConfigBo bo) { + SysOssConfig sysOssConfig = MapstructUtils.convert(bo, SysOssConfig.class); + int row = baseMapper.update(null, new LambdaUpdateWrapper() + .set(SysOssConfig::getStatus, "1")); + row += baseMapper.updateById(sysOssConfig); + if (row > 0) { + RedisUtils.setCacheObject(OssConstant.DEFAULT_CONFIG_KEY, sysOssConfig.getConfigKey()); + } + return row; + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java new file mode 100644 index 0000000..8c12879 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java @@ -0,0 +1,269 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.domain.dto.OssDTO; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.OssService; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.file.FileUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.oss.core.OssClient; +import org.dromara.common.oss.entity.UploadResult; +import org.dromara.common.oss.enums.AccessPolicyType; +import org.dromara.common.oss.factory.OssFactory; +import org.dromara.system.domain.SysOss; +import org.dromara.system.domain.bo.SysOssBo; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.mapper.SysOssMapper; +import org.dromara.system.service.SysOssService; +import org.jetbrains.annotations.NotNull; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 文件上传 服务层实现 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysOssServiceImpl implements SysOssService, OssService { + + private final SysOssMapper baseMapper; + + /** + * 查询OSS对象存储列表 + * + * @param bo OSS对象存储分页查询对象 + * @param pageQuery 分页查询实体类 + * @return 结果 + */ + @Override + public TableDataInfo queryPageList(SysOssBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + List filterResult = StreamUtils.toList(result.getRecords(), this::matchingUrl); + result.setRecords(filterResult); + return TableDataInfo.build(result); + } + + /** + * 根据一组 ossIds 获取对应的 SysOssVo 列表 + * + * @param ossIds 一组文件在数据库中的唯一标识集合 + * @return 包含 SysOssVo 对象的列表 + */ + @Override + public List listByIds(Collection ossIds) { + List list = new ArrayList<>(); + SysOssServiceImpl ossService = SpringUtils.getAopProxy(this); + for (Long id : ossIds) { + SysOssVo vo = ossService.getById(id); + if (ObjectUtil.isNotNull(vo)) { + try { + list.add(this.matchingUrl(vo)); + } catch (Exception ignored) { + // 如果oss异常无法连接则将数据直接返回 + list.add(vo); + } + } + } + return list; + } + + /** + * 根据一组 ossIds 获取对应文件的 URL 列表 + * + * @param ossIds 以逗号分隔的 ossId 字符串 + * @return 以逗号分隔的文件 URL 字符串 + */ + @Override + public String selectUrlByIds(String ossIds) { + List list = new ArrayList<>(); + SysOssServiceImpl ossService = SpringUtils.getAopProxy(this); + for (Long id : StringUtils.splitTo(ossIds, Convert::toLong)) { + SysOssVo vo = ossService.getById(id); + if (ObjectUtil.isNotNull(vo)) { + try { + list.add(this.matchingUrl(vo).getUrl()); + } catch (Exception ignored) { + // 如果oss异常无法连接则将数据直接返回 + list.add(vo.getUrl()); + } + } + } + return String.join(StringUtils.SEPARATOR, list); + } + + @Override + public List selectByIds(String ossIds) { + List list = new ArrayList<>(); + for (Long id : StringUtils.splitTo(ossIds, Convert::toLong)) { + SysOssVo vo = SpringUtils.getAopProxy(this).getById(id); + if (ObjectUtil.isNotNull(vo)) { + try { + vo.setUrl(this.matchingUrl(vo).getUrl()); + list.add(BeanUtil.toBean(vo, OssDTO.class)); + } catch (Exception ignored) { + // 如果oss异常无法连接则将数据直接返回 + list.add(BeanUtil.toBean(vo, OssDTO.class)); + } + } + } + return list; + } + + private LambdaQueryWrapper buildQueryWrapper(SysOssBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getFileName()), SysOss::getFileName, bo.getFileName()); + lqw.like(StringUtils.isNotBlank(bo.getOriginalName()), SysOss::getOriginalName, bo.getOriginalName()); + lqw.eq(StringUtils.isNotBlank(bo.getFileSuffix()), SysOss::getFileSuffix, bo.getFileSuffix()); + lqw.eq(StringUtils.isNotBlank(bo.getUrl()), SysOss::getUrl, bo.getUrl()); + lqw.between(params.get("beginCreateTime") != null && params.get("endCreateTime") != null, + SysOss::getCreateTime, params.get("beginCreateTime"), params.get("endCreateTime")); + lqw.eq(ObjectUtil.isNotNull(bo.getCreateBy()), SysOss::getCreateBy, bo.getCreateBy()); + lqw.eq(StringUtils.isNotBlank(bo.getService()), SysOss::getService, bo.getService()); + lqw.orderByAsc(SysOss::getOssId); + return lqw; + } + + /** + * 根据 ossId 从缓存或数据库中获取 SysOssVo 对象 + * + * @param ossId 文件在数据库中的唯一标识 + * @return SysOssVo 对象,包含文件信息 + */ + @Cacheable(cacheNames = CacheNames.SYS_OSS, key = "#ossId") + @Override + public SysOssVo getById(Long ossId) { + return baseMapper.selectVoById(ossId); + } + + + /** + * 文件下载方法,支持一次性下载完整文件 + * + * @param ossId OSS对象ID + * @param response HttpServletResponse对象,用于设置响应头和向客户端发送文件内容 + */ + @Override + public void download(Long ossId, HttpServletResponse response) throws IOException { + SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId); + if (ObjectUtil.isNull(sysOss)) { + throw new ServiceException("文件数据不存在!"); + } + FileUtils.setAttachmentResponseHeader(response, sysOss.getOriginalName()); + response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8"); + OssClient storage = OssFactory.instance(sysOss.getService()); + storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong); + } + + /** + * 上传 MultipartFile 到对象存储服务,并保存文件信息到数据库 + * + * @param file 要上传的 MultipartFile 对象 + * @return 上传成功后的 SysOssVo 对象,包含文件信息 + * @throws ServiceException 如果上传过程中发生异常,则抛出 ServiceException 异常 + */ + @Override + public SysOssVo upload(MultipartFile file) { + String originalfileName = file.getOriginalFilename(); + String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length()); + OssClient storage = OssFactory.instance(); + UploadResult uploadResult; + try { + uploadResult = storage.uploadSuffix(file.getBytes(), suffix, file.getContentType()); + } catch (IOException e) { + throw new ServiceException(e.getMessage()); + } + // 保存文件信息 + return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult); + } + + /** + * 上传文件到对象存储服务,并保存文件信息到数据库 + * + * @param file 要上传的文件对象 + * @return 上传成功后的 SysOssVo 对象,包含文件信息 + */ + @Override + public SysOssVo upload(File file) { + String originalfileName = file.getName(); + String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length()); + OssClient storage = OssFactory.instance(); + UploadResult uploadResult = storage.uploadSuffix(file, suffix); + // 保存文件信息 + return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult); + } + + @NotNull + private SysOssVo buildResultEntity(String originalfileName, String suffix, String configKey, UploadResult uploadResult) { + SysOss oss = new SysOss(); + oss.setUrl(uploadResult.getUrl()); + oss.setFileSuffix(suffix); + oss.setFileName(uploadResult.getFilename()); + oss.setOriginalName(originalfileName); + oss.setService(configKey); + baseMapper.insert(oss); + SysOssVo sysOssVo = MapstructUtils.convert(oss, SysOssVo.class); + return this.matchingUrl(sysOssVo); + } + + /** + * 删除OSS对象存储 + * + * @param ids OSS对象ID串 + * @param isValid 判断是否需要校验 + * @return 结果 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + // 做一些业务上的校验,判断是否需要校验 + } + List list = baseMapper.selectByIds(ids); + for (SysOss sysOss : list) { + OssClient storage = OssFactory.instance(sysOss.getService()); + storage.delete(sysOss.getUrl()); + } + return baseMapper.deleteByIds(ids) > 0; + } + + /** + * 桶类型为 private 的URL 修改为临时URL时长为120s + * + * @param oss OSS对象 + * @return oss 匹配Url的OSS对象 + */ + private SysOssVo matchingUrl(SysOssVo oss) { + OssClient storage = OssFactory.instance(oss.getService()); + // 仅修改桶类型为 private 的URL,临时URL时长为120s + if (AccessPolicyType.PRIVATE == storage.getAccessPolicy()) { + oss.setUrl(storage.getPrivateUrl(oss.getFileName(), Duration.ofSeconds(120))); + } + return oss; + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPermissionServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPermissionServiceImpl.java new file mode 100644 index 0000000..68d4301 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPermissionServiceImpl.java @@ -0,0 +1,62 @@ +package org.dromara.system.service.impl; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.service.PermissionService; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.service.SysMenuService; +import org.dromara.system.service.SysPermissionService; +import org.dromara.system.service.SysRoleService; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.Set; + +/** + * 用户权限处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysPermissionServiceImpl implements SysPermissionService, PermissionService { + + private final SysRoleService roleService; + private final SysMenuService menuService; + + /** + * 获取角色数据权限 + * + * @param userId 用户id + * @return 角色权限信息 + */ + @Override + public Set getRolePermission(Long userId) { + Set roles = new HashSet<>(); + // 管理员拥有所有权限 + if (LoginHelper.isSuperAdmin(userId)) { + roles.add(SystemConstants.SUPER_ADMIN_ROLE_KEY); + } else { + roles.addAll(roleService.selectRolePermissionByUserId(userId)); + } + return roles; + } + + /** + * 获取菜单数据权限 + * + * @param userId 用户id + * @return 菜单权限信息 + */ + @Override + public Set getMenuPermission(Long userId) { + Set perms = new HashSet<>(); + // 管理员拥有所有权限 + if (LoginHelper.isSuperAdmin(userId)) { + perms.add("*:*:*"); + } else { + perms.addAll(menuService.selectMenuPermsByUserId(userId)); + } + return perms; + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPostServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPostServiceImpl.java new file mode 100644 index 0000000..140d577 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysPostServiceImpl.java @@ -0,0 +1,254 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.PostService; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.SysDept; +import org.dromara.system.domain.SysPost; +import org.dromara.system.domain.SysUserPost; +import org.dromara.system.domain.bo.SysPostBo; +import org.dromara.system.domain.vo.SysPostVo; +import org.dromara.system.mapper.SysDeptMapper; +import org.dromara.system.mapper.SysPostMapper; +import org.dromara.system.mapper.SysUserPostMapper; +import org.dromara.system.service.SysPostService; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * 岗位信息 服务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysPostServiceImpl implements SysPostService, PostService { + + private final SysPostMapper baseMapper; + private final SysDeptMapper deptMapper; + private final SysUserPostMapper userPostMapper; + + @Override + public TableDataInfo selectPagePostList(SysPostBo post, PageQuery pageQuery) { + Page page = baseMapper.selectPagePostList(pageQuery.build(), buildQueryWrapper(post)); + return TableDataInfo.build(page); + } + + /** + * 查询岗位信息集合 + * + * @param post 岗位信息 + * @return 岗位信息集合 + */ + @Override + public List selectPostList(SysPostBo post) { + return baseMapper.selectVoList(buildQueryWrapper(post)); + } + + /** + * 查询用户所属岗位组 + * + * @param userId 用户ID + * @return 岗位ID + */ + @Override + public List selectPostsByUserId(Long userId) { + return baseMapper.selectPostsByUserId(userId); + } + + /** + * 根据查询条件构建查询包装器 + * + * @param bo 查询条件对象 + * @return 构建好的查询包装器 + */ + private LambdaQueryWrapper buildQueryWrapper(SysPostBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(StringUtils.isNotBlank(bo.getPostCode()), SysPost::getPostCode, bo.getPostCode()) + .like(StringUtils.isNotBlank(bo.getPostCategory()), SysPost::getPostCategory, bo.getPostCategory()) + .like(StringUtils.isNotBlank(bo.getPostName()), SysPost::getPostName, bo.getPostName()) + .eq(StringUtils.isNotBlank(bo.getStatus()), SysPost::getStatus, bo.getStatus()) + .between(params.get("beginTime") != null && params.get("endTime") != null, + SysPost::getCreateTime, params.get("beginTime"), params.get("endTime")) + .orderByAsc(SysPost::getPostSort); + if (ObjectUtil.isNotNull(bo.getDeptId())) { + //优先单部门搜索 + wrapper.eq(SysPost::getDeptId, bo.getDeptId()); + } else if (ObjectUtil.isNotNull(bo.getBelongDeptId())) { + //部门树搜索 + wrapper.and(x -> { + List deptList = deptMapper.selectListByParentId(bo.getBelongDeptId()); + List deptIds = StreamUtils.toList(deptList, SysDept::getDeptId); + deptIds.add(bo.getBelongDeptId()); + x.in(SysPost::getDeptId, deptIds); + }); + } + return wrapper; + } + + /** + * 查询所有岗位 + * + * @return 岗位列表 + */ + @Override + public List selectPostAll() { + return baseMapper.selectVoList(new QueryWrapper<>()); + } + + /** + * 通过岗位ID查询岗位信息 + * + * @param postId 岗位ID + * @return 角色对象信息 + */ + @Override + public SysPostVo selectPostById(Long postId) { + return baseMapper.selectVoById(postId); + } + + /** + * 根据用户ID获取岗位选择框列表 + * + * @param userId 用户ID + * @return 选中岗位ID列表 + */ + @Override + public List selectPostListByUserId(Long userId) { + List list = baseMapper.selectPostsByUserId(userId); + return StreamUtils.toList(list, SysPostVo::getPostId); + } + + /** + * 通过岗位ID串查询岗位 + * + * @param postIds 岗位id串 + * @return 岗位列表信息 + */ + @Override + public List selectPostByIds(List postIds) { + return baseMapper.selectVoList(new LambdaQueryWrapper() + .select(SysPost::getPostId, SysPost::getPostName, SysPost::getPostCode) + .eq(SysPost::getStatus, SystemConstants.NORMAL) + .in(CollUtil.isNotEmpty(postIds), SysPost::getPostId, postIds)); + } + + /** + * 校验岗位名称是否唯一 + * + * @param post 岗位信息 + * @return 结果 + */ + @Override + public boolean checkPostNameUnique(SysPostBo post) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysPost::getPostName, post.getPostName()) + .eq(SysPost::getDeptId, post.getDeptId()) + .ne(ObjectUtil.isNotNull(post.getPostId()), SysPost::getPostId, post.getPostId())); + return !exist; + } + + /** + * 校验岗位编码是否唯一 + * + * @param post 岗位信息 + * @return 结果 + */ + @Override + public boolean checkPostCodeUnique(SysPostBo post) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysPost::getPostCode, post.getPostCode()) + .ne(ObjectUtil.isNotNull(post.getPostId()), SysPost::getPostId, post.getPostId())); + return !exist; + } + + /** + * 通过岗位ID查询岗位使用数量 + * + * @param postId 岗位ID + * @return 结果 + */ + @Override + public long countUserPostById(Long postId) { + return userPostMapper.selectCount(new LambdaQueryWrapper().eq(SysUserPost::getPostId, postId)); + } + + /** + * 通过部门ID查询岗位使用数量 + * + * @param deptId 部门id + * @return 结果 + */ + @Override + public long countPostByDeptId(Long deptId) { + return baseMapper.selectCount(new LambdaQueryWrapper().eq(SysPost::getDeptId, deptId)); + } + + /** + * 删除岗位信息 + * + * @param postId 岗位ID + * @return 结果 + */ + @Override + public int deletePostById(Long postId) { + return baseMapper.deleteById(postId); + } + + /** + * 批量删除岗位信息 + * + * @param postIds 需要删除的岗位ID + * @return 结果 + */ + @Override + public int deletePostByIds(Long[] postIds) { + for (Long postId : postIds) { + SysPost post = baseMapper.selectById(postId); + if (countUserPostById(postId) > 0) { + throw new ServiceException(String.format("%1$s已分配,不能删除!", post.getPostName())); + } + } + return baseMapper.deleteByIds(Arrays.asList(postIds)); + } + + /** + * 新增保存岗位信息 + * + * @param bo 岗位信息 + * @return 结果 + */ + @Override + public int insertPost(SysPostBo bo) { + SysPost post = MapstructUtils.convert(bo, SysPost.class); + return baseMapper.insert(post); + } + + /** + * 修改保存岗位信息 + * + * @param bo 岗位信息 + * @return 结果 + */ + @Override + public int updatePost(SysPostBo bo) { + SysPost post = MapstructUtils.convert(bo, SysPost.class); + return baseMapper.updateById(post); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysRoleServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysRoleServiceImpl.java new file mode 100644 index 0000000..8a7fb88 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysRoleServiceImpl.java @@ -0,0 +1,551 @@ +package org.dromara.system.service.impl; + +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +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.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.RoleService; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.SysRole; +import org.dromara.system.domain.SysRoleDept; +import org.dromara.system.domain.SysRoleMenu; +import org.dromara.system.domain.SysUserRole; +import org.dromara.system.domain.bo.SysRoleBo; +import org.dromara.system.domain.vo.SysRoleVo; +import org.dromara.system.mapper.SysRoleDeptMapper; +import org.dromara.system.mapper.SysRoleMapper; +import org.dromara.system.mapper.SysRoleMenuMapper; +import org.dromara.system.mapper.SysUserRoleMapper; +import org.dromara.system.service.SysRoleService; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * 角色 业务层处理 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysRoleServiceImpl implements SysRoleService, RoleService { + + private final SysRoleMapper baseMapper; + private final SysRoleMenuMapper roleMenuMapper; + private final SysUserRoleMapper userRoleMapper; + private final SysRoleDeptMapper roleDeptMapper; + + @Override + public TableDataInfo selectPageRoleList(SysRoleBo role, PageQuery pageQuery) { + Page page = baseMapper.selectPageRoleList(pageQuery.build(), this.buildQueryWrapper(role)); + return TableDataInfo.build(page); + } + + /** + * 根据条件分页查询角色数据 + * + * @param role 角色信息 + * @return 角色数据集合信息 + */ + @Override + public List selectRoleList(SysRoleBo role) { + return baseMapper.selectRoleList(this.buildQueryWrapper(role)); + } + + private Wrapper buildQueryWrapper(SysRoleBo bo) { + Map params = bo.getParams(); + QueryWrapper wrapper = Wrappers.query(); + wrapper.eq("r.del_flag", SystemConstants.NORMAL) + .eq(ObjectUtil.isNotNull(bo.getRoleId()), "r.role_id", bo.getRoleId()) + .like(StringUtils.isNotBlank(bo.getRoleName()), "r.role_name", bo.getRoleName()) + .eq(StringUtils.isNotBlank(bo.getStatus()), "r.status", bo.getStatus()) + .like(StringUtils.isNotBlank(bo.getRoleKey()), "r.role_key", bo.getRoleKey()) + .between(params.get("beginTime") != null && params.get("endTime") != null, + "r.create_time", params.get("beginTime"), params.get("endTime")) + .orderByAsc("r.role_sort").orderByAsc("r.create_time"); + return wrapper; + } + + /** + * 根据用户ID查询角色 + * + * @param userId 用户ID + * @return 角色列表 + */ + @Override + public List selectRolesByUserId(Long userId) { + return baseMapper.selectRolesByUserId(userId); + } + + /** + * 根据用户ID查询角色列表(包含被授权状态) + * + * @param userId 用户ID + * @return 角色列表 + */ + @Override + public List selectRolesAuthByUserId(Long userId) { + List userRoles = baseMapper.selectRolesByUserId(userId); + List roles = selectRoleAll(); + // 使用HashSet提高查找效率 + Set userRoleIds = StreamUtils.toSet(userRoles, SysRoleVo::getRoleId); + for (SysRoleVo role : roles) { + if (userRoleIds.contains(role.getRoleId())) { + role.setFlag(true); + } + } + return roles; + } + + /** + * 根据用户ID查询权限 + * + * @param userId 用户ID + * @return 权限列表 + */ + @Override + public Set selectRolePermissionByUserId(Long userId) { + List perms = baseMapper.selectRolesByUserId(userId); + Set permsSet = new HashSet<>(); + for (SysRoleVo perm : perms) { + if (ObjectUtil.isNotNull(perm)) { + permsSet.addAll(StringUtils.splitList(perm.getRoleKey().trim())); + } + } + return permsSet; + } + + /** + * 查询所有角色 + * + * @return 角色列表 + */ + @Override + public List selectRoleAll() { + return this.selectRoleList(new SysRoleBo()); + } + + /** + * 根据用户ID获取角色选择框列表 + * + * @param userId 用户ID + * @return 选中角色ID列表 + */ + @Override + public List selectRoleListByUserId(Long userId) { + List list = baseMapper.selectRolesByUserId(userId); + return StreamUtils.toList(list, SysRoleVo::getRoleId); + } + + /** + * 通过角色ID查询角色 + * + * @param roleId 角色ID + * @return 角色对象信息 + */ + @Override + public SysRoleVo selectRoleById(Long roleId) { + return baseMapper.selectRoleById(roleId); + } + + /** + * 通过角色ID串查询角色 + * + * @param roleIds 角色ID串 + * @return 角色列表信息 + */ + @Override + public List selectRoleByIds(List roleIds) { + return baseMapper.selectRoleList(new QueryWrapper() + .eq("r.status", SystemConstants.NORMAL) + .in(CollUtil.isNotEmpty(roleIds), "r.role_id", roleIds)); + } + + /** + * 校验角色名称是否唯一 + * + * @param role 角色信息 + * @return 结果 + */ + @Override + public boolean checkRoleNameUnique(SysRoleBo role) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysRole::getRoleName, role.getRoleName()) + .ne(ObjectUtil.isNotNull(role.getRoleId()), SysRole::getRoleId, role.getRoleId())); + return !exist; + } + + /** + * 校验角色权限是否唯一 + * + * @param role 角色信息 + * @return 结果 + */ + @Override + public boolean checkRoleKeyUnique(SysRoleBo role) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysRole::getRoleKey, role.getRoleKey()) + .ne(ObjectUtil.isNotNull(role.getRoleId()), SysRole::getRoleId, role.getRoleId())); + return !exist; + } + + /** + * 校验角色是否允许操作 + * + * @param role 角色信息 + */ + @Override + public void checkRoleAllowed(SysRoleBo role) { + if (ObjectUtil.isNotNull(role.getRoleId()) && LoginHelper.isSuperAdmin(role.getRoleId())) { + throw new ServiceException("不允许操作超级管理员角色"); + } + String[] keys = new String[]{SystemConstants.SUPER_ADMIN_ROLE_KEY, SystemConstants.ADMIN_ROLE_KEY}; + // 新增不允许使用 管理员标识符 + if (ObjectUtil.isNull(role.getRoleId()) + && StringUtils.equalsAny(role.getRoleKey(), keys)) { + throw new ServiceException("不允许使用系统内置管理员角色标识符!"); + } + // 修改不允许修改 管理员标识符 + if (ObjectUtil.isNotNull(role.getRoleId())) { + SysRole sysRole = baseMapper.selectById(role.getRoleId()); + // 如果标识符不相等 判断为修改了管理员标识符 + if (!StringUtils.equals(sysRole.getRoleKey(), role.getRoleKey())) { + if (StringUtils.equalsAny(sysRole.getRoleKey(), keys)) { + throw new ServiceException("不允许修改系统内置管理员角色标识符!"); + } else if (StringUtils.equalsAny(role.getRoleKey(), keys)) { + throw new ServiceException("不允许使用系统内置管理员角色标识符!"); + } + } + } + } + + /** + * 校验角色是否有数据权限 + * + * @param roleId 角色id + */ + @Override + public void checkRoleDataScope(Long roleId) { + if (ObjectUtil.isNull(roleId)) { + return; + } + if (LoginHelper.isSuperAdmin()) { + return; + } + List roles = this.selectRoleList(new SysRoleBo(roleId)); + if (CollUtil.isEmpty(roles)) { + throw new ServiceException("没有权限访问角色数据!"); + } + + } + + /** + * 通过角色ID查询角色使用数量 + * + * @param roleId 角色ID + * @return 结果 + */ + @Override + public long countUserRoleByRoleId(Long roleId) { + return userRoleMapper.selectCount(new LambdaQueryWrapper().eq(SysUserRole::getRoleId, roleId)); + } + + /** + * 新增保存角色信息 + * + * @param bo 角色信息 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int insertRole(SysRoleBo bo) { + SysRole role = MapstructUtils.convert(bo, SysRole.class); + // 新增角色信息 + baseMapper.insert(role); + bo.setRoleId(role.getRoleId()); + return insertRoleMenu(bo); + } + + /** + * 修改保存角色信息 + * + * @param bo 角色信息 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int updateRole(SysRoleBo bo) { + SysRole role = MapstructUtils.convert(bo, SysRole.class); + + if (SystemConstants.DISABLE.equals(role.getStatus()) && this.countUserRoleByRoleId(role.getRoleId()) > 0) { + throw new ServiceException("角色已分配,不能禁用!"); + } + // 修改角色信息 + baseMapper.updateById(role); + // 删除角色与菜单关联 + roleMenuMapper.delete(new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, role.getRoleId())); + return insertRoleMenu(bo); + } + + /** + * 修改角色状态 + * + * @param roleId 角色ID + * @param status 角色状态 + * @return 结果 + */ + @Override + public int updateRoleStatus(Long roleId, String status) { + if (SystemConstants.DISABLE.equals(status) && this.countUserRoleByRoleId(roleId) > 0) { + throw new ServiceException("角色已分配,不能禁用!"); + } + return baseMapper.update(null, + new LambdaUpdateWrapper() + .set(SysRole::getStatus, status) + .eq(SysRole::getRoleId, roleId)); + } + + /** + * 修改数据权限信息 + * + * @param bo 角色信息 + * @return 结果 + */ + @CacheEvict(cacheNames = CacheNames.SYS_ROLE_CUSTOM, key = "#bo.roleId") + @Override + @Transactional(rollbackFor = Exception.class) + public int authDataScope(SysRoleBo bo) { + SysRole role = MapstructUtils.convert(bo, SysRole.class); + // 修改角色信息 + baseMapper.updateById(role); + // 删除角色与部门关联 + roleDeptMapper.delete(new LambdaQueryWrapper().eq(SysRoleDept::getRoleId, role.getRoleId())); + // 新增角色和部门信息(数据权限) + return insertRoleDept(bo); + } + + /** + * 新增角色菜单信息 + * + * @param role 角色对象 + */ + private int insertRoleMenu(SysRoleBo role) { + int rows = 1; + // 新增用户与角色管理 + List list = new ArrayList<>(); + for (Long menuId : role.getMenuIds()) { + SysRoleMenu rm = new SysRoleMenu(); + rm.setRoleId(role.getRoleId()); + rm.setMenuId(menuId); + list.add(rm); + } + if (list.size() > 0) { + rows = roleMenuMapper.insertBatch(list) ? list.size() : 0; + } + return rows; + } + + /** + * 新增角色部门信息(数据权限) + * + * @param role 角色对象 + */ + private int insertRoleDept(SysRoleBo role) { + int rows = 1; + // 新增角色与部门(数据权限)管理 + List list = new ArrayList<>(); + for (Long deptId : role.getDeptIds()) { + SysRoleDept rd = new SysRoleDept(); + rd.setRoleId(role.getRoleId()); + rd.setDeptId(deptId); + list.add(rd); + } + if (list.size() > 0) { + rows = roleDeptMapper.insertBatch(list) ? list.size() : 0; + } + return rows; + } + + /** + * 通过角色ID删除角色 + * + * @param roleId 角色ID + * @return 结果 + */ + @CacheEvict(cacheNames = CacheNames.SYS_ROLE_CUSTOM, key = "#roleId") + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteRoleById(Long roleId) { + // 删除角色与菜单关联 + roleMenuMapper.delete(new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, roleId)); + // 删除角色与部门关联 + roleDeptMapper.delete(new LambdaQueryWrapper().eq(SysRoleDept::getRoleId, roleId)); + return baseMapper.deleteById(roleId); + } + + /** + * 批量删除角色信息 + * + * @param roleIds 需要删除的角色ID + * @return 结果 + */ + @CacheEvict(cacheNames = CacheNames.SYS_ROLE_CUSTOM, allEntries = true) + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteRoleByIds(Long[] roleIds) { + for (Long roleId : roleIds) { + SysRole role = baseMapper.selectById(roleId); + checkRoleAllowed(BeanUtil.toBean(role, SysRoleBo.class)); + checkRoleDataScope(roleId); + if (countUserRoleByRoleId(roleId) > 0) { + throw new ServiceException(String.format("%1$s已分配,不能删除!", role.getRoleName())); + } + } + List ids = Arrays.asList(roleIds); + // 删除角色与菜单关联 + roleMenuMapper.delete(new LambdaQueryWrapper().in(SysRoleMenu::getRoleId, ids)); + // 删除角色与部门关联 + roleDeptMapper.delete(new LambdaQueryWrapper().in(SysRoleDept::getRoleId, ids)); + return baseMapper.deleteByIds(ids); + } + + /** + * 取消授权用户角色 + * + * @param userRole 用户和角色关联信息 + * @return 结果 + */ + @Override + public int deleteAuthUser(SysUserRole userRole) { + int rows = userRoleMapper.delete(new LambdaQueryWrapper() + .eq(SysUserRole::getRoleId, userRole.getRoleId()) + .eq(SysUserRole::getUserId, userRole.getUserId())); + if (rows > 0) { + cleanOnlineUser(List.of(userRole.getUserId())); + } + return rows; + } + + /** + * 批量取消授权用户角色 + * + * @param roleId 角色ID + * @param userIds 需要取消授权的用户数据ID + * @return 结果 + */ + @Override + public int deleteAuthUsers(Long roleId, Long[] userIds) { + List ids = List.of(userIds); + int rows = userRoleMapper.delete(new LambdaQueryWrapper() + .eq(SysUserRole::getRoleId, roleId) + .in(SysUserRole::getUserId, ids)); + if (rows > 0) { + cleanOnlineUser(ids); + } + return rows; + } + + /** + * 批量选择授权用户角色 + * + * @param roleId 角色ID + * @param userIds 需要授权的用户数据ID + * @return 结果 + */ + @Override + public int insertAuthUsers(Long roleId, Long[] userIds) { + // 新增用户与角色管理 + int rows = 1; + List ids = List.of(userIds); + List list = StreamUtils.toList(ids, userId -> { + SysUserRole ur = new SysUserRole(); + ur.setUserId(userId); + ur.setRoleId(roleId); + return ur; + }); + if (CollUtil.isNotEmpty(list)) { + rows = userRoleMapper.insertBatch(list) ? list.size() : 0; + } + if (rows > 0) { + cleanOnlineUser(ids); + } + return rows; + } + + @Override + public void cleanOnlineUserByRole(Long roleId) { + // 如果角色未绑定用户 直接返回 + Long num = userRoleMapper.selectCount(new LambdaQueryWrapper().eq(SysUserRole::getRoleId, roleId)); + if (num == 0) { + return; + } + List keys = StpUtil.searchTokenValue("", 0, -1, false); + if (CollUtil.isEmpty(keys)) { + return; + } + // 角色关联的在线用户量过大会导致redis阻塞卡顿 谨慎操作 + keys.parallelStream().forEach(key -> { + String token = StringUtils.substringAfterLast(key, ":"); + // 如果已经过期则跳过 + if (StpUtil.stpLogic.getTokenActiveTimeoutByToken(token) < -1) { + return; + } + LoginUser loginUser = LoginHelper.getLoginUser(token); + if (ObjectUtil.isNull(loginUser) || CollUtil.isEmpty(loginUser.getRoles())) { + return; + } + if (loginUser.getRoles().stream().anyMatch(r -> r.getRoleId().equals(roleId))) { + try { + StpUtil.logoutByTokenValue(token); + } catch (NotLoginException ignored) { + } + } + }); + } + + @Override + public void cleanOnlineUser(List userIds) { + List keys = StpUtil.searchTokenValue("", 0, -1, false); + if (CollUtil.isEmpty(keys)) { + return; + } + // 角色关联的在线用户量过大会导致redis阻塞卡顿 谨慎操作 + keys.parallelStream().forEach(key -> { + String token = StringUtils.substringAfterLast(key, ":"); + // 如果已经过期则跳过 + if (StpUtil.stpLogic.getTokenActiveTimeoutByToken(token) < -1) { + return; + } + LoginUser loginUser = LoginHelper.getLoginUser(token); + if (ObjectUtil.isNull(loginUser)) { + return; + } + if (userIds.contains(loginUser.getUserId())) { + try { + StpUtil.logoutByTokenValue(token); + } catch (NotLoginException ignored) { + } + } + }); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysSensitiveServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysSensitiveServiceImpl.java new file mode 100644 index 0000000..d938657 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysSensitiveServiceImpl.java @@ -0,0 +1,42 @@ +package org.dromara.system.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.util.ArrayUtil; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.sensitive.core.SensitiveService; +import org.springframework.stereotype.Service; + +/** + * 脱敏服务 + * 默认管理员不过滤 + * 需自行根据业务重写实现 + * + * @author pengles + * @version 3.6.0 + */ +@Service +public class SysSensitiveServiceImpl implements SensitiveService { + + /** + * 是否脱敏 + */ + @Override + public boolean isSensitive(String[] roleKey, String[] perms) { + if (!LoginHelper.isLogin()) { + return true; + } + boolean roleExist = ArrayUtil.isNotEmpty(roleKey); + boolean permsExist = ArrayUtil.isNotEmpty(perms); + if (roleExist && permsExist) { + if (StpUtil.hasRoleOr(roleKey) && StpUtil.hasPermissionOr(perms)) { + return false; + } + } else if (roleExist && StpUtil.hasRoleOr(roleKey)) { + return false; + } else if (permsExist && StpUtil.hasPermissionOr(perms)) { + return false; + } + return !LoginHelper.isSuperAdmin(); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysSocialServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysSocialServiceImpl.java new file mode 100644 index 0000000..69f3dbb --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysSocialServiceImpl.java @@ -0,0 +1,112 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.system.domain.SysSocial; +import org.dromara.system.domain.bo.SysSocialBo; +import org.dromara.system.domain.vo.SysSocialVo; +import org.dromara.system.mapper.SysSocialMapper; +import org.dromara.system.service.SysSocialService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 社会化关系Service业务层处理 + * + * @author thiszhc + * @date 2023-06-12 + */ +@RequiredArgsConstructor +@Service +public class SysSocialServiceImpl implements SysSocialService { + + private final SysSocialMapper baseMapper; + + + /** + * 查询社会化关系 + */ + @Override + public SysSocialVo queryById(String id) { + return baseMapper.selectVoById(id); + } + + /** + * 授权列表 + */ + @Override + public List queryList(SysSocialBo bo) { + LambdaQueryWrapper lqw = new LambdaQueryWrapper() + .eq(ObjectUtil.isNotNull(bo.getUserId()), SysSocial::getUserId, bo.getUserId()) + .eq(StringUtils.isNotBlank(bo.getAuthId()), SysSocial::getAuthId, bo.getAuthId()) + .eq(StringUtils.isNotBlank(bo.getSource()), SysSocial::getSource, bo.getSource()); + return baseMapper.selectVoList(lqw); + } + + @Override + public List queryListByUserId(Long userId) { + return baseMapper.selectVoList(new LambdaQueryWrapper().eq(SysSocial::getUserId, userId)); + } + + + /** + * 新增社会化关系 + */ + @Override + public Boolean insertByBo(SysSocialBo bo) { + SysSocial add = MapstructUtils.convert(bo, SysSocial.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + if (add != null) { + bo.setId(add.getId()); + } else { + return false; + } + } + return flag; + } + + /** + * 更新社会化关系 + */ + @Override + public Boolean updateByBo(SysSocialBo bo) { + SysSocial update = MapstructUtils.convert(bo, SysSocial.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(SysSocial entity) { + //TODO 做一些数据校验,如唯一约束 + } + + + /** + * 删除社会化关系 + */ + @Override + public Boolean deleteWithValidById(Long id) { + return baseMapper.deleteById(id) > 0; + } + + + /** + * 根据 authId 查询用户信息 + * + * @param authId 认证id + * @return 授权信息 + */ + @Override + public List selectByAuthId(String authId) { + return baseMapper.selectVoList(new LambdaQueryWrapper().eq(SysSocial::getAuthId, authId)); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysTaskAssigneeServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysTaskAssigneeServiceImpl.java new file mode 100644 index 0000000..585d19b --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysTaskAssigneeServiceImpl.java @@ -0,0 +1,138 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.convert.Convert; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.dto.TaskAssigneeDTO; +import org.dromara.common.core.domain.model.TaskAssigneeBody; +import org.dromara.common.core.service.TaskAssigneeService; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.SysDeptBo; +import org.dromara.system.domain.bo.SysPostBo; +import org.dromara.system.domain.bo.SysRoleBo; +import org.dromara.system.domain.bo.SysUserBo; +import org.dromara.system.domain.vo.SysDeptVo; +import org.dromara.system.domain.vo.SysPostVo; +import org.dromara.system.domain.vo.SysRoleVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.service.SysDeptService; +import org.dromara.system.service.SysPostService; +import org.dromara.system.service.SysRoleService; +import org.dromara.system.service.SysUserService; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * 工作流设计器获取任务执行人 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysTaskAssigneeServiceImpl implements TaskAssigneeService { + + // 上级Service注入下级Service 其他Service永远不可能注入当前类 避免循环注入 + private final SysPostService postService; + private final SysDeptService deptService; + private final SysUserService userService; + private final SysRoleService roleService; + + /** + * 查询角色并返回任务指派的列表,支持分页 + * + * @param taskQuery 查询条件 + * @return 办理人 + */ + @Override + public TaskAssigneeDTO selectRolesByTaskAssigneeList(TaskAssigneeBody taskQuery) { + PageQuery pageQuery = new PageQuery(taskQuery.getPageSize(), taskQuery.getPageNum()); + SysRoleBo bo = new SysRoleBo(); + bo.setRoleKey(taskQuery.getHandlerCode()); + bo.setRoleName(taskQuery.getHandlerName()); + bo.setStatus(SystemConstants.NORMAL); + Map params = bo.getParams(); + params.put("beginTime", taskQuery.getBeginTime()); + params.put("endTime", taskQuery.getEndTime()); + TableDataInfo page = roleService.selectPageRoleList(bo, pageQuery); + // 使用封装的字段映射方法进行转换 + List handlers = TaskAssigneeDTO.convertToHandlerList(page.getRows(), + SysRoleVo::getRoleId, SysRoleVo::getRoleKey, SysRoleVo::getRoleName, null, SysRoleVo::getCreateTime); + return new TaskAssigneeDTO(page.getTotal(), handlers); + } + + /** + * 查询岗位并返回任务指派的列表,支持分页 + * + * @param taskQuery 查询条件 + * @return 办理人 + */ + @Override + public TaskAssigneeDTO selectPostsByTaskAssigneeList(TaskAssigneeBody taskQuery) { + PageQuery pageQuery = new PageQuery(taskQuery.getPageSize(), taskQuery.getPageNum()); + SysPostBo bo = new SysPostBo(); + bo.setPostCategory(taskQuery.getHandlerCode()); + bo.setPostName(taskQuery.getHandlerName()); + bo.setStatus(SystemConstants.NORMAL); + Map params = bo.getParams(); + params.put("beginTime", taskQuery.getBeginTime()); + params.put("endTime", taskQuery.getEndTime()); + bo.setBelongDeptId(Convert.toLong(taskQuery.getGroupId())); + TableDataInfo page = postService.selectPagePostList(bo, pageQuery); + // 使用封装的字段映射方法进行转换 + List handlers = TaskAssigneeDTO.convertToHandlerList(page.getRows(), + SysPostVo::getPostId, SysPostVo::getPostCategory, SysPostVo::getPostName, SysPostVo::getDeptId, SysPostVo::getCreateTime); + return new TaskAssigneeDTO(page.getTotal(), handlers); + } + + /** + * 查询部门并返回任务指派的列表,支持分页 + * + * @param taskQuery 查询条件 + * @return 办理人 + */ + @Override + public TaskAssigneeDTO selectDeptsByTaskAssigneeList(TaskAssigneeBody taskQuery) { + PageQuery pageQuery = new PageQuery(taskQuery.getPageSize(), taskQuery.getPageNum()); + SysDeptBo bo = new SysDeptBo(); + bo.setDeptCategory(taskQuery.getHandlerCode()); + bo.setDeptName(taskQuery.getHandlerName()); + bo.setStatus(SystemConstants.NORMAL); + Map params = bo.getParams(); + params.put("beginTime", taskQuery.getBeginTime()); + params.put("endTime", taskQuery.getEndTime()); + bo.setBelongDeptId(Convert.toLong(taskQuery.getGroupId())); + TableDataInfo page = deptService.selectPageDeptList(bo, pageQuery); + // 使用封装的字段映射方法进行转换 + List handlers = TaskAssigneeDTO.convertToHandlerList(page.getRows(), + SysDeptVo::getDeptId, SysDeptVo::getDeptCategory, SysDeptVo::getDeptName, SysDeptVo::getParentId, SysDeptVo::getCreateTime); + return new TaskAssigneeDTO(page.getTotal(), handlers); + } + + /** + * 查询用户并返回任务指派的列表,支持分页 + * + * @param taskQuery 查询条件 + * @return 办理人 + */ + @Override + public TaskAssigneeDTO selectUsersByTaskAssigneeList(TaskAssigneeBody taskQuery) { + PageQuery pageQuery = new PageQuery(taskQuery.getPageSize(), taskQuery.getPageNum()); + SysUserBo bo = new SysUserBo(); + bo.setUserName(taskQuery.getHandlerCode()); + bo.setNickName(taskQuery.getHandlerName()); + bo.setStatus(SystemConstants.NORMAL); + Map params = bo.getParams(); + params.put("beginTime", taskQuery.getBeginTime()); + params.put("endTime", taskQuery.getEndTime()); + bo.setDeptId(Convert.toLong(taskQuery.getGroupId())); + TableDataInfo page = userService.selectPageUserList(bo, pageQuery); + // 使用封装的字段映射方法进行转换 + List handlers = TaskAssigneeDTO.convertToHandlerList(page.getRows(), + SysUserVo::getUserId, SysUserVo::getUserName, SysUserVo::getNickName, SysUserVo::getDeptId, SysUserVo::getCreateTime); + return new TaskAssigneeDTO(page.getTotal(), handlers); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java new file mode 100644 index 0000000..442bad0 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java @@ -0,0 +1,840 @@ +package org.dromara.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +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.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.dto.UserDTO; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.UserService; +import org.dromara.common.core.utils.*; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.*; +import org.dromara.system.domain.bo.SysUserBo; +import org.dromara.system.domain.vo.SysPostVo; +import org.dromara.system.domain.vo.SysRoleVo; +import org.dromara.system.domain.vo.SysUserExportVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.mapper.*; +import org.dromara.system.service.SysUserService; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户 业务层处理 + * + * @author pengles + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class SysUserServiceImpl implements SysUserService, UserService { + + private final SysUserMapper baseMapper; + private final SysDeptMapper deptMapper; + private final SysRoleMapper roleMapper; + private final SysPostMapper postMapper; + private final SysUserRoleMapper userRoleMapper; + private final SysUserPostMapper userPostMapper; + + @Override + public TableDataInfo selectPageUserList(SysUserBo user, PageQuery pageQuery) { + Page page = baseMapper.selectPageUserList(pageQuery.build(), this.buildQueryWrapper(user)); + return TableDataInfo.build(page); + } + + /** + * 根据条件分页查询用户列表 + * + * @param user 用户信息 + * @return 用户信息集合信息 + */ + @Override + public List selectUserExportList(SysUserBo user) { + Map params = user.getParams(); + QueryWrapper wrapper = Wrappers.query(); + wrapper.eq("u.del_flag", SystemConstants.NORMAL) + .like(StringUtils.isNotBlank(user.getUserName()), "u.user_name", user.getUserName()) + .like(StringUtils.isNotBlank(user.getNickName()), "u.nick_name", user.getNickName()) + .eq(StringUtils.isNotBlank(user.getStatus()), "u.status", user.getStatus()) + .like(StringUtils.isNotBlank(user.getPhonenumber()), "u.phonenumber", user.getPhonenumber()) + .between(params.get("beginTime") != null && params.get("endTime") != null, + "u.create_time", params.get("beginTime"), params.get("endTime")) + .and(ObjectUtil.isNotNull(user.getDeptId()), w -> { + List deptList = deptMapper.selectListByParentId(user.getDeptId()); + List ids = StreamUtils.toList(deptList, SysDept::getDeptId); + ids.add(user.getDeptId()); + w.in("u.dept_id", ids); + }).orderByAsc("u.user_id"); + return baseMapper.selectUserExportList(wrapper); + } + + private Wrapper buildQueryWrapper(SysUserBo user) { + Map params = user.getParams(); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.eq(SysUser::getDelFlag, SystemConstants.NORMAL) + .eq(ObjectUtil.isNotNull(user.getUserId()), SysUser::getUserId, user.getUserId()) + .in(StringUtils.isNotBlank(user.getUserIds()), SysUser::getUserId, StringUtils.splitTo(user.getUserIds(), Convert::toLong)) + .like(StringUtils.isNotBlank(user.getUserName()), SysUser::getUserName, user.getUserName()) + .like(StringUtils.isNotBlank(user.getNickName()), SysUser::getNickName, user.getNickName()) + .eq(StringUtils.isNotBlank(user.getStatus()), SysUser::getStatus, user.getStatus()) + .like(StringUtils.isNotBlank(user.getPhonenumber()), SysUser::getPhonenumber, user.getPhonenumber()) + .between(params.get("beginTime") != null && params.get("endTime") != null, + SysUser::getCreateTime, params.get("beginTime"), params.get("endTime")) + .and(ObjectUtil.isNotNull(user.getDeptId()), w -> { + List deptList = deptMapper.selectListByParentId(user.getDeptId()); + List ids = StreamUtils.toList(deptList, SysDept::getDeptId); + ids.add(user.getDeptId()); + w.in(SysUser::getDeptId, ids); + }).orderByAsc(SysUser::getUserId); + if (StringUtils.isNotBlank(user.getExcludeUserIds())) { + wrapper.notIn(SysUser::getUserId, StringUtils.splitList(user.getExcludeUserIds())); + } + return wrapper; + } + + /** + * 根据条件分页查询已分配用户角色列表 + * + * @param user 用户信息 + * @return 用户信息集合信息 + */ + @Override + public TableDataInfo selectAllocatedList(SysUserBo user, PageQuery pageQuery) { + QueryWrapper wrapper = Wrappers.query(); + wrapper.eq("u.del_flag", SystemConstants.NORMAL) + .eq(ObjectUtil.isNotNull(user.getRoleId()), "r.role_id", user.getRoleId()) + .like(StringUtils.isNotBlank(user.getUserName()), "u.user_name", user.getUserName()) + .eq(StringUtils.isNotBlank(user.getStatus()), "u.status", user.getStatus()) + .like(StringUtils.isNotBlank(user.getPhonenumber()), "u.phonenumber", user.getPhonenumber()) + .orderByAsc("u.user_id"); + Page page = baseMapper.selectAllocatedList(pageQuery.build(), wrapper); + return TableDataInfo.build(page); + } + + /** + * 根据条件分页查询未分配用户角色列表 + * + * @param user 用户信息 + * @return 用户信息集合信息 + */ + @Override + public TableDataInfo selectUnallocatedList(SysUserBo user, PageQuery pageQuery) { + List userIds = userRoleMapper.selectUserIdsByRoleId(user.getRoleId()); + QueryWrapper wrapper = Wrappers.query(); + wrapper.eq("u.del_flag", SystemConstants.NORMAL) + .and(w -> w.ne("r.role_id", user.getRoleId()).or().isNull("r.role_id")) + .notIn(CollUtil.isNotEmpty(userIds), "u.user_id", userIds) + .like(StringUtils.isNotBlank(user.getUserName()), "u.user_name", user.getUserName()) + .like(StringUtils.isNotBlank(user.getPhonenumber()), "u.phonenumber", user.getPhonenumber()) + .orderByAsc("u.user_id"); + Page page = baseMapper.selectUnallocatedList(pageQuery.build(), wrapper); + return TableDataInfo.build(page); + } + + /** + * 通过用户名查询用户 + * + * @param userName 用户名 + * @return 用户对象信息 + */ + @Override + public SysUserVo selectUserByUserName(String userName) { + return baseMapper.selectVoOne(new LambdaQueryWrapper().eq(SysUser::getUserName, userName)); + } + + /** + * 通过手机号查询用户 + * + * @param phonenumber 手机号 + * @return 用户对象信息 + */ + @Override + public SysUserVo selectUserByPhonenumber(String phonenumber) { + return baseMapper.selectVoOne(new LambdaQueryWrapper().eq(SysUser::getPhonenumber, phonenumber)); + } + + /** + * 通过邮箱查询用户 + * + * @param email 邮箱 + * @return 用户对象信息 + */ + @Override + public SysUserVo selectUserByEmail(String email) { + return baseMapper.selectVoOne(new LambdaQueryWrapper().eq(SysUser::getEmail, email)); + } + + /** + * 更新用户登录信息 + * + * @param userId 用户ID + * @param ip 登录IP + */ + @Override + public void updateLoginInfo(Long userId, String ip) { + baseMapper.update(null, new LambdaUpdateWrapper() + .set(SysUser::getLoginIp, ip) + .set(SysUser::getLoginDate, new Date()) + .eq(SysUser::getUserId, userId)); + } + + /** + * 通过用户ID查询用户 + * + * @param userId 用户ID + * @return 用户对象信息 + */ + @Override + public SysUserVo selectUserById(Long userId) { + SysUserVo user = baseMapper.selectVoById(userId); + if (ObjectUtil.isNull(user)) { + return user; + } + user.setRoles(roleMapper.selectRolesByUserId(user.getUserId())); + return user; + } + + /** + * 通过用户ID串查询用户 + * + * @param userIds 用户ID串 + * @param deptId 部门id + * @return 用户列表信息 + */ + @Override + public List selectUserByIds(List userIds, Long deptId) { + return baseMapper.selectUserList(new LambdaQueryWrapper() + .select(SysUser::getUserId, SysUser::getUserName, SysUser::getNickName) + .eq(SysUser::getStatus, SystemConstants.NORMAL) + .eq(ObjectUtil.isNotNull(deptId), SysUser::getDeptId, deptId) + .in(CollUtil.isNotEmpty(userIds), SysUser::getUserId, userIds)); + } + + /** + * 查询用户所属角色组 + * + * @param userId 用户ID + * @return 结果 + */ + @Override + public String selectUserRoleGroup(Long userId) { + List list = roleMapper.selectRolesByUserId(userId); + if (CollUtil.isEmpty(list)) { + return StringUtils.EMPTY; + } + return StreamUtils.join(list, SysRoleVo::getRoleName); + } + + /** + * 查询用户所属岗位组 + * + * @param userId 用户ID + * @return 结果 + */ + @Override + public String selectUserPostGroup(Long userId) { + List list = postMapper.selectPostsByUserId(userId); + if (CollUtil.isEmpty(list)) { + return StringUtils.EMPTY; + } + return StreamUtils.join(list, SysPostVo::getPostName); + } + + /** + * 校验用户名称是否唯一 + * + * @param user 用户信息 + * @return 结果 + */ + @Override + public boolean checkUserNameUnique(SysUserBo user) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysUser::getUserName, user.getUserName()) + .ne(ObjectUtil.isNotNull(user.getUserId()), SysUser::getUserId, user.getUserId())); + return !exist; + } + + /** + * 校验手机号码是否唯一 + * + * @param user 用户信息 + */ + @Override + public boolean checkPhoneUnique(SysUserBo user) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysUser::getPhonenumber, user.getPhonenumber()) + .ne(ObjectUtil.isNotNull(user.getUserId()), SysUser::getUserId, user.getUserId())); + return !exist; + } + + /** + * 校验email是否唯一 + * + * @param user 用户信息 + */ + @Override + public boolean checkEmailUnique(SysUserBo user) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(SysUser::getEmail, user.getEmail()) + .ne(ObjectUtil.isNotNull(user.getUserId()), SysUser::getUserId, user.getUserId())); + return !exist; + } + + /** + * 校验用户是否允许操作 + * + * @param userId 用户ID + */ + @Override + public void checkUserAllowed(Long userId) { + if (ObjectUtil.isNotNull(userId) && LoginHelper.isSuperAdmin(userId)) { + throw new ServiceException("不允许操作超级管理员用户"); + } + } + + /** + * 校验用户是否有数据权限 + * + * @param userId 用户id + */ + @Override + public void checkUserDataScope(Long userId) { + if (ObjectUtil.isNull(userId)) { + return; + } + if (LoginHelper.isSuperAdmin()) { + return; + } + if (baseMapper.countUserById(userId) == 0) { + throw new ServiceException("没有权限访问用户数据!"); + } + } + + /** + * 新增保存用户信息 + * + * @param user 用户信息 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int insertUser(SysUserBo user) { + SysUser sysUser = MapstructUtils.convert(user, SysUser.class); + // 新增用户信息 + int rows = baseMapper.insert(sysUser); + user.setUserId(sysUser.getUserId()); + // 新增用户岗位关联 + insertUserPost(user, false); + // 新增用户与角色管理 + insertUserRole(user, false); + return rows; + } + + /** + * 注册用户信息 + * + * @param user 用户信息 + * @return 结果 + */ + @Override + public boolean registerUser(SysUserBo user) { + user.setCreateBy(0L); + user.setUpdateBy(0L); + SysUser sysUser = MapstructUtils.convert(user, SysUser.class); + return baseMapper.insert(sysUser) > 0; + } + + /** + * 修改保存用户信息 + * + * @param user 用户信息 + * @return 结果 + */ + @Override + @CacheEvict(cacheNames = CacheNames.SYS_NICKNAME, key = "#user.userId") + @Transactional(rollbackFor = Exception.class) + public int updateUser(SysUserBo user) { + // 新增用户与角色管理 + insertUserRole(user, true); + // 新增用户与岗位管理 + insertUserPost(user, true); + SysUser sysUser = MapstructUtils.convert(user, SysUser.class); + // 防止错误更新后导致的数据误删除 + int flag = baseMapper.updateById(sysUser); + if (flag < 1) { + throw new ServiceException("修改用户" + user.getUserName() + "信息失败"); + } + return flag; + } + + /** + * 用户授权角色 + * + * @param userId 用户ID + * @param roleIds 角色组 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void insertUserAuth(Long userId, Long[] roleIds) { + insertUserRole(userId, roleIds, true); + } + + /** + * 修改用户状态 + * + * @param userId 用户ID + * @param status 帐号状态 + * @return 结果 + */ + @Override + public int updateUserStatus(Long userId, String status) { + return baseMapper.update(null, + new LambdaUpdateWrapper() + .set(SysUser::getStatus, status) + .eq(SysUser::getUserId, userId)); + } + + /** + * 修改用户基本信息 + * + * @param user 用户信息 + * @return 结果 + */ + @CacheEvict(cacheNames = CacheNames.SYS_NICKNAME, key = "#user.userId") + @Override + public int updateUserProfile(SysUserBo user) { + return baseMapper.update(null, + new LambdaUpdateWrapper() + .set(ObjectUtil.isNotNull(user.getNickName()), SysUser::getNickName, user.getNickName()) + .set(SysUser::getPhonenumber, user.getPhonenumber()) + .set(SysUser::getEmail, user.getEmail()) + .set(SysUser::getSex, user.getSex()) + .eq(SysUser::getUserId, user.getUserId())); + } + + /** + * 修改用户头像 + * + * @param userId 用户ID + * @param avatar 头像地址 + * @return 结果 + */ + @Override + public boolean updateUserAvatar(Long userId, Long avatar) { + return baseMapper.update(null, + new LambdaUpdateWrapper() + .set(SysUser::getAvatar, avatar) + .eq(SysUser::getUserId, userId)) > 0; + } + + /** + * 重置用户密码 + * + * @param userId 用户ID + * @param password 密码 + * @return 结果 + */ + @Override + public int resetUserPwd(Long userId, String password) { + return baseMapper.update(null, + new LambdaUpdateWrapper() + .set(SysUser::getPassword, password) + .eq(SysUser::getUserId, userId)); + } + + /** + * 新增用户角色信息 + * + * @param user 用户对象 + * @param clear 清除已存在的关联数据 + */ + private void insertUserRole(SysUserBo user, boolean clear) { + this.insertUserRole(user.getUserId(), user.getRoleIds(), clear); + } + + /** + * 新增用户岗位信息 + * + * @param user 用户对象 + * @param clear 清除已存在的关联数据 + */ + private void insertUserPost(SysUserBo user, boolean clear) { + Long[] posts = user.getPostIds(); + if (ArrayUtil.isNotEmpty(posts)) { + if (clear) { + // 删除用户与岗位关联 + userPostMapper.delete(new LambdaQueryWrapper().eq(SysUserPost::getUserId, user.getUserId())); + } + // 新增用户与岗位管理 + List list = StreamUtils.toList(List.of(posts), postId -> { + SysUserPost up = new SysUserPost(); + up.setUserId(user.getUserId()); + up.setPostId(postId); + return up; + }); + userPostMapper.insertBatch(list); + } + } + + /** + * 新增用户角色信息 + * + * @param userId 用户ID + * @param roleIds 角色组 + * @param clear 清除已存在的关联数据 + */ + private void insertUserRole(Long userId, Long[] roleIds, boolean clear) { + if (ArrayUtil.isNotEmpty(roleIds)) { + List roleList = new ArrayList<>(List.of(roleIds)); + if (!LoginHelper.isSuperAdmin(userId)) { + roleList.remove(SystemConstants.SUPER_ADMIN_ID); + } + // 判断是否具有此角色的操作权限 + List roles = roleMapper.selectRoleList( + new QueryWrapper().in("r.role_id", roleList)); + if (CollUtil.isEmpty(roles)) { + throw new ServiceException("没有权限访问角色的数据"); + } + if (clear) { + // 删除用户与角色关联 + userRoleMapper.delete(new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); + } + // 新增用户与角色管理 + List list = StreamUtils.toList(roleList, roleId -> { + SysUserRole ur = new SysUserRole(); + ur.setUserId(userId); + ur.setRoleId(roleId); + return ur; + }); + userRoleMapper.insertBatch(list); + } + } + + /** + * 通过用户ID删除用户 + * + * @param userId 用户ID + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteUserById(Long userId) { + // 删除用户与角色关联 + userRoleMapper.delete(new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); + // 删除用户与岗位表 + userPostMapper.delete(new LambdaQueryWrapper().eq(SysUserPost::getUserId, userId)); + // 防止更新失败导致的数据删除 + int flag = baseMapper.deleteById(userId); + if (flag < 1) { + throw new ServiceException("删除用户失败!"); + } + return flag; + } + + /** + * 批量删除用户信息 + * + * @param userIds 需要删除的用户ID + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteUserByIds(Long[] userIds) { + for (Long userId : userIds) { + checkUserAllowed(userId); + checkUserDataScope(userId); + } + List ids = List.of(userIds); + // 删除用户与角色关联 + userRoleMapper.delete(new LambdaQueryWrapper().in(SysUserRole::getUserId, ids)); + // 删除用户与岗位表 + userPostMapper.delete(new LambdaQueryWrapper().in(SysUserPost::getUserId, ids)); + // 防止更新失败导致的数据删除 + int flag = baseMapper.deleteByIds(ids); + if (flag < 1) { + throw new ServiceException("删除用户失败!"); + } + return flag; + } + + /** + * 通过部门id查询当前部门所有用户 + * + * @param deptId 部门ID + * @return 用户信息集合信息 + */ + @Override + public List selectUserListByDept(Long deptId) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(SysUser::getDeptId, deptId); + lqw.orderByAsc(SysUser::getUserId); + return baseMapper.selectVoList(lqw); + } + + /** + * 通过用户ID查询用户账户 + * + * @param userId 用户ID + * @return 用户账户 + */ + @Cacheable(cacheNames = CacheNames.SYS_USER_NAME, key = "#userId") + @Override + public String selectUserNameById(Long userId) { + SysUser sysUser = baseMapper.selectOne(new LambdaQueryWrapper() + .select(SysUser::getUserName).eq(SysUser::getUserId, userId)); + return ObjectUtils.notNullGetter(sysUser, SysUser::getUserName); + } + + /** + * 通过用户ID查询用户账户 + * + * @param userId 用户ID + * @return 用户账户 + */ + @Override + @Cacheable(cacheNames = CacheNames.SYS_NICKNAME, key = "#userId") + public String selectNicknameById(Long userId) { + SysUser sysUser = baseMapper.selectOne(new LambdaQueryWrapper() + .select(SysUser::getNickName).eq(SysUser::getUserId, userId)); + return ObjectUtils.notNullGetter(sysUser, SysUser::getNickName); + } + + /** + * 通过用户ID查询用户账户 + * + * @param userIds 用户ID 多个用逗号隔开 + * @return 用户账户 + */ + @Override + public String selectNicknameByIds(String userIds) { + List list = new ArrayList<>(); + for (Long id : StringUtils.splitTo(userIds, Convert::toLong)) { + String nickname = SpringUtils.getAopProxy(this).selectNicknameById(id); + if (StringUtils.isNotBlank(nickname)) { + list.add(nickname); + } + } + return String.join(StringUtils.SEPARATOR, list); + } + + /** + * 通过用户ID查询用户手机号 + * + * @param userId 用户id + * @return 用户手机号 + */ + @Override + public String selectPhonenumberById(Long userId) { + SysUser sysUser = baseMapper.selectOne(new LambdaQueryWrapper() + .select(SysUser::getPhonenumber).eq(SysUser::getUserId, userId)); + return ObjectUtils.notNullGetter(sysUser, SysUser::getPhonenumber); + } + + /** + * 通过用户ID查询用户邮箱 + * + * @param userId 用户id + * @return 用户邮箱 + */ + @Override + public String selectEmailById(Long userId) { + SysUser sysUser = baseMapper.selectOne(new LambdaQueryWrapper() + .select(SysUser::getEmail).eq(SysUser::getUserId, userId)); + return ObjectUtils.notNullGetter(sysUser, SysUser::getEmail); + } + + /** + * 通过用户ID查询用户列表 + * + * @param userIds 用户ids + * @return 用户列表 + */ + @Override + public List selectListByIds(List userIds) { + if (CollUtil.isEmpty(userIds)) { + return List.of(); + } + List list = baseMapper.selectVoList(new LambdaQueryWrapper() + .select(SysUser::getUserId, SysUser::getDeptId, SysUser::getUserName, + SysUser::getNickName, SysUser::getUserType, SysUser::getEmail, + SysUser::getPhonenumber, SysUser::getSex, SysUser::getStatus, + SysUser::getCreateTime) + .eq(SysUser::getStatus, SystemConstants.NORMAL) + .in(SysUser::getUserId, userIds)); + return BeanUtil.copyToList(list, UserDTO.class); + } + + /** + * 通过角色ID查询用户ID + * + * @param roleIds 角色ids + * @return 用户ids + */ + @Override + public List selectUserIdsByRoleIds(List roleIds) { + if (CollUtil.isEmpty(roleIds)) { + return List.of(); + } + List userRoles = userRoleMapper.selectList( + new LambdaQueryWrapper().in(SysUserRole::getRoleId, roleIds)); + return StreamUtils.toList(userRoles, SysUserRole::getUserId); + } + + /** + * 通过角色ID查询用户 + * + * @param roleIds 角色ids + * @return 用户 + */ + @Override + public List selectUsersByRoleIds(List roleIds) { + if (CollUtil.isEmpty(roleIds)) { + return List.of(); + } + + // 通过角色ID获取用户角色信息 + List userRoles = userRoleMapper.selectList( + new LambdaQueryWrapper().in(SysUserRole::getRoleId, roleIds)); + + // 获取用户ID列表 + Set userIds = StreamUtils.toSet(userRoles, SysUserRole::getUserId); + + return this.selectListByIds(new ArrayList<>(userIds)); + } + + /** + * 通过部门ID查询用户 + * + * @param deptIds 部门ids + * @return 用户 + */ + @Override + public List selectUsersByDeptIds(List deptIds) { + if (CollUtil.isEmpty(deptIds)) { + return List.of(); + } + List list = baseMapper.selectVoList(new LambdaQueryWrapper() + .select(SysUser::getUserId, SysUser::getUserName, SysUser::getNickName, SysUser::getEmail, SysUser::getPhonenumber) + .eq(SysUser::getStatus, SystemConstants.NORMAL) + .in(SysUser::getDeptId, deptIds)); + return BeanUtil.copyToList(list, UserDTO.class); + } + + /** + * 通过岗位ID查询用户 + * + * @param postIds 岗位ids + * @return 用户 + */ + @Override + public List selectUsersByPostIds(List postIds) { + if (CollUtil.isEmpty(postIds)) { + return List.of(); + } + + // 通过岗位ID获取用户岗位信息 + List userPosts = userPostMapper.selectList( + new LambdaQueryWrapper().in(SysUserPost::getPostId, postIds)); + + // 获取用户ID列表 + Set userIds = StreamUtils.toSet(userPosts, SysUserPost::getUserId); + + return this.selectListByIds(new ArrayList<>(userIds)); + } + + /** + * 根据用户 ID 列表查询用户名称映射关系 + * + * @param userIds 用户 ID 列表 + * @return Map,其中 key 为用户 ID,value 为对应的用户名称 + */ + @Override + public Map selectUserNamesByIds(List userIds) { + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyMap(); + } + return baseMapper.selectList( + new LambdaQueryWrapper() + .select(SysUser::getUserId, SysUser::getNickName) + .in(SysUser::getUserId, userIds) + ).stream() + .collect(Collectors.toMap(SysUser::getUserId, SysUser::getNickName)); + } + + /** + * 根据角色 ID 列表查询角色名称映射关系 + * + * @param roleIds 角色 ID 列表 + * @return Map,其中 key 为角色 ID,value 为对应的角色名称 + */ + @Override + public Map selectRoleNamesByIds(List roleIds) { + if (CollUtil.isEmpty(roleIds)) { + return Collections.emptyMap(); + } + return roleMapper.selectList( + new LambdaQueryWrapper() + .select(SysRole::getRoleId, SysRole::getRoleName) + .in(SysRole::getRoleId, roleIds) + ).stream() + .collect(Collectors.toMap(SysRole::getRoleId, SysRole::getRoleName)); + } + + /** + * 根据部门 ID 列表查询部门名称映射关系 + * + * @param deptIds 部门 ID 列表 + * @return Map,其中 key 为部门 ID,value 为对应的部门名称 + */ + @Override + public Map selectDeptNamesByIds(List deptIds) { + if (CollUtil.isEmpty(deptIds)) { + return Collections.emptyMap(); + } + return deptMapper.selectList( + new LambdaQueryWrapper() + .select(SysDept::getDeptId, SysDept::getDeptName) + .in(SysDept::getDeptId, deptIds) + ).stream() + .collect(Collectors.toMap(SysDept::getDeptId, SysDept::getDeptName)); + } + + /** + * 根据岗位 ID 列表查询岗位名称映射关系 + * + * @param postIds 岗位 ID 列表 + * @return Map,其中 key 为岗位 ID,value 为对应的岗位名称 + */ + @Override + public Map selectPostNamesByIds(List postIds) { + if (CollUtil.isEmpty(postIds)) { + return Collections.emptyMap(); + } + return postMapper.selectList( + new LambdaQueryWrapper() + .select(SysPost::getPostId, SysPost::getPostName) + .in(SysPost::getPostId, postIds) + ).stream() + .collect(Collectors.toMap(SysPost::getPostId, SysPost::getPostName)); + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/package-info.md b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/package-info.md new file mode 100644 index 0000000..c938b1e --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/package-info.md @@ -0,0 +1,3 @@ +java包使用 `.` 分割 resource 目录使用 `/` 分割 +
+此文件目的 防止文件夹粘连找不到 `xml` 文件 \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysClientMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysClientMapper.xml new file mode 100644 index 0000000..5016995 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysClientMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysConfigMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysConfigMapper.xml new file mode 100644 index 0000000..e542a10 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysConfigMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml new file mode 100644 index 0000000..b9326aa --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDictDataMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDictDataMapper.xml new file mode 100644 index 0000000..6bcce51 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDictDataMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDictTypeMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDictTypeMapper.xml new file mode 100644 index 0000000..6975da4 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysDictTypeMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysLogininforMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysLogininforMapper.xml new file mode 100644 index 0000000..c64b551 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysLogininforMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysMenuMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysMenuMapper.xml new file mode 100644 index 0000000..ff654ca --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysMenuMapper.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysNoticeMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysNoticeMapper.xml new file mode 100644 index 0000000..43f494d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysNoticeMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml new file mode 100644 index 0000000..5ef14ee --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOssConfigMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOssConfigMapper.xml new file mode 100644 index 0000000..38b0e6a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOssConfigMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOssMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOssMapper.xml new file mode 100644 index 0000000..d9b25bd --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysOssMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysPostMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysPostMapper.xml new file mode 100644 index 0000000..3c79918 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysPostMapper.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleDeptMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleDeptMapper.xml new file mode 100644 index 0000000..1705bb2 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleDeptMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleMapper.xml new file mode 100644 index 0000000..b0755ea --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + select distinct r.role_id, + r.role_name, + r.role_key, + r.role_sort, + r.data_scope, + r.menu_check_strictly, + r.dept_check_strictly, + r.status, + r.del_flag, + r.create_time, + r.remark + from sys_role r + left join sys_user_role sur on sur.role_id = r.role_id + left join sys_user u on u.user_id = sur.user_id + left join sys_dept d on u.dept_id = d.dept_id + + + + + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleMenuMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleMenuMapper.xml new file mode 100644 index 0000000..f01dc5e --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysRoleMenuMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysSocialMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysSocialMapper.xml new file mode 100644 index 0000000..ec87c3a --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysSocialMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml new file mode 100644 index 0000000..a602e0d --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserPostMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserPostMapper.xml new file mode 100644 index 0000000..e9f2496 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserPostMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserRoleMapper.xml b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserRoleMapper.xml new file mode 100644 index 0000000..af8c151 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserRoleMapper.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/ruoyi-modules/ruoyi-workflow/pom.xml b/ruoyi-modules/ruoyi-workflow/pom.xml new file mode 100644 index 0000000..951ffb7 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/pom.xml @@ -0,0 +1,80 @@ + + + + org.dromara + ruoyi-modules + ${revision} + ../pom.xml + + 4.0.0 + jar + ruoyi-workflow + + + 工作流模块 + + + + + + org.dromara + ruoyi-common-sse + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-mail + + + + org.dromara + ruoyi-common-sms + + + + org.dromara + ruoyi-common-mybatis + + + org.dromara + ruoyi-common-web + + + org.dromara + ruoyi-common-log + + + org.dromara + ruoyi-common-idempotent + + + org.dromara + ruoyi-common-excel + + + org.dromara + ruoyi-common-translation + + + org.dromara + ruoyi-common-security + + + org.dromara.warm + warm-flow-mybatis-plus-sb3-starter + + + org.dromara.warm + warm-flow-plugin-ui-sb-web + + + + + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/ConditionalOnEnable.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/ConditionalOnEnable.java new file mode 100644 index 0000000..6440c29 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/ConditionalOnEnable.java @@ -0,0 +1,29 @@ +package org.dromara.workflow.common; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 自定义条件注解,用于基于配置启用或禁用特定功能 + *

+ * 该注解只会在配置文件中 `warm-flow.enabled=true` 时,标注了此注解的类或方法才会被 Spring 容器加载 + *

+ * 示例配置: + *

+ * warm-flow:
+ *   enabled: true  # 设置为 true 时,启用工作流功能
+ * 
+ *

+ * 使用此注解时,可以动态控制工作流功能是否启用,而不需要修改代码逻辑 + * + * @author pengles + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +@ConditionalOnProperty(value = "warm-flow.enabled", havingValue = "true") +public @interface ConditionalOnEnable { +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/constant/FlowConstant.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/constant/FlowConstant.java new file mode 100644 index 0000000..610d345 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/constant/FlowConstant.java @@ -0,0 +1,81 @@ +package org.dromara.workflow.common.constant; + + +/** + * 工作流常量 + * + * @author may + */ +public interface FlowConstant { + + /** + * 流程发起人 + */ + String INITIATOR = "initiator"; + + /** + * 业务id + */ + String BUSINESS_ID = "businessId"; + + /** + * 委托 + */ + String DELEGATE_TASK = "delegateTask"; + + /** + * 转办 + */ + String TRANSFER_TASK = "transferTask"; + + /** + * 加签 + */ + String ADD_SIGNATURE = "addSignature"; + + /** + * 减签 + */ + String REDUCTION_SIGNATURE = "reductionSignature"; + + /** + * 流程分类Id转名称 + */ + String CATEGORY_ID_TO_NAME = "category_id_to_name"; + + /** + * 流程分类名称 + */ + String FLOW_CATEGORY_NAME = "flow_category_name#30d"; + + /** + * 默认OA申请分类id + */ + Long FLOW_CATEGORY_ID = 100L; + + /** + * 是否为申请人提交常量 + */ + String SUBMIT = "submit"; + + /** + * 抄送常量 + */ + String FLOW_COPY_LIST = "flowCopyList"; + + /** + * 消息类型常量 + */ + String MESSAGE_TYPE = "messageType"; + + /** + * 消息通知常量 + */ + String MESSAGE_NOTICE = "messageNotice"; + + /** + * 任务状态 + */ + String WF_TASK_STATUS = "wf_task_status"; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/ButtonPermissionEnum.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/ButtonPermissionEnum.java new file mode 100644 index 0000000..598cd05 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/ButtonPermissionEnum.java @@ -0,0 +1,65 @@ +package org.dromara.workflow.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 按钮权限枚举 + * + * @author AprilWind + */ +@Getter +@AllArgsConstructor +public enum ButtonPermissionEnum implements NodeExtEnum { + + /** + * 是否弹窗选人 + */ + POP("是否弹窗选人", "pop", false), + + /** + * 是否能委托 + */ + TRUST("是否能委托", "trust", false), + + /** + * 是否能转办 + */ + TRANSFER("是否能转办", "transfer", false), + + /** + * 是否能抄送 + */ + COPY("是否能抄送", "copy", false), + + /** + * 是否显示退回 + */ + BACK("是否显示退回", "back", true), + + /** + * 是否能加签 + */ + ADD_SIGN("是否能加签", "addSign", false), + + /** + * 是否能减签 + */ + SUB_SIGN("是否能减签", "subSign", false), + + /** + * 是否能终止 + */ + TERMINATION("是否能终止", "termination", true), + + /** + * 是否能上传附件 + */ + FILE("是否能上传附件", "file", true); + + private final String label; + private final String value; + private final boolean selected; + +} + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/MessageTypeEnum.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/MessageTypeEnum.java new file mode 100644 index 0000000..6d59f81 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/MessageTypeEnum.java @@ -0,0 +1,51 @@ +package org.dromara.workflow.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 消息类型枚举 + * + * @author may + */ +@Getter +@AllArgsConstructor +public enum MessageTypeEnum { + + /** + * 站内信 + */ + SYSTEM_MESSAGE("1", "站内信"), + + /** + * 邮箱 + */ + EMAIL_MESSAGE("2", "邮箱"), + + /** + * 短信 + */ + SMS_MESSAGE("3", "短信"); + + private static final Map MESSAGE_TYPE_ENUM_MAP = Arrays.stream(values()) + .collect(Collectors.toConcurrentMap(MessageTypeEnum::getCode, Function.identity())); + private final String code; + private final String desc; + + /** + * 根据消息类型 code 获取 MessageTypeEnum + * + * @param code 消息类型code + * @return MessageTypeEnum + */ + public static MessageTypeEnum getByCode(String code) { + return MESSAGE_TYPE_ENUM_MAP.getOrDefault(code, null); + } + +} + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/NodeExtEnum.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/NodeExtEnum.java new file mode 100644 index 0000000..9926a8e --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/NodeExtEnum.java @@ -0,0 +1,32 @@ +package org.dromara.workflow.common.enums; + +/** + * 节点扩展属性枚举 + * + * @author AprilWind + */ +public interface NodeExtEnum { + + /** + * 选项label + * + * @return 选项label + */ + String getLabel(); + + /** + * 选项值 + * + * @return 选项值 + */ + String getValue(); + + /** + * 是否默认选中 + * + * @return 是否默认选中 + */ + boolean isSelected(); + +} + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskAssigneeEnum.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskAssigneeEnum.java new file mode 100644 index 0000000..438c8f3 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskAssigneeEnum.java @@ -0,0 +1,109 @@ +package org.dromara.workflow.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.dromara.common.core.exception.ServiceException; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 任务分配人枚举 + * + * @author AprilWind + */ +@Getter +@AllArgsConstructor +public enum TaskAssigneeEnum { + + /** + * 用户 + */ + USER("用户", ""), + + /** + * 角色 + */ + ROLE("角色", "role:"), + + /** + * 部门 + */ + DEPT("部门", "dept:"), + + /** + * 岗位 + */ + POST("岗位", "post:"); + + private final String desc; + private final String code; + + /** + * 根据描述获取对应的枚举类型 + *

+ * 通过传入描述,查找并返回匹配的枚举项。如果未找到匹配项,会抛出 {@link ServiceException}。 + *

+ * + * @param desc 描述,用于匹配对应的枚举项 + * @return TaskAssigneeEnum 返回对应的枚举类型 + * @throws ServiceException 如果未找到匹配的枚举项 + */ + public static TaskAssigneeEnum fromDesc(String desc) { + for (TaskAssigneeEnum type : values()) { + if (type.getDesc().equals(desc)) { + return type; + } + } + throw new ServiceException("未知的办理人类型: " + desc); + } + + /** + * 根据代码获取对应的枚举类型 + *

+ * 通过传入代码,查找并返回匹配的枚举项。如果未找到匹配项,会抛出 {@link ServiceException}。 + *

+ * + * @param code 代码,用于匹配对应的枚举项 + * @return TaskAssigneeEnum 返回对应的枚举类型 + * @throws IllegalArgumentException 如果未找到匹配的枚举项 + */ + public static TaskAssigneeEnum fromCode(String code) { + for (TaskAssigneeEnum type : values()) { + if (type.getCode().equals(code)) { + return type; + } + } + throw new ServiceException("未知的办理人类型代码: " + code); + } + + /** + * 获取所有办理人类型的描述列表 + *

+ * 获取当前枚举类所有项的描述字段列表,通常用于展示选择项。 + *

+ * + * @return List 返回所有办理人类型的描述列表 + */ + public static List getAssigneeTypeList() { + return Arrays.stream(values()) + .map(TaskAssigneeEnum::getDesc) + .collect(Collectors.toList()); + } + + /** + * 获取所有办理人类型的代码列表 + *

+ * 获取当前枚举类所有项的代码字段列表,通常用于程序内部逻辑的判断。 + *

+ * + * @return List 返回所有办理人类型的代码列表 + */ + public static List getAssigneeCodeList() { + return Arrays.stream(values()) + .map(TaskAssigneeEnum::getCode) + .collect(Collectors.toList()); + } +} + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskAssigneeType.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskAssigneeType.java new file mode 100644 index 0000000..eed1b91 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskAssigneeType.java @@ -0,0 +1,49 @@ +package org.dromara.workflow.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 人员类型 + * + * @author AprilWind + */ +@Getter +@AllArgsConstructor +public enum TaskAssigneeType { + + /** + * 待办任务的审批人权限 + *

该权限表示用户是待办任务的审批人,负责审核任务的执行情况。

+ */ + APPROVER("1", "待办任务的审批人权限"), + + /** + * 待办任务的转办人权限 + *

该权限表示用户是待办任务的转办人,负责将任务分配给其他人员。

+ */ + TRANSFER("2", "待办任务的转办人权限"), + + /** + * 待办任务的委托人权限 + *

该权限表示用户是待办任务的委托人,能够委托其他人代为处理任务。

+ */ + DELEGATE("3", "待办任务的委托人权限"), + + /** + * 待办任务的抄送人权限 + *

该权限表示用户是待办任务的抄送人,仅接收任务信息的通知,不参与任务的审批或处理。

+ */ + COPY("4", "待办任务的抄送人权限"); + + /** + * 类型 + */ + private final String code; + + /** + * 描述 + */ + private final String description; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskStatusEnum.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskStatusEnum.java new file mode 100644 index 0000000..ebc93da --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/common/enums/TaskStatusEnum.java @@ -0,0 +1,102 @@ +package org.dromara.workflow.common.enums; + +import cn.hutool.core.util.StrUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 任务状态枚举 + * + * @author may + */ +@Getter +@AllArgsConstructor +public enum TaskStatusEnum { + + /** + * 撤销 + */ + CANCEL("cancel", "撤销"), + + /** + * 通过 + */ + PASS("pass", "通过"), + + /** + * 待审核 + */ + WAITING("waiting", "待审核"), + + /** + * 作废 + */ + INVALID("invalid", "作废"), + + /** + * 退回 + */ + BACK("back", "退回"), + + /** + * 终止 + */ + TERMINATION("termination", "终止"), + + /** + * 转办 + */ + TRANSFER("transfer", "转办"), + + /** + * 委托 + */ + DEPUTE("depute", "委托"), + + /** + * 抄送 + */ + COPY("copy", "抄送"), + + /** + * 加签 + */ + SIGN("sign", "加签"), + + /** + * 减签 + */ + SIGN_OFF("sign_off", "减签"), + + /** + * 超时 + */ + TIMEOUT("timeout", "超时"); + + private static final Map STATUS_DESC_MAP = Arrays.stream(values()) + .collect(Collectors.toConcurrentMap(TaskStatusEnum::getStatus, TaskStatusEnum::getDesc)); + /** + * 状态 + */ + private final String status; + /** + * 描述 + */ + private final String desc; + + /** + * 任务业务状态 + * + * @param status 状态 + */ + public static String findByStatus(String status) { + // 从缓存中直接获取描述 + return STATUS_DESC_MAP.getOrDefault(status, StrUtil.EMPTY); + } + +} + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/config/WarmFlowConfig.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/config/WarmFlowConfig.java new file mode 100644 index 0000000..08f1808 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/config/WarmFlowConfig.java @@ -0,0 +1,16 @@ +package org.dromara.workflow.config; + +import org.dromara.workflow.common.ConditionalOnEnable; +import org.springframework.context.annotation.Configuration; + +/** + * warmFlow配置 + * + * @author may + */ +@ConditionalOnEnable +@Configuration +public class WarmFlowConfig { + +} + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwCategoryController.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwCategoryController.java new file mode 100644 index 0000000..3018b08 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwCategoryController.java @@ -0,0 +1,136 @@ +package org.dromara.workflow.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.lang.tree.Tree; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.web.core.BaseController; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.constant.FlowConstant; +import org.dromara.workflow.domain.bo.FlowCategoryBo; +import org.dromara.workflow.domain.vo.FlowCategoryVo; +import org.dromara.workflow.service.IFlwCategoryService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 流程分类 + * + * @author may + */ +@ConditionalOnEnable +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/workflow/category") +public class FlwCategoryController extends BaseController { + + private final IFlwCategoryService flwCategoryService; + + /** + * 查询流程分类列表 + */ + @SaCheckPermission("workflow:category:list") + @GetMapping("/list") + public R> list(FlowCategoryBo bo) { + List list = flwCategoryService.queryList(bo); + return R.ok(list); + } + + /** + * 导出流程分类列表 + */ + @SaCheckPermission("workflow:category:export") + @Log(title = "流程分类", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(FlowCategoryBo bo, HttpServletResponse response) { + List list = flwCategoryService.queryList(bo); + ExcelUtil.exportExcel(list, "流程分类", FlowCategoryVo.class, response); + } + + /** + * 获取流程分类详细信息 + * + * @param categoryId 主键 + */ + @SaCheckPermission("workflow:category:query") + @GetMapping("/{categoryId}") + public R getInfo(@NotNull(message = "主键不能为空") @PathVariable Long categoryId) { + flwCategoryService.checkCategoryDataScope(categoryId); + return R.ok(flwCategoryService.queryById(categoryId)); + } + + /** + * 新增流程分类 + */ + @SaCheckPermission("workflow:category:add") + @Log(title = "流程分类", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody FlowCategoryBo category) { + if (!flwCategoryService.checkCategoryNameUnique(category)) { + return R.fail("新增流程分类'" + category.getCategoryName() + "'失败,流程分类名称已存在"); + } + return toAjax(flwCategoryService.insertByBo(category)); + } + + /** + * 修改流程分类 + */ + @SaCheckPermission("workflow:category:edit") + @Log(title = "流程分类", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody FlowCategoryBo category) { + Long categoryId = category.getCategoryId(); + flwCategoryService.checkCategoryDataScope(categoryId); + if (!flwCategoryService.checkCategoryNameUnique(category)) { + return R.fail("修改流程分类'" + category.getCategoryName() + "'失败,流程分类名称已存在"); + } else if (category.getParentId().equals(categoryId)) { + return R.fail("修改流程分类'" + category.getCategoryName() + "'失败,上级流程分类不能是自己"); + } + return toAjax(flwCategoryService.updateByBo(category)); + } + + /** + * 删除流程分类 + * + * @param categoryId 主键 + */ + @SaCheckPermission("workflow:category:remove") + @Log(title = "流程分类", businessType = BusinessType.DELETE) + @DeleteMapping("/{categoryId}") + public R remove(@PathVariable Long categoryId) { + if (FlowConstant.FLOW_CATEGORY_ID.equals(categoryId)) { + return R.warn("默认流程分类,不允许删除"); + } + if (flwCategoryService.hasChildByCategoryId(categoryId)) { + return R.warn("存在下级流程分类,不允许删除"); + } + if (flwCategoryService.checkCategoryExistDefinition(categoryId)) { + return R.warn("流程分类存在流程定义,不允许删除"); + } + return toAjax(flwCategoryService.deleteWithValidById(categoryId)); + } + + /** + * 获取流程分类树列表 + * + * @param categoryBo 流程分类 + */ + @GetMapping("/categoryTree") + public R>> categoryTree(FlowCategoryBo categoryBo) { + return R.ok(flwCategoryService.selectCategoryTreeList(categoryBo)); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwDefinitionController.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwDefinitionController.java new file mode 100644 index 0000000..10d9de8 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwDefinitionController.java @@ -0,0 +1,194 @@ +package org.dromara.workflow.controller; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.warm.flow.core.entity.Definition; +import org.dromara.warm.flow.core.service.DefService; +import org.dromara.warm.flow.orm.entity.FlowDefinition; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.domain.vo.FlowDefinitionVo; +import org.dromara.workflow.service.IFlwDefinitionService; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +/** + * 流程定义管理 控制层 + * + * @author may + */ +@ConditionalOnEnable +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/workflow/definition") +public class FlwDefinitionController extends BaseController { + + private final DefService defService; + private final IFlwDefinitionService flwDefinitionService; + + /** + * 查询流程定义列表 + * + * @param flowDefinition 参数 + * @param pageQuery 分页 + */ + @GetMapping("/list") + public TableDataInfo list(FlowDefinition flowDefinition, PageQuery pageQuery) { + return flwDefinitionService.queryList(flowDefinition, pageQuery); + } + + /** + * 查询未发布的流程定义列表 + * + * @param flowDefinition 参数 + * @param pageQuery 分页 + */ + @GetMapping("/unPublishList") + public TableDataInfo unPublishList(FlowDefinition flowDefinition, PageQuery pageQuery) { + return flwDefinitionService.unPublishList(flowDefinition, pageQuery); + } + + /** + * 获取流程定义详细信息 + * + * @param id 流程定义id + */ + @GetMapping(value = "/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(defService.getById(id)); + } + + /** + * 新增流程定义 + * + * @param flowDefinition 参数 + */ + @Log(title = "流程定义", businessType = BusinessType.INSERT) + @PostMapping + @RepeatSubmit() + @Transactional(rollbackFor = Exception.class) + public R add(@RequestBody FlowDefinition flowDefinition) { + return R.ok(defService.checkAndSave(flowDefinition)); + } + + /** + * 修改流程定义 + * + * @param flowDefinition 参数 + */ + @Log(title = "流程定义", businessType = BusinessType.UPDATE) + @PutMapping + @RepeatSubmit() + @Transactional(rollbackFor = Exception.class) + public R edit(@RequestBody FlowDefinition flowDefinition) { + return R.ok(defService.updateById(flowDefinition)); + } + + /** + * 发布流程定义 + * + * @param id 流程定义id + */ + @Log(title = "流程定义", businessType = BusinessType.INSERT) + @PutMapping("/publish/{id}") + @RepeatSubmit() + public R publish(@PathVariable Long id) { + return R.ok(flwDefinitionService.publish(id)); + } + + /** + * 取消发布流程定义 + * + * @param id 流程定义id + */ + @Log(title = "流程定义", businessType = BusinessType.INSERT) + @PutMapping("/unPublish/{id}") + @RepeatSubmit() + @Transactional(rollbackFor = Exception.class) + public R unPublish(@PathVariable Long id) { + return R.ok(defService.unPublish(id)); + } + + /** + * 删除流程定义 + */ + @Log(title = "流程定义", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@PathVariable List ids) { + return toAjax(flwDefinitionService.removeDef(ids)); + } + + /** + * 复制流程定义 + * + * @param id 流程定义id + */ + @Log(title = "流程定义", businessType = BusinessType.INSERT) + @PostMapping("/copy/{id}") + @RepeatSubmit() + @Transactional(rollbackFor = Exception.class) + public R copy(@PathVariable Long id) { + return R.ok(defService.copyDef(id)); + } + + /** + * 导入流程定义 + * + * @param file 文件 + * @param category 分类 + */ + @Log(title = "流程定义", businessType = BusinessType.IMPORT) + @PostMapping("/importDef") + public R importDef(MultipartFile file, String category) { + return R.ok(flwDefinitionService.importJson(file, category)); + } + + /** + * 导出流程定义 + * + * @param id 流程定义id + * @param response 响应 + * @throws IOException 异常 + */ + @Log(title = "流程定义", businessType = BusinessType.EXPORT) + @PostMapping("/exportDef/{id}") + public void exportDef(@PathVariable Long id, HttpServletResponse response) throws IOException { + flwDefinitionService.exportDef(id, response); + } + + /** + * 获取流程定义JSON字符串 + * + * @param id 流程定义id + */ + @GetMapping("/xmlString/{id}") + public R xmlString(@PathVariable Long id) { + return R.ok("操作成功", defService.exportJson(id)); + } + + /** + * 激活/挂起流程定义 + * + * @param id 流程定义id + * @param active 激活/挂起 + */ + @RepeatSubmit() + @PutMapping("/active/{id}") + @Transactional(rollbackFor = Exception.class) + public R active(@PathVariable Long id, @RequestParam boolean active) { + return R.ok(active ? defService.active(id) : defService.unActive(id)); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwInstanceController.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwInstanceController.java new file mode 100644 index 0000000..e7ea5e4 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwInstanceController.java @@ -0,0 +1,157 @@ +package org.dromara.workflow.controller; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.warm.flow.core.service.InsService; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.domain.bo.FlowCancelBo; +import org.dromara.workflow.domain.bo.FlowInstanceBo; +import org.dromara.workflow.domain.bo.FlowInvalidBo; +import org.dromara.workflow.domain.vo.FlowInstanceVo; +import org.dromara.workflow.service.IFlwInstanceService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 流程实例管理 控制层 + * + * @author may + */ +@ConditionalOnEnable +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/workflow/instance") +public class FlwInstanceController extends BaseController { + + private final InsService insService; + private final IFlwInstanceService flwInstanceService; + + /** + * 查询正在运行的流程实例列表 + * + * @param flowInstanceBo 流程实例 + * @param pageQuery 分页 + */ + @GetMapping("/pageByRunning") + public TableDataInfo selectRunningInstanceList(FlowInstanceBo flowInstanceBo, PageQuery pageQuery) { + return flwInstanceService.selectRunningInstanceList(flowInstanceBo, pageQuery); + } + + /** + * 查询已结束的流程实例列表 + * + * @param flowInstanceBo 流程实例 + * @param pageQuery 分页 + */ + @GetMapping("/pageByFinish") + public TableDataInfo selectFinishInstanceList(FlowInstanceBo flowInstanceBo, PageQuery pageQuery) { + return flwInstanceService.selectFinishInstanceList(flowInstanceBo, pageQuery); + } + + /** + * 根据业务id查询流程实例详细信息 + * + * @param businessId 业务id + */ + @GetMapping("/getInfo/{businessId}") + public R getInfo(@PathVariable Long businessId) { + return R.ok(flwInstanceService.queryByBusinessId(businessId)); + } + + /** + * 按照业务id删除流程实例 + * + * @param businessIds 业务id + */ + @DeleteMapping("/deleteByBusinessIds/{businessIds}") + public R deleteByBusinessIds(@PathVariable List businessIds) { + return toAjax(flwInstanceService.deleteByBusinessIds(businessIds)); + } + + /** + * 按照实例id删除流程实例 + * + * @param instanceIds 实例id + */ + @DeleteMapping("/deleteByInstanceIds/{instanceIds}") + public R deleteByInstanceIds(@PathVariable List instanceIds) { + return toAjax(flwInstanceService.deleteByInstanceIds(instanceIds)); + } + + /** + * 撤销流程 + * + * @param bo 参数 + */ + @RepeatSubmit() + @PutMapping("/cancelProcessApply") + public R cancelProcessApply(@RequestBody FlowCancelBo bo) { + return toAjax(flwInstanceService.cancelProcessApply(bo)); + } + + /** + * 激活/挂起流程实例 + * + * @param id 流程实例id + * @param active 激活/挂起 + */ + @RepeatSubmit() + @PutMapping("/active/{id}") + public R active(@PathVariable Long id, @RequestParam boolean active) { + return R.ok(active ? insService.active(id) : insService.unActive(id)); + } + + /** + * 获取当前登陆人发起的流程实例 + * + * @param flowInstanceBo 参数 + * @param pageQuery 分页 + */ + @GetMapping("/pageByCurrent") + public TableDataInfo selectCurrentInstanceList(FlowInstanceBo flowInstanceBo, PageQuery pageQuery) { + return flwInstanceService.selectCurrentInstanceList(flowInstanceBo, pageQuery); + } + + /** + * 获取流程图,流程记录 + * + * @param businessId 业务id + */ + @GetMapping("/flowHisTaskList/{businessId}") + public R> flowHisTaskList(@PathVariable String businessId) { + return R.ok(flwInstanceService.flowHisTaskList(businessId)); + } + + /** + * 获取流程变量 + * + * @param instanceId 流程实例id + */ + @GetMapping("/instanceVariable/{instanceId}") + public R> instanceVariable(@PathVariable Long instanceId) { + return R.ok(flwInstanceService.instanceVariable(instanceId)); + } + + /** + * 作废流程 + * + * @param bo 参数 + */ + @Log(title = "流程实例管理", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping("/invalid") + public R invalid(@Validated @RequestBody FlowInvalidBo bo) { + return R.ok(flwInstanceService.processInvalid(bo)); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwTaskController.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwTaskController.java new file mode 100644 index 0000000..5534b67 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/FlwTaskController.java @@ -0,0 +1,212 @@ +package org.dromara.workflow.controller; + +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.dto.StartProcessReturnDTO; +import org.dromara.common.core.domain.dto.UserDTO; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.warm.flow.core.entity.Node; +import org.dromara.warm.flow.orm.entity.FlowNode; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.domain.bo.*; +import org.dromara.workflow.domain.vo.FlowHisTaskVo; +import org.dromara.workflow.domain.vo.FlowTaskVo; +import org.dromara.workflow.service.IFlwTaskService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 任务管理 控制层 + * + * @author may + */ +@ConditionalOnEnable +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/workflow/task") +public class FlwTaskController extends BaseController { + + private final IFlwTaskService flwTaskService; + + /** + * 启动任务 + * + * @param startProcessBo 启动流程参数 + */ + @Log(title = "任务管理", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping("/startWorkFlow") + public R startWorkFlow(@Validated(AddGroup.class) @RequestBody StartProcessBo startProcessBo) { + StartProcessReturnDTO startProcessReturn = flwTaskService.startWorkFlow(startProcessBo); + return R.ok("提交成功", startProcessReturn); + } + + /** + * 办理任务 + * + * @param completeTaskBo 办理任务参数 + */ + @Log(title = "任务管理", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping("/completeTask") + public R completeTask(@Validated(AddGroup.class) @RequestBody CompleteTaskBo completeTaskBo) { + return toAjax(flwTaskService.completeTask(completeTaskBo)); + } + + /** + * 查询当前用户的待办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @GetMapping("/pageByTaskWait") + public TableDataInfo pageByTaskWait(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + return flwTaskService.pageByTaskWait(flowTaskBo, pageQuery); + } + + /** + * 查询当前用户的已办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + + @GetMapping("/pageByTaskFinish") + public TableDataInfo pageByTaskFinish(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + return flwTaskService.pageByTaskFinish(flowTaskBo, pageQuery); + } + + /** + * 查询待办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @GetMapping("/pageByAllTaskWait") + public TableDataInfo pageByAllTaskWait(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + return flwTaskService.pageByAllTaskWait(flowTaskBo, pageQuery); + } + + /** + * 查询已办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @GetMapping("/pageByAllTaskFinish") + public TableDataInfo pageByAllTaskFinish(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + return flwTaskService.pageByAllTaskFinish(flowTaskBo, pageQuery); + } + + /** + * 查询当前用户的抄送 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @GetMapping("/pageByTaskCopy") + public TableDataInfo pageByTaskCopy(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + return flwTaskService.pageByTaskCopy(flowTaskBo, pageQuery); + } + + /** + * 根据taskId查询代表任务 + * + * @param taskId 任务id + */ + @GetMapping("/getTask/{taskId}") + public R getTask(@PathVariable Long taskId) { + return R.ok(flwTaskService.selectById(taskId)); + } + + /** + * 获取下一节点信息 + * + * @param bo 参数 + */ + @PostMapping("/getNextNodeList") + public R> getNextNodeList(@RequestBody FlowNextNodeBo bo) { + return R.ok(flwTaskService.getNextNodeList(bo)); + } + + /** + * 终止任务 + * + * @param bo 参数 + */ + @Log(title = "任务管理", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping("/terminationTask") + public R terminationTask(@RequestBody FlowTerminationBo bo) { + return R.ok(flwTaskService.terminationTask(bo)); + } + + /** + * 任务操作 + * + * @param bo 参数 + * @param taskOperation 操作类型,委派 delegateTask、转办 transferTask、加签 addSignature、减签 reductionSignature + */ + @Log(title = "任务管理", businessType = BusinessType.UPDATE) + @RepeatSubmit + @PostMapping("/taskOperation/{taskOperation}") + public R taskOperation(@Validated @RequestBody TaskOperationBo bo, @PathVariable String taskOperation) { + return toAjax(flwTaskService.taskOperation(bo, taskOperation)); + } + + /** + * 修改任务办理人 + * + * @param taskIdList 任务id + * @param userId 办理人id + */ + @Log(title = "任务管理", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping("/updateAssignee/{userId}") + public R updateAssignee(@RequestBody List taskIdList, @PathVariable String userId) { + return toAjax(flwTaskService.updateAssignee(taskIdList, userId)); + } + + /** + * 驳回审批 + * + * @param bo 参数 + */ + @Log(title = "任务管理", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping("/backProcess") + public R backProcess(@Validated({AddGroup.class}) @RequestBody BackProcessBo bo) { + return toAjax(flwTaskService.backProcess(bo)); + } + + /** + * 获取可驳回的前置节点 + * + * @param definitionId 流程定义id + * @param nowNodeCode 当前节点 + */ + @GetMapping("/getBackTaskNode/{definitionId}/{nowNodeCode}") + public R> getBackTaskNode(@PathVariable Long definitionId, @PathVariable String nowNodeCode) { + return R.ok(flwTaskService.getBackTaskNode(definitionId, nowNodeCode)); + } + + /** + * 获取当前任务的所有办理人 + * + * @param taskId 任务id + */ + @GetMapping("/currentTaskAllUser/{taskId}") + public R> currentTaskAllUser(@PathVariable Long taskId) { + return R.ok(flwTaskService.currentTaskAllUser(taskId)); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/TestLeaveController.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/TestLeaveController.java new file mode 100644 index 0000000..98825d9 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/controller/TestLeaveController.java @@ -0,0 +1,108 @@ +package org.dromara.workflow.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.excel.utils.ExcelUtil; +import org.dromara.common.idempotent.annotation.RepeatSubmit; +import org.dromara.common.log.annotation.Log; +import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.domain.bo.TestLeaveBo; +import org.dromara.workflow.domain.vo.TestLeaveVo; +import org.dromara.workflow.service.ITestLeaveService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 请假 + * + * @author may + * @date 2023-07-21 + */ +@ConditionalOnEnable +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/workflow/leave") +public class TestLeaveController extends BaseController { + + private final ITestLeaveService testLeaveService; + + /** + * 查询请假列表 + */ + @SaCheckPermission("workflow:leave:list") + @GetMapping("/list") + public TableDataInfo list(TestLeaveBo bo, PageQuery pageQuery) { + return testLeaveService.queryPageList(bo, pageQuery); + } + + /** + * 导出请假列表 + */ + @SaCheckPermission("workflow:leave:export") + @Log(title = "请假", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(TestLeaveBo bo, HttpServletResponse response) { + List list = testLeaveService.queryList(bo); + ExcelUtil.exportExcel(list, "请假", TestLeaveVo.class, response); + } + + /** + * 获取请假详细信息 + * + * @param id 主键 + */ + @SaCheckPermission("workflow:leave:query") + @GetMapping("/{id}") + public R getInfo(@NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(testLeaveService.queryById(id)); + } + + /** + * 新增请假 + */ + @SaCheckPermission("workflow:leave:add") + @Log(title = "请假", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody TestLeaveBo bo) { + return R.ok(testLeaveService.insertByBo(bo)); + } + + /** + * 修改请假 + */ + @SaCheckPermission("workflow:leave:edit") + @Log(title = "请假", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody TestLeaveBo bo) { + return R.ok(testLeaveService.updateByBo(bo)); + } + + /** + * 删除请假 + * + * @param ids 主键串 + */ + @SaCheckPermission("workflow:leave:remove") + @Log(title = "请假", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(testLeaveService.deleteWithValidByIds(List.of(ids))); + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/FlowCategory.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/FlowCategory.java new file mode 100644 index 0000000..5247efe --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/FlowCategory.java @@ -0,0 +1,67 @@ +package org.dromara.workflow.domain; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.List; + +/** + * 流程分类对象 wf_category + * + * @author may + * @date 2023-06-27 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("flow_category") +public class FlowCategory extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 流程分类ID + */ + @TableId(value = "category_id") + private Long categoryId; + + /** + * 父流程分类id + */ + private Long parentId; + + /** + * 祖级列表 + */ + private String ancestors; + + /** + * 流程分类名称 + */ + private String categoryName; + + /** + * 显示顺序 + */ + private Long orderNum; + + /** + * 删除标志(0代表存在 1代表删除) + */ + @TableLogic + private String delFlag; + + /** + * 子菜单 + */ + @TableField(exist = false) + private List children = new ArrayList<>(); + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/TestLeave.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/TestLeave.java new file mode 100644 index 0000000..7d42a9b --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/TestLeave.java @@ -0,0 +1,63 @@ +package org.dromara.workflow.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; +import java.util.Date; + +/** + * 请假对象 test_leave + * + * @author may + * @date 2023-07-21 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("test_leave") +public class TestLeave extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 请假类型 + */ + private String leaveType; + + /** + * 开始时间 + */ + private Date startDate; + + /** + * 结束时间 + */ + private Date endDate; + + /** + * 请假天数 + */ + private Integer leaveDays; + + /** + * 请假原因 + */ + private String remark; + + /** + * 状态 + */ + private String status; + + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/BackProcessBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/BackProcessBo.java new file mode 100644 index 0000000..a67a1f7 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/BackProcessBo.java @@ -0,0 +1,69 @@ +package org.dromara.workflow.domain.bo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.dromara.common.core.validate.AddGroup; + +import java.io.Serial; +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +/** + * 驳回参数请求 + * + * @author may + */ +@Data +public class BackProcessBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 任务ID + */ + @NotNull(message = "任务ID不能为空", groups = AddGroup.class) + private Long taskId; + + /** + * 附件id + */ + private String fileId; + + /** + * 消息类型 + */ + private List messageType; + + /** + * 驳回的节点id(目前未使用,直接驳回到申请人) + */ + private String nodeCode; + + /** + * 办理意见 + */ + private String message; + + /** + * 通知 + */ + private String notice; + + /** + * 流程变量 + */ + private Map variables; + + public Map getVariables() { + if (variables == null) { + return new HashMap<>(16); + } + variables.entrySet().removeIf(entry -> Objects.isNull(entry.getValue())); + return variables; + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/CompleteTaskBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/CompleteTaskBo.java new file mode 100644 index 0000000..360fc3b --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/CompleteTaskBo.java @@ -0,0 +1,80 @@ +package org.dromara.workflow.domain.bo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.dromara.common.core.validate.AddGroup; + +import java.io.Serial; +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 办理任务请求对象 + * + * @author may + */ +@Data +public class CompleteTaskBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 任务id + */ + @NotNull(message = "任务id不能为空", groups = {AddGroup.class}) + private Long taskId; + + /** + * 附件id + */ + private String fileId; + + /** + * 抄送人员 + */ + private List flowCopyList; + + /** + * 消息类型 + */ + private List messageType; + + /** + * 办理意见 + */ + private String message; + + /** + * 消息通知 + */ + private String notice; + + /** + * 流程变量 + */ + private Map variables; + + /** + * 弹窗选择的办理人 + */ + private Map assigneeMap; + + /** + * 扩展变量(此处为逗号分隔的ossId) + */ + private String ext; + + public Map getVariables() { + if (variables == null) { + variables = new HashMap<>(16); + return variables; + } + variables.entrySet().removeIf(entry -> Objects.isNull(entry.getValue())); + return variables; + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCancelBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCancelBo.java new file mode 100644 index 0000000..31742ea --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCancelBo.java @@ -0,0 +1,31 @@ +package org.dromara.workflow.domain.bo; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import org.dromara.common.core.validate.AddGroup; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 撤销任务请求对象 + * + * @author may + */ +@Data +public class FlowCancelBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 任务ID + */ + @NotBlank(message = "业务ID不能为空", groups = AddGroup.class) + private String businessId; + + /** + * 办理意见 + */ + private String message; +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCategoryBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCategoryBo.java new file mode 100644 index 0000000..514c660 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCategoryBo.java @@ -0,0 +1,46 @@ +package org.dromara.workflow.domain.bo; + +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.workflow.domain.FlowCategory; + +/** + * 流程分类业务对象 wf_category + * + * @author ruoyu + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = FlowCategory.class, reverseConvertGenerate = false) +public class FlowCategoryBo extends BaseEntity { + + /** + * 流程分类ID + */ + @NotNull(message = "流程分类ID不能为空", groups = {EditGroup.class}) + private Long categoryId; + + /** + * 父流程分类id + */ + @NotNull(message = "父流程分类id不能为空", groups = {AddGroup.class, EditGroup.class}) + private Long parentId; + + /** + * 流程分类名称 + */ + @NotBlank(message = "流程分类名称不能为空", groups = {AddGroup.class, EditGroup.class}) + private String categoryName; + + /** + * 显示顺序 + */ + private Long orderNum; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCopyBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCopyBo.java new file mode 100644 index 0000000..a45e521 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowCopyBo.java @@ -0,0 +1,30 @@ +package org.dromara.workflow.domain.bo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + + +/** + * 抄送 + * + * @author may + */ +@Data +public class FlowCopyBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户id + */ + private Long userId; + + /** + * 用户名称 + */ + private String userName; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowInstanceBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowInstanceBo.java new file mode 100644 index 0000000..fb1fe61 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowInstanceBo.java @@ -0,0 +1,55 @@ +package org.dromara.workflow.domain.bo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 流程实例请求对象 + * + * @author may + */ +@Data +public class FlowInstanceBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 流程定义名称 + */ + private String flowName; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 任务发起人 + */ + private String startUserId; + + /** + * 业务id + */ + private String businessId; + + /** + * 流程分类id + */ + private String category; + + /** + * 任务名称 + */ + private String nodeName; + + /** + * 申请人Ids + */ + private List createByIds; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowInvalidBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowInvalidBo.java new file mode 100644 index 0000000..297bd00 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowInvalidBo.java @@ -0,0 +1,31 @@ +package org.dromara.workflow.domain.bo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.dromara.common.core.validate.AddGroup; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 作废请求对象 + * + * @author may + */ +@Data +public class FlowInvalidBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 流程实例id + */ + @NotNull(message = "流程实例id为空", groups = AddGroup.class) + private Long id; + + /** + * 审批意见 + */ + private String comment; +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowNextNodeBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowNextNodeBo.java new file mode 100644 index 0000000..12f0653 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowNextNodeBo.java @@ -0,0 +1,38 @@ +package org.dromara.workflow.domain.bo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * 下一节点信息 + * + * @author may + */ +@Data +public class FlowNextNodeBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + /** + * 任务id + */ + private Long taskId; + + /** + * 流程变量 + */ + private Map variables; + + public Map getVariables() { + if (variables == null) { + return new HashMap<>(16); + } + variables.entrySet().removeIf(entry -> Objects.isNull(entry.getValue())); + return variables; + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowTaskBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowTaskBo.java new file mode 100644 index 0000000..64dd082 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowTaskBo.java @@ -0,0 +1,55 @@ +package org.dromara.workflow.domain.bo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 任务请求对象 + * + * @author may + */ +@Data +public class FlowTaskBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 任务名称 + */ + private String nodeName; + + /** + * 流程定义名称 + */ + private String flowName; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 流程分类id + */ + private String category; + + /** + * 流程实例id + */ + private Long instanceId; + + /** + * 权限列表 + */ + private List permissionList; + + /** + * 申请人Ids + */ + private List createByIds; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowTerminationBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowTerminationBo.java new file mode 100644 index 0000000..897fc21 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/FlowTerminationBo.java @@ -0,0 +1,31 @@ +package org.dromara.workflow.domain.bo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.dromara.common.core.validate.AddGroup; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 终止任务请求对象 + * + * @author may + */ +@Data +public class FlowTerminationBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 任务id + */ + @NotNull(message = "任务id为空", groups = AddGroup.class) + private Long taskId; + + /** + * 审批意见 + */ + private String comment; +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/StartProcessBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/StartProcessBo.java new file mode 100644 index 0000000..ea21a81 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/StartProcessBo.java @@ -0,0 +1,49 @@ +package org.dromara.workflow.domain.bo; + + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import org.dromara.common.core.validate.AddGroup; + +import java.io.Serial; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * 启动流程对象 + * + * @author may + */ +@Data +public class StartProcessBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 业务唯一值id + */ + @NotBlank(message = "业务ID不能为空", groups = {AddGroup.class}) + private String businessId; + + /** + * 流程定义编码 + */ + @NotBlank(message = "流程定义编码不能为空", groups = {AddGroup.class}) + private String flowCode; + + /** + * 流程变量,前端会提交一个元素{'entity': {业务详情数据对象}} + */ + private Map variables; + + public Map getVariables() { + if (variables == null) { + return new HashMap<>(16); + } + variables.entrySet().removeIf(entry -> Objects.isNull(entry.getValue())); + return variables; + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/TaskOperationBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/TaskOperationBo.java new file mode 100644 index 0000000..4348e31 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/TaskOperationBo.java @@ -0,0 +1,48 @@ +package org.dromara.workflow.domain.bo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + + +/** + * 任务操作业务对象,用于描述任务委派、转办、加签等操作的必要参数 + * 包含了用户ID、任务ID、任务相关的消息、以及加签/减签的用户ID + * + * @author AprilWind + */ +@Data +public class TaskOperationBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 委派/转办人的用户ID(必填,准对委派/转办人操作) + */ + @NotNull(message = "委派/转办人id不能为空", groups = {AddGroup.class}) + private String userId; + + /** + * 加签/减签人的用户ID列表(必填,针对加签/减签操作) + */ + @NotNull(message = "加签/减签id不能为空", groups = {EditGroup.class}) + private List userIds; + + /** + * 任务ID(必填) + */ + @NotNull(message = "任务id不能为空") + private Long taskId; + + /** + * 意见或备注信息(可选) + */ + private String message; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/TestLeaveBo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/TestLeaveBo.java new file mode 100644 index 0000000..395f71d --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/bo/TestLeaveBo.java @@ -0,0 +1,82 @@ +package org.dromara.workflow.domain.bo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.workflow.domain.TestLeave; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * 请假业务对象 test_leave + * + * @author may + * @date 2023-07-21 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = TestLeave.class, reverseConvertGenerate = false) +public class TestLeaveBo extends BaseEntity { + + /** + * 主键 + */ + @NotNull(message = "主键不能为空", groups = {EditGroup.class}) + private Long id; + + /** + * 请假类型 + */ + @NotBlank(message = "请假类型不能为空", groups = {AddGroup.class, EditGroup.class}) + private String leaveType; + + /** + * 开始时间 + */ + @NotNull(message = "开始时间不能为空", groups = {AddGroup.class, EditGroup.class}) + @DateTimeFormat(pattern = "yyyy-MM-dd") + @JsonFormat(pattern = "yyyy-MM-dd") + private Date startDate; + + /** + * 结束时间 + */ + @NotNull(message = "结束时间不能为空", groups = {AddGroup.class, EditGroup.class}) + @DateTimeFormat(pattern = "yyyy-MM-dd") + @JsonFormat(pattern = "yyyy-MM-dd") + private Date endDate; + + /** + * 请假天数 + */ + private Integer leaveDays; + + /** + * 开始时间 + */ + private Integer startLeaveDays; + + /** + * 结束时间 + */ + private Integer endLeaveDays; + + /** + * 请假原因 + */ + private String remark; + + /** + * 状态 + */ + private String status; + + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/ButtonPermissionVo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/ButtonPermissionVo.java new file mode 100644 index 0000000..7175e5e --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/ButtonPermissionVo.java @@ -0,0 +1,43 @@ +package org.dromara.workflow.domain.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 按钮权限 + * + * @author may + * @date 2025-02-28 + */ +@Data +public class ButtonPermissionVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 唯一编码 + */ + private String code; + + /** + * 选项值 + */ + private String value; + + /** + * 是否显示 + */ + private Boolean show; + + public ButtonPermissionVo() { + } + + public ButtonPermissionVo(String code, Boolean show) { + this.code = code; + this.show = show; + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowCategoryVo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowCategoryVo.java new file mode 100644 index 0000000..c5427a5 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowCategoryVo.java @@ -0,0 +1,69 @@ +package org.dromara.workflow.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.workflow.common.constant.FlowConstant; +import org.dromara.workflow.domain.FlowCategory; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 流程分类视图对象 wf_category + * + * @author may + * @date 2023-06-27 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = FlowCategory.class) +public class FlowCategoryVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 流程分类ID + */ + @ExcelProperty(value = "流程分类ID") + private Long categoryId; + + /** + * 父级分类id + */ + private Long parentId; + + /** + * 父级分类名称 + */ + @Translation(type = FlowConstant.CATEGORY_ID_TO_NAME, mapper = "parentId") + private String parentName; + + /** + * 祖级列表 + */ + private String ancestors; + + /** + * 流程分类名称 + */ + @ExcelProperty(value = "流程分类名称") + private String categoryName; + + /** + * 显示顺序 + */ + @ExcelProperty(value = "显示顺序") + private Long orderNum; + + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private Date createTime; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowDefinitionVo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowDefinitionVo.java new file mode 100644 index 0000000..210f1de --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowDefinitionVo.java @@ -0,0 +1,99 @@ +package org.dromara.workflow.domain.vo; + +import lombok.Data; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.workflow.common.constant.FlowConstant; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 流程定义视图 + * + * @author may + */ +@Data +public class FlowDefinitionVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + + /** + * 删除标记 + */ + private String delFlag; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 流程定义名称 + */ + private String flowName; + + /** + * 流程分类id + */ + private String category; + + /** + * 流程分类名称 + */ + @Translation(type = FlowConstant.CATEGORY_ID_TO_NAME, mapper = "category") + private String categoryName; + + /** + * 流程版本 + */ + private String version; + + /** + * 是否发布(0未发布 1已发布 9失效) + */ + private Integer isPublish; + + /** + * 审批表单是否自定义(Y是 N否) + */ + private String formCustom; + + /** + * 审批表单路径 + */ + private String formPath; + + /** + * 流程激活状态(0挂起 1激活) + */ + private Integer activityStatus; + + /** + * 监听器类型 + */ + private String listenerType; + + /** + * 监听器路径 + */ + private String listenerPath; + + /** + * 扩展字段,预留给业务系统使用 + */ + private String ext; +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowHisTaskVo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowHisTaskVo.java new file mode 100644 index 0000000..8776a76 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowHisTaskVo.java @@ -0,0 +1,244 @@ +package org.dromara.workflow.domain.vo; + +import lombok.Data; +import org.dromara.common.core.utils.DateUtils; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.common.translation.constant.TransConstant; +import org.dromara.warm.flow.core.enums.CooperateType; +import org.dromara.workflow.common.constant.FlowConstant; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * 历史任务视图 + * + * @author may + */ +@Data +public class FlowHisTaskVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + + /** + * 租户ID + */ + private String tenantId; + + /** + * 删除标记 + */ + private String delFlag; + + /** + * 对应flow_definition表的id + */ + private Long definitionId; + + /** + * 流程定义名称 + */ + private String flowName; + + /** + * 流程实例表id + */ + private Long instanceId; + + /** + * 任务表id + */ + private Long taskId; + + /** + * 协作方式(1审批 2转办 3委派 4会签 5票签 6加签 7减签) + */ + private Integer cooperateType; + + /** + * 协作方式(1审批 2转办 3委派 4会签 5票签 6加签 7减签) + */ + private String cooperateTypeName; + + /** + * 业务id + */ + private String businessId; + + /** + * 开始节点编码 + */ + private String nodeCode; + + /** + * 开始节点名称 + */ + private String nodeName; + + /** + * 开始节点类型(0开始节点 1中间节点 2结束节点 3互斥网关 4并行网关) + */ + private Integer nodeType; + + /** + * 目标节点编码 + */ + private String targetNodeCode; + + /** + * 结束节点名称 + */ + private String targetNodeName; + + /** + * 审批者 + */ + private String approver; + + /** + * 审批者 + */ + @Translation(type = TransConstant.USER_ID_TO_NICKNAME, mapper = "approver") + private String approveName; + + /** + * 协作人(只有转办、会签、票签、委派) + */ + private String collaborator; + + /** + * 权限标识 permissionFlag的list形式 + */ + private List permissionList; + + /** + * 跳转类型(PASS通过 REJECT退回 NONE无动作) + */ + private String skipType; + + /** + * 流程状态 + */ + private String flowStatus; + + /** + * 任务状态 + */ + private String flowTaskStatus; + + /** + * 流程状态 + */ + private String flowStatusName; + + /** + * 审批意见 + */ + private String message; + + /** + * 业务详情 存业务类的json + */ + private String ext; + + /** + * 创建者 + */ + private String createBy; + + /** + * 申请人 + */ + @Translation(type = TransConstant.USER_ID_TO_NICKNAME, mapper = "createBy") + private String createByName; + + /** + * 流程分类id + */ + private String category; + + /** + * 流程分类名称 + */ + @Translation(type = FlowConstant.CATEGORY_ID_TO_NAME, mapper = "category") + private String categoryName; + + /** + * 审批表单是否自定义(Y是 N否) + */ + private String formCustom; + + /** + * 审批表单路径 + */ + private String formPath; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 流程版本号 + */ + private String version; + + /** + * 运行时长 + */ + private String runDuration; + + /** + * 设置创建时间并计算任务运行时长 + * + * @param createTime 创建时间 + */ + public void setCreateTime(Date createTime) { + this.createTime = createTime; + updateRunDuration(); + } + + /** + * 设置更新时间并计算任务运行时长 + * + * @param updateTime 更新时间 + */ + public void setUpdateTime(Date updateTime) { + this.updateTime = updateTime; + updateRunDuration(); + } + + /** + * 更新运行时长 + */ + private void updateRunDuration() { + // 如果创建时间和更新时间均不为空,计算它们之间的时长 + if (this.updateTime != null && this.createTime != null) { + this.runDuration = DateUtils.getTimeDifference(this.updateTime, this.createTime); + } + } + + /** + * 设置协作方式,并通过协作方式获取名称 + */ + public void setCooperateType(Integer cooperateType) { + this.cooperateType = cooperateType; + this.cooperateTypeName = CooperateType.getValueByKey(cooperateType); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowInstanceVo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowInstanceVo.java new file mode 100644 index 0000000..75543f4 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowInstanceVo.java @@ -0,0 +1,137 @@ +package org.dromara.workflow.domain.vo; + +import lombok.Data; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.common.translation.constant.TransConstant; +import org.dromara.workflow.common.constant.FlowConstant; + +import java.util.Date; + +/** + * 流程实例视图 + * + * @author may + */ +@Data +public class FlowInstanceVo { + + private Long id; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + + /** + * 租户ID + */ + private String tenantId; + + /** + * 删除标记 + */ + private String delFlag; + + /** + * 对应flow_definition表的id + */ + private Long definitionId; + + /** + * 流程定义名称 + */ + private String flowName; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 业务id + */ + private String businessId; + + /** + * 节点类型(0开始节点 1中间节点 2结束节点 3互斥网关 4并行网关) + */ + private Integer nodeType; + + /** + * 流程节点编码 每个流程的nodeCode是唯一的,即definitionId+nodeCode唯一,在数据库层面做了控制 + */ + private String nodeCode; + + /** + * 流程节点名称 + */ + private String nodeName; + + /** + * 流程变量 + */ + private String variable; + + /** + * 流程状态(0待提交 1审批中 2 审批通过 3自动通过 8已完成 9已退回 10失效) + */ + private String flowStatus; + + /** + * 流程状态 + */ + private String flowStatusName; + + /** + * 流程激活状态(0挂起 1激活) + */ + private Integer activityStatus; + + /** + * 审批表单是否自定义(Y是 N否) + */ + private String formCustom; + + /** + * 审批表单路径 + */ + private String formPath; + + /** + * 扩展字段,预留给业务系统使用 + */ + private String ext; + + /** + * 流程定义版本 + */ + private String version; + + /** + * 创建者 + */ + private String createBy; + + /** + * 申请人 + */ + @Translation(type = TransConstant.USER_ID_TO_NICKNAME, mapper = "createBy") + private String createByName; + + /** + * 流程分类id + */ + private String category; + + /** + * 流程分类名称 + */ + @Translation(type = FlowConstant.CATEGORY_ID_TO_NAME, mapper = "category") + private String categoryName; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowTaskVo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowTaskVo.java new file mode 100644 index 0000000..1b1191e --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/FlowTaskVo.java @@ -0,0 +1,182 @@ +package org.dromara.workflow.domain.vo; + +import lombok.Data; +import org.dromara.common.translation.annotation.Translation; +import org.dromara.common.translation.constant.TransConstant; +import org.dromara.warm.flow.core.entity.User; +import org.dromara.workflow.common.constant.FlowConstant; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +/** + * 任务视图 + * + * @author may + */ +@Data +public class FlowTaskVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + + /** + * 删除标记 + */ + private String delFlag; + + /** + * 对应flow_definition表的id + */ + private Long definitionId; + + /** + * 流程实例表id + */ + private Long instanceId; + + /** + * 流程定义名称 + */ + private String flowName; + + /** + * 业务id + */ + private String businessId; + + /** + * 节点编码 + */ + private String nodeCode; + + /** + * 节点名称 + */ + private String nodeName; + + /** + * 节点类型(0开始节点 1中间节点 2结束节点 3互斥网关 4并行网关) + */ + private Integer nodeType; + + /** + * 权限标识 permissionFlag的list形式 + */ + private List permissionList; + + /** + * 流程用户列表 + */ + private List userList; + + /** + * 审批表单是否自定义(Y是 N否) + */ + private String formCustom; + + /** + * 审批表单 + */ + private String formPath; + + /** + * 流程定义编码 + */ + private String flowCode; + + /** + * 流程版本号 + */ + private String version; + + /** + * 流程状态 + */ + private String flowStatus; + + /** + * 流程分类id + */ + private String category; + + /** + * 流程分类名称 + */ + @Translation(type = FlowConstant.CATEGORY_ID_TO_NAME, mapper = "category") + private String categoryName; + + /** + * 流程状态 + */ + @Translation(type = TransConstant.DICT_TYPE_TO_LABEL, mapper = "flowStatus", other = "wf_business_status") + private String flowStatusName; + + /** + * 办理人类型 + */ + private String type; + + /** + * 办理人ids + */ + private String assigneeIds; + + /** + * 办理人名称 + */ + private String assigneeNames; + + /** + * 抄送人id + */ + private String processedBy; + + /** + * 抄送人名称 + */ + @Translation(type = TransConstant.USER_ID_TO_NICKNAME, mapper = "processedBy") + private String processedByName; + + /** + * 流程签署比例值 大于0为票签,会签 + */ + private BigDecimal nodeRatio; + + /** + * 申请人id + */ + private String createBy; + + /** + * 申请人名称 + */ + @Translation(type = TransConstant.USER_ID_TO_NICKNAME, mapper = "createBy") + private String createByName; + + /** + * 是否为申请人节点 + */ + private Boolean applyNode; + + /** + * 按钮权限 + */ + private List buttonList; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/TestLeaveVo.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/TestLeaveVo.java new file mode 100644 index 0000000..741949f --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/domain/vo/TestLeaveVo.java @@ -0,0 +1,70 @@ +package org.dromara.workflow.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.dromara.workflow.domain.TestLeave; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + + +/** + * 请假视图对象 test_leave + * + * @author may + * @date 2023-07-21 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = TestLeave.class) +public class TestLeaveVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @ExcelProperty(value = "主键") + private Long id; + + /** + * 请假类型 + */ + @ExcelProperty(value = "请假类型") + private String leaveType; + + /** + * 开始时间 + */ + @ExcelProperty(value = "开始时间") + private Date startDate; + + /** + * 结束时间 + */ + @ExcelProperty(value = "结束时间") + private Date endDate; + + /** + * 请假天数 + */ + @ExcelProperty(value = "请假天数") + private Integer leaveDays; + + /** + * 备注 + */ + @ExcelProperty(value = "请假原因") + private String remark; + + /** + * 状态 + */ + @ExcelProperty(value = "状态") + private String status; + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/handler/FlowProcessEventHandler.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/handler/FlowProcessEventHandler.java new file mode 100644 index 0000000..fc7bfcb --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/handler/FlowProcessEventHandler.java @@ -0,0 +1,84 @@ +package org.dromara.workflow.handler; + +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.event.ProcessDeleteEvent; +import org.dromara.common.core.domain.event.ProcessEvent; +import org.dromara.common.core.domain.event.ProcessTaskEvent; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.warm.flow.core.entity.Instance; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 流程监听服务 + * + * @author may + * @date 2024-06-02 + */ +@ConditionalOnEnable +@Slf4j +@Component +public class FlowProcessEventHandler { + + /** + * 总体流程监听(例如: 草稿,撤销,退回,作废,终止,已完成,单任务完成等) + * + * @param flowCode 流程定义编码 + * @param instance 实例数据 + * @param status 流程状态 + * @param params 办理参数 + * @param submit 当为true时为申请人节点办理 + */ + public void processHandler(String flowCode, Instance instance, String status, Map params, boolean submit) { + log.info("【流程事件发布】流程编码: {}, 业务ID: {}, 流程状态: {}, 节点类型: {}, 节点编码: {}, 节点名称: {}, 是否申请人节点: {}, 参数: {}", + flowCode, instance.getBusinessId(), status, instance.getNodeType(), instance.getNodeCode(), instance.getNodeName(), submit, params); + ProcessEvent processEvent = new ProcessEvent(); + processEvent.setFlowCode(flowCode); + processEvent.setBusinessId(instance.getBusinessId()); + processEvent.setNodeType(instance.getNodeType()); + processEvent.setNodeCode(instance.getNodeCode()); + processEvent.setNodeName(instance.getNodeName()); + processEvent.setStatus(status); + processEvent.setParams(params); + processEvent.setSubmit(submit); + SpringUtils.context().publishEvent(processEvent); + } + + /** + * 执行创建任务监听 + * + * @param flowCode 流程定义编码 + * @param instance 实例数据 + * @param taskId 任务id + */ + public void processTaskHandler(String flowCode, Instance instance, Long taskId) { + log.info("【流程任务事件发布】流程编码: {}, 业务ID: {}, 节点类型: {}, 节点编码: {}, 节点名称: {}, 任务ID: {}", + flowCode, instance.getBusinessId(), instance.getNodeType(), instance.getNodeCode(), instance.getNodeName(), taskId); + ProcessTaskEvent processTaskEvent = new ProcessTaskEvent(); + processTaskEvent.setFlowCode(flowCode); + processTaskEvent.setBusinessId(instance.getBusinessId()); + processTaskEvent.setNodeType(instance.getNodeType()); + processTaskEvent.setNodeCode(instance.getNodeCode()); + processTaskEvent.setNodeName(instance.getNodeName()); + processTaskEvent.setTaskId(taskId); + processTaskEvent.setStatus(instance.getFlowStatus()); + SpringUtils.context().publishEvent(processTaskEvent); + } + + /** + * 删除流程监听 + * + * @param flowCode 流程定义编码 + * @param businessId 业务ID + */ + public void processDeleteHandler(String flowCode, String businessId) { + log.info("【流程删除事件发布】, 流程编码: {}, 业务ID: {}", flowCode, businessId); + ProcessDeleteEvent processDeleteEvent = new ProcessDeleteEvent(); + processDeleteEvent.setFlowCode(flowCode); + processDeleteEvent.setBusinessId(businessId); + SpringUtils.context().publishEvent(processDeleteEvent); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/handler/WorkflowPermissionHandler.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/handler/WorkflowPermissionHandler.java new file mode 100644 index 0000000..f9ede15 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/handler/WorkflowPermissionHandler.java @@ -0,0 +1,59 @@ +package org.dromara.workflow.handler; + +import cn.hutool.core.collection.CollUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.warm.flow.core.dto.FlowParams; +import org.dromara.warm.flow.core.handler.PermissionHandler; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.service.IFlwCommonService; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 办理人权限处理器 + * + * @author AprilWind + */ +@ConditionalOnEnable +@RequiredArgsConstructor +@Component +@Slf4j +public class WorkflowPermissionHandler implements PermissionHandler { + + private final IFlwCommonService flwCommonService; + + /** + * 办理人权限标识,比如用户,角色,部门等,用于校验是否有权限办理任务 + * 后续在{@link FlowParams#getPermissionFlag} 中获取 + * 返回当前用户权限集合 + */ + @Override + public List permissions() { + return Collections.singletonList(LoginHelper.getUserIdStr()); + } + + /** + * 获取当前办理人 + * + * @return 当前办理人 + */ + @Override + public String getHandler() { + return LoginHelper.getUserIdStr(); + } + + /** + * 转换办理人,比如设计器中预设了能办理的人,如果其中包含角色或者部门id等,可以通过此接口进行转换成用户id + */ + @Override + public List convertPermissions(List permissions) { + if (CollUtil.isNotEmpty(permissions)) { + permissions = flwCommonService.buildUser(permissions); + } + return permissions; + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/listener/WorkflowGlobalListener.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/listener/WorkflowGlobalListener.java new file mode 100644 index 0000000..6214a86 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/listener/WorkflowGlobalListener.java @@ -0,0 +1,193 @@ +package org.dromara.workflow.listener; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.enums.BusinessStatusEnum; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.warm.flow.core.FlowEngine; +import org.dromara.warm.flow.core.dto.FlowParams; +import org.dromara.warm.flow.core.entity.Definition; +import org.dromara.warm.flow.core.entity.Instance; +import org.dromara.warm.flow.core.entity.Task; +import org.dromara.warm.flow.core.listener.GlobalListener; +import org.dromara.warm.flow.core.listener.ListenerVariable; +import org.dromara.warm.flow.core.service.InsService; +import org.dromara.warm.flow.orm.entity.FlowInstance; +import org.dromara.warm.flow.orm.entity.FlowTask; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.constant.FlowConstant; +import org.dromara.workflow.common.enums.TaskStatusEnum; +import org.dromara.workflow.domain.bo.FlowCopyBo; +import org.dromara.workflow.handler.FlowProcessEventHandler; +import org.dromara.workflow.service.IFlwCommonService; +import org.dromara.workflow.service.IFlwInstanceService; +import org.dromara.workflow.service.IFlwTaskService; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 全局任务办理监听 + * + * @author may + */ +@ConditionalOnEnable +@Component +@Slf4j +@RequiredArgsConstructor +public class WorkflowGlobalListener implements GlobalListener { + + private final IFlwTaskService flwTaskService; + private final IFlwInstanceService instanceService; + private final FlowProcessEventHandler flowProcessEventHandler; + private final IFlwCommonService flwCommonService; + private final InsService insService; + + /** + * 创建监听器,任务创建时执行 + * + * @param listenerVariable 监听器变量 + */ + @Override + public void create(ListenerVariable listenerVariable) { + + } + + /** + * 开始监听器,任务开始办理时执行 + * + * @param listenerVariable 监听器变量 + */ + @Override + public void start(ListenerVariable listenerVariable) { + } + + /** + * 分派监听器,动态修改代办任务信息 + * + * @param listenerVariable 监听器变量 + */ + @Override + public void assignment(ListenerVariable listenerVariable) { + Map variable = listenerVariable.getVariable(); + List nextTasks = listenerVariable.getNextTasks(); + FlowParams flowParams = listenerVariable.getFlowParams(); + Definition definition = listenerVariable.getDefinition(); + Instance instance = listenerVariable.getInstance(); + String applyNodeCode = flwCommonService.applyNodeCode(definition.getId()); + for (Task flowTask : nextTasks) { + // 如果办理或者退回并行存在需要指定办理人,则直接覆盖办理人 + if (variable.containsKey(flowTask.getNodeCode()) && (TaskStatusEnum.PASS.getStatus().equals(flowParams.getHisStatus()) + || TaskStatusEnum.BACK.getStatus().equals(flowParams.getHisStatus()))) { + String userIds = variable.get(flowTask.getNodeCode()).toString(); + flowTask.setPermissionList(List.of(userIds.split(StringUtils.SEPARATOR))); + variable.remove(flowTask.getNodeCode()); + } + // 如果是申请节点,则把启动人添加到办理人 + if (flowTask.getNodeCode().equals(applyNodeCode)) { + flowTask.setPermissionList(List.of(instance.getCreateBy())); + } + } + } + + /** + * 完成监听器,当前任务完成后执行 + * + * @param listenerVariable 监听器变量 + */ + @Override + public void finish(ListenerVariable listenerVariable) { + Instance instance = listenerVariable.getInstance(); + Definition definition = listenerVariable.getDefinition(); + Task task = listenerVariable.getTask(); + Map params = new HashMap<>(); + FlowParams flowParams = listenerVariable.getFlowParams(); + Map variable = new HashMap<>(); + if (ObjectUtil.isNotNull(flowParams)) { + // 历史任务扩展(通常为附件) + params.put("hisTaskExt", flowParams.getHisTaskExt()); + // 办理人 + params.put("handler", flowParams.getHandler()); + // 办理意见 + params.put("message", flowParams.getMessage()); + variable = flowParams.getVariable(); + } + //申请人提交事件 + Boolean submit = MapUtil.getBool(variable, FlowConstant.SUBMIT); + if (submit != null && submit) { + flowProcessEventHandler.processHandler(definition.getFlowCode(), instance, instance.getFlowStatus(), variable, true); + } else { + // 判断流程状态(发布:撤销,退回,作废,终止,已完成事件) + String status = determineFlowStatus(instance); + if (StringUtils.isNotBlank(status)) { + flowProcessEventHandler.processHandler(definition.getFlowCode(), instance, status, params, false); + } + } + //发布任务事件 + if (task != null) { + flowProcessEventHandler.processTaskHandler(definition.getFlowCode(), instance, task.getId()); + } + if (ObjectUtil.isNull(flowParams)) { + return; + } + // 只有办理或者退回的时候才执行消息通知和抄送 + if (TaskStatusEnum.PASS.getStatus().equals(flowParams.getHisStatus()) + || TaskStatusEnum.BACK.getStatus().equals(flowParams.getHisStatus())) { + if (variable != null) { + if (variable.containsKey(FlowConstant.FLOW_COPY_LIST)) { + List flowCopyList = (List) variable.get(FlowConstant.FLOW_COPY_LIST); + // 添加抄送人 + flwTaskService.setCopy(task, flowCopyList); + } + if (variable.containsKey(FlowConstant.MESSAGE_TYPE)) { + List messageType = (List) variable.get(FlowConstant.MESSAGE_TYPE); + String notice = (String) variable.get(FlowConstant.MESSAGE_NOTICE); + // 消息通知 + if (CollUtil.isNotEmpty(messageType)) { + flwCommonService.sendMessage(definition.getFlowName(), instance.getId(), messageType, notice); + } + } + FlowInstance ins = new FlowInstance(); + Map variableMap = instance.getVariableMap(); + variableMap.remove(FlowConstant.FLOW_COPY_LIST); + variableMap.remove(FlowConstant.MESSAGE_TYPE); + variableMap.remove(FlowConstant.MESSAGE_NOTICE); + variableMap.remove(FlowConstant.SUBMIT); + ins.setId(instance.getId()); + ins.setVariable(FlowEngine.jsonConvert.objToStr(variableMap)); + insService.updateById(ins); + } + } + } + + /** + * 根据流程实例确定最终状态 + * + * @param instance 流程实例 + * @return 流程最终状态 + */ + private String determineFlowStatus(Instance instance) { + String flowStatus = instance.getFlowStatus(); + if (StringUtils.isNotBlank(flowStatus) && BusinessStatusEnum.initialState(flowStatus)) { + log.info("流程实例当前状态: {}", flowStatus); + return flowStatus; + } else { + Long instanceId = instance.getId(); + List flowTasks = flwTaskService.selectByInstId(instanceId); + if (CollUtil.isEmpty(flowTasks)) { + String status = BusinessStatusEnum.FINISH.getStatus(); + // 更新流程状态为已完成 + instanceService.updateStatus(instanceId, status); + log.info("流程已结束,状态更新为: {}", status); + return status; + } + return null; + } + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwCategoryMapper.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwCategoryMapper.java new file mode 100644 index 0000000..c572788 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwCategoryMapper.java @@ -0,0 +1,62 @@ +package org.dromara.workflow.mapper; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.dromara.common.mybatis.annotation.DataColumn; +import org.dromara.common.mybatis.annotation.DataPermission; +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.common.mybatis.helper.DataBaseHelper; +import org.dromara.workflow.domain.FlowCategory; +import org.dromara.workflow.domain.vo.FlowCategoryVo; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 流程分类Mapper接口 + * + * @author may + * @date 2023-06-27 + */ +public interface FlwCategoryMapper extends BaseMapperPlus { + + /** + * 统计指定流程分类ID的分类数量 + * + * @param categoryId 流程分类ID + * @return 该流程分类ID的分类数量 + */ + @DataPermission({ + @DataColumn(key = "deptName", value = "createDept") + }) + default long countCategoryById(Long categoryId) { + return this.selectCount(new LambdaQueryWrapper().eq(FlowCategory::getCategoryId, categoryId)); + } + + /** + * 根据父流程分类ID查询其所有子流程分类的列表 + * + * @param parentId 父流程分类ID + * @return 包含子流程分类的列表 + */ + default List selectListByParentId(Long parentId) { + return this.selectList(new LambdaQueryWrapper() + .select(FlowCategory::getCategoryId) + .apply(DataBaseHelper.findInSet(parentId, "ancestors"))); + } + + /** + * 根据父流程分类ID查询包括父ID及其所有子流程分类ID的列表 + * + * @param parentId 父流程分类ID + * @return 包含父ID和子流程分类ID的列表 + */ + default List selectCategoryIdsByParentId(Long parentId) { + return Stream.concat( + this.selectListByParentId(parentId).stream() + .map(FlowCategory::getCategoryId), + Stream.of(parentId) + ).collect(Collectors.toList()); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwInstanceMapper.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwInstanceMapper.java new file mode 100644 index 0000000..92809c8 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwInstanceMapper.java @@ -0,0 +1,27 @@ +package org.dromara.workflow.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Param; +import org.dromara.workflow.domain.bo.FlowInstanceBo; +import org.dromara.workflow.domain.vo.FlowInstanceVo; + +/** + * 实例信息Mapper接口 + * + * @author may + * @date 2024-03-02 + */ +public interface FlwInstanceMapper { + + /** + * 流程实例信息 + * + * @param page 分页 + * @param queryWrapper 条件 + * @return 结果 + */ + Page selectInstanceList(@Param("page") Page page, @Param(Constants.WRAPPER) Wrapper queryWrapper); + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwTaskMapper.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwTaskMapper.java new file mode 100644 index 0000000..fd86c82 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/FlwTaskMapper.java @@ -0,0 +1,57 @@ +package org.dromara.workflow.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Param; +import org.dromara.workflow.domain.bo.FlowTaskBo; +import org.dromara.workflow.domain.vo.FlowHisTaskVo; +import org.dromara.workflow.domain.vo.FlowTaskVo; + +import java.util.List; + + +/** + * 任务信息Mapper接口 + * + * @author may + * @date 2024-03-02 + */ +public interface FlwTaskMapper { + + /** + * 获取待办信息 + * + * @param page 分页 + * @param queryWrapper 条件 + * @return 结果 + */ + Page getListRunTask(@Param("page") Page page, @Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 获取待办信息 + * + * @param queryWrapper 条件 + * @return 结果 + */ + List getListRunTask(@Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 获取已办 + * + * @param page 分页 + * @param queryWrapper 条件 + * @return 结果 + */ + Page getListFinishTask(@Param("page") Page page, @Param(Constants.WRAPPER) Wrapper queryWrapper); + + /** + * 查询当前用户的抄送 + * + * @param page 分页 + * @param queryWrapper 条件 + * @return 结果 + */ + Page getTaskCopyByPage(@Param("page") Page page, @Param(Constants.WRAPPER) QueryWrapper queryWrapper); +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/TestLeaveMapper.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/TestLeaveMapper.java new file mode 100644 index 0000000..cd1edba --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/mapper/TestLeaveMapper.java @@ -0,0 +1,15 @@ +package org.dromara.workflow.mapper; + +import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; +import org.dromara.workflow.domain.TestLeave; +import org.dromara.workflow.domain.vo.TestLeaveVo; + +/** + * 请假Mapper接口 + * + * @author may + * @date 2023-07-21 + */ +public interface TestLeaveMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwCategoryService.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwCategoryService.java new file mode 100644 index 0000000..91f173d --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwCategoryService.java @@ -0,0 +1,102 @@ +package org.dromara.workflow.service; + +import cn.hutool.core.lang.tree.Tree; +import org.dromara.workflow.domain.bo.FlowCategoryBo; +import org.dromara.workflow.domain.vo.FlowCategoryVo; + +import java.util.List; + +/** + * 流程分类Service接口 + * + * @author may + */ +public interface IFlwCategoryService { + + /** + * 查询流程分类 + * + * @param categoryId 主键 + * @return 流程分类 + */ + FlowCategoryVo queryById(Long categoryId); + + /** + * 根据流程分类ID查询流程分类名称 + * + * @param categoryId 流程分类ID + * @return 流程分类名称 + */ + String selectCategoryNameById(Long categoryId); + + /** + * 查询符合条件的流程分类列表 + * + * @param bo 查询条件 + * @return 流程分类列表 + */ + List queryList(FlowCategoryBo bo); + + /** + * 查询流程分类树结构信息 + * + * @param category 流程分类信息 + * @return 流程分类树信息集合 + */ + List> selectCategoryTreeList(FlowCategoryBo category); + + /** + * 校验流程分类是否有数据权限 + * + * @param categoryId 流程分类ID + */ + void checkCategoryDataScope(Long categoryId); + + /** + * 校验流程分类名称是否唯一 + * + * @param category 流程分类信息 + * @return 结果 + */ + boolean checkCategoryNameUnique(FlowCategoryBo category); + + /** + * 查询流程分类是否存在流程定义 + * + * @param categoryId 流程分类ID + * @return 结果 true 存在 false 不存在 + */ + boolean checkCategoryExistDefinition(Long categoryId); + + /** + * 是否存在流程分类子节点 + * + * @param categoryId 流程分类ID + * @return 结果 + */ + boolean hasChildByCategoryId(Long categoryId); + + /** + * 新增流程分类 + * + * @param bo 流程分类 + * @return 是否新增成功 + */ + int insertByBo(FlowCategoryBo bo); + + /** + * 修改流程分类 + * + * @param bo 流程分类 + * @return 是否修改成功 + */ + int updateByBo(FlowCategoryBo bo); + + /** + * 删除流程分类信息 + * + * @param categoryId 主键 + * @return 是否删除成功 + */ + int deleteWithValidById(Long categoryId); +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwCommonService.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwCommonService.java new file mode 100644 index 0000000..662d599 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwCommonService.java @@ -0,0 +1,37 @@ +package org.dromara.workflow.service; + +import java.util.List; + +/** + * 通用 工作流服务 + * + * @author LionLi + */ +public interface IFlwCommonService { + + /** + * 构建工作流用户 + * + * @param permissionList 办理用户 + * @return 用户 + */ + List buildUser(List permissionList); + + /** + * 发送消息 + * + * @param flowName 流程定义名称 + * @param instId 实例id + * @param messageType 消息类型 + * @param message 消息内容,为空则发送默认配置的消息内容 + */ + void sendMessage(String flowName, Long instId, List messageType, String message); + + /** + * 申请人节点编码 + * + * @param definitionId 流程定义id + * @return 申请人节点编码 + */ + String applyNodeCode(Long definitionId); +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwDefinitionService.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwDefinitionService.java new file mode 100644 index 0000000..a2d6110 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwDefinitionService.java @@ -0,0 +1,72 @@ +package org.dromara.workflow.service; + +import jakarta.servlet.http.HttpServletResponse; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.warm.flow.orm.entity.FlowDefinition; +import org.dromara.workflow.domain.vo.FlowDefinitionVo; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +/** + * 流程定义 服务层 + * + * @author may + */ +public interface IFlwDefinitionService { + + /** + * 查询流程定义列表 + * + * @param flowDefinition 参数 + * @param pageQuery 分页 + * @return 返回分页列表 + */ + TableDataInfo queryList(FlowDefinition flowDefinition, PageQuery pageQuery); + + /** + * 查询未发布的流程定义列表 + * + * @param flowDefinition 参数 + * @param pageQuery 分页 + * @return 返回分页列表 + */ + TableDataInfo unPublishList(FlowDefinition flowDefinition, PageQuery pageQuery); + + /** + * 发布流程定义 + * + * @param id 流程定义id + * @return 结果 + */ + boolean publish(Long id); + + /** + * 导出流程定义 + * + * @param id 流程定义id + * @param response 响应 + * @throws IOException 异常 + */ + void exportDef(Long id, HttpServletResponse response) throws IOException; + + /** + * 导入流程定义 + * + * @param file 文件 + * @param category 分类 + * @return 结果 + */ + boolean importJson(MultipartFile file, String category); + + /** + * 删除流程定义 + * + * @param ids 流程定义id + * @return 结果 + */ + boolean removeDef(List ids); + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwInstanceService.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwInstanceService.java new file mode 100644 index 0000000..01e5124 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwInstanceService.java @@ -0,0 +1,159 @@ +package org.dromara.workflow.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.warm.flow.orm.entity.FlowInstance; +import org.dromara.workflow.domain.bo.FlowCancelBo; +import org.dromara.workflow.domain.bo.FlowInstanceBo; +import org.dromara.workflow.domain.bo.FlowInvalidBo; +import org.dromara.workflow.domain.vo.FlowInstanceVo; + +import java.util.List; +import java.util.Map; + +/** + * 流程实例 服务层 + * + * @author may + */ +public interface IFlwInstanceService { + + /** + * 分页查询正在运行的流程实例 + * + * @param flowInstanceBo 流程实例 + * @param pageQuery 分页 + * @return 结果 + */ + TableDataInfo selectRunningInstanceList(FlowInstanceBo flowInstanceBo, PageQuery pageQuery); + + /** + * 分页查询已结束的流程实例 + * + * @param flowInstanceBo 流程实例 + * @param pageQuery 分页 + * @return 结果 + */ + TableDataInfo selectFinishInstanceList(FlowInstanceBo flowInstanceBo, PageQuery pageQuery); + + /** + * 根据业务id查询流程实例详细信息 + * + * @param businessId 业务id + * @return 结果 + */ + FlowInstanceVo queryByBusinessId(Long businessId); + + /** + * 按照业务id查询流程实例 + * + * @param businessId 业务id + * @return 结果 + */ + FlowInstance selectInstByBusinessId(String businessId); + + /** + * 按照实例id查询流程实例 + * + * @param instanceId 实例id + * @return 结果 + */ + FlowInstance selectInstById(Long instanceId); + + /** + * 按照实例id查询流程实例 + * + * @param instanceIds 实例id + * @return 结果 + */ + List selectInstListByIdList(List instanceIds); + + /** + * 按照业务id删除流程实例 + * + * @param businessIds 业务id + * @return 结果 + */ + boolean deleteByBusinessIds(List businessIds); + + /** + * 按照实例id删除流程实例 + * + * @param instanceIds 实例id + * @return 结果 + */ + boolean deleteByInstanceIds(List instanceIds); + + /** + * 撤销流程 + * + * @param bo 参数 + * @return 结果 + */ + boolean cancelProcessApply(FlowCancelBo bo); + + /** + * 获取当前登陆人发起的流程实例 + * + * @param instanceBo 流程实例 + * @param pageQuery 分页 + * @return 结果 + */ + TableDataInfo selectCurrentInstanceList(FlowInstanceBo instanceBo, PageQuery pageQuery); + + /** + * 获取流程图,流程记录 + * + * @param businessId 业务id + * @return 结果 + */ + Map flowHisTaskList(String businessId); + + /** + * 按照实例id更新状态 + * + * @param instanceId 实例id + * @param status 状态 + */ + void updateStatus(Long instanceId, String status); + + /** + * 获取流程变量 + * + * @param instanceId 实例id + * @return 结果 + */ + Map instanceVariable(Long instanceId); + + /** + * 设置流程变量 + * + * @param instanceId 实例id + * @param variable 流程变量 + */ + void setVariable(Long instanceId, Map variable); + + /** + * 按任务id查询实例 + * + * @param taskId 任务id + * @return 结果 + */ + FlowInstance selectByTaskId(Long taskId); + + /** + * 按任务id查询实例 + * + * @param taskIdList 任务id + * @return 结果 + */ + List selectByTaskIdList(List taskIdList); + + /** + * 作废流程 + * + * @param bo 流程实例 + * @return 结果 + */ + boolean processInvalid(FlowInvalidBo bo); +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwNodeExtService.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwNodeExtService.java new file mode 100644 index 0000000..9595165 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwNodeExtService.java @@ -0,0 +1,22 @@ +package org.dromara.workflow.service; + +import org.dromara.workflow.domain.vo.ButtonPermissionVo; + +import java.util.List; + +/** + * 流程节点扩展属性 服务层 + * + * @author AprilWind + */ +public interface IFlwNodeExtService { + + /** + * 从扩展属性构建按钮权限列表:根据 ext 中记录的权限值,标记每个按钮是否勾选 + * + * @param ext 扩展属性 JSON 字符串 + * @return 按钮权限 VO 列表 + */ + List buildButtonPermissionsFromExt(String ext); + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwTaskAssigneeService.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwTaskAssigneeService.java new file mode 100644 index 0000000..830abaf --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwTaskAssigneeService.java @@ -0,0 +1,24 @@ +package org.dromara.workflow.service; + +import org.dromara.common.core.domain.dto.UserDTO; + +import java.util.List; + +/** + * 流程设计器-获取办理人 + * + * @author AprilWind + */ +public interface IFlwTaskAssigneeService { + + /** + * 批量解析多个存储标识符(storageIds),按类型分类并合并查询用户列表 + * 输入格式支持多个以逗号分隔的标识(如 "user:123,role:456,789") + * 会自动去重返回结果,非法格式的标识将被忽略 + * + * @param storageIds 多个存储标识符字符串(逗号分隔) + * @return 合并后的用户列表,去重后返回,非法格式的标识将被跳过 + */ + List fetchUsersByStorageIds(String storageIds); + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwTaskService.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwTaskService.java new file mode 100644 index 0000000..26dd7c9 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/IFlwTaskService.java @@ -0,0 +1,218 @@ +package org.dromara.workflow.service; + +import org.dromara.common.core.domain.dto.StartProcessReturnDTO; +import org.dromara.common.core.domain.dto.UserDTO; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.warm.flow.core.entity.Node; +import org.dromara.warm.flow.core.entity.Task; +import org.dromara.warm.flow.orm.entity.FlowHisTask; +import org.dromara.warm.flow.orm.entity.FlowNode; +import org.dromara.warm.flow.orm.entity.FlowTask; +import org.dromara.workflow.domain.bo.*; +import org.dromara.workflow.domain.vo.FlowHisTaskVo; +import org.dromara.workflow.domain.vo.FlowTaskVo; + +import java.util.List; +import java.util.Map; + +/** + * 任务 服务层 + * + * @author may + */ +public interface IFlwTaskService { + + /** + * 启动任务 + * + * @param startProcessBo 启动流程参数 + * @return 结果 + */ + StartProcessReturnDTO startWorkFlow(StartProcessBo startProcessBo); + + /** + * 办理任务 + * + * @param completeTaskBo 办理任务参数 + * @return 结果 + */ + boolean completeTask(CompleteTaskBo completeTaskBo); + + /** + * 添加抄送人 + * + * @param task 任务信息 + * @param flowCopyList 抄送人 + */ + void setCopy(Task task, List flowCopyList); + + /** + * 查询当前用户的待办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + * @return 结果 + */ + TableDataInfo pageByTaskWait(FlowTaskBo flowTaskBo, PageQuery pageQuery); + + /** + * 查询所有待办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + * @return 结果 + */ + TableDataInfo pageByTaskFinish(FlowTaskBo flowTaskBo, PageQuery pageQuery); + + /** + * 查询待办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + * @return 结果 + */ + TableDataInfo pageByAllTaskWait(FlowTaskBo flowTaskBo, PageQuery pageQuery); + + /** + * 查询已办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + * @return 结果 + */ + TableDataInfo pageByAllTaskFinish(FlowTaskBo flowTaskBo, PageQuery pageQuery); + + /** + * 查询当前用户的抄送 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + * @return 结果 + */ + TableDataInfo pageByTaskCopy(FlowTaskBo flowTaskBo, PageQuery pageQuery); + + /** + * 修改任务办理人 + * + * @param taskIdList 任务id + * @param userId 用户id + * @return 结果 + */ + boolean updateAssignee(List taskIdList, String userId); + + /** + * 驳回审批 + * + * @param bo 参数 + * @return 结果 + */ + boolean backProcess(BackProcessBo bo); + + /** + * 获取可驳回的前置节点 + * + * @param definitionId 流程定义id + * @param nowNodeCode 当前节点 + * @return 结果 + */ + List getBackTaskNode(Long definitionId, String nowNodeCode); + + /** + * 终止任务 + * + * @param bo 参数 + * @return 结果 + */ + boolean terminationTask(FlowTerminationBo bo); + + /** + * 按照任务id查询任务 + * + * @param taskIdList 任务id + * @return 结果 + */ + List selectByIdList(List taskIdList); + + /** + * 按照任务id查询任务 + * + * @param taskId 任务id + * @return 结果 + */ + FlowTaskVo selectById(Long taskId); + + /** + * 获取下一节点信息 + * + * @param bo 参数 + * @return 结果 + */ + List getNextNodeList(FlowNextNodeBo bo); + + /** + * 按照任务id查询任务 + * + * @param taskIdList 任务id + * @return 结果 + */ + List selectHisTaskByIdList(List taskIdList); + + /** + * 按照任务id查询任务 + * + * @param taskId 任务id + * @return 结果 + */ + FlowHisTask selectHisTaskById(Long taskId); + + /** + * 按照实例id查询任务 + * + * @param instanceIdList 流程实例id + * @return 结果 + */ + List selectByInstIdList(List instanceIdList); + + /** + * 按照实例id查询任务 + * + * @param instanceId 流程实例id + * @return 结果 + */ + List selectByInstId(Long instanceId); + + /** + * 任务操作 + * + * @param bo 参数 + * @param taskOperation 操作类型,委派 delegateTask、转办 transferTask、加签 addSignature、减签 reductionSignature + * @return 结果 + */ + boolean taskOperation(TaskOperationBo bo, String taskOperation); + + /** + * 获取任务所有办理人 + * + * @param taskIdList 任务id + * @return 结果 + */ + Map> currentTaskAllUser(List taskIdList); + + /** + * 获取当前任务的所有办理人 + * + * @param taskId 任务id + * @return 结果 + */ + List currentTaskAllUser(Long taskId); + + /** + * 按照节点编码查询节点 + * + * @param nodeCode 节点编码 + * @param definitionId 流程定义id + * @return 节点 + */ + FlowNode getByNodeCode(String nodeCode, Long definitionId); +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/ITestLeaveService.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/ITestLeaveService.java new file mode 100644 index 0000000..67b50ba --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/ITestLeaveService.java @@ -0,0 +1,47 @@ +package org.dromara.workflow.service; + +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.workflow.domain.bo.TestLeaveBo; +import org.dromara.workflow.domain.vo.TestLeaveVo; + +import java.util.List; + +/** + * 请假Service接口 + * + * @author may + * @date 2023-07-21 + */ +public interface ITestLeaveService { + + /** + * 查询请假 + */ + TestLeaveVo queryById(Long id); + + /** + * 查询请假列表 + */ + TableDataInfo queryPageList(TestLeaveBo bo, PageQuery pageQuery); + + /** + * 查询请假列表 + */ + List queryList(TestLeaveBo bo); + + /** + * 新增请假 + */ + TestLeaveVo insertByBo(TestLeaveBo bo); + + /** + * 修改请假 + */ + TestLeaveVo updateByBo(TestLeaveBo bo); + + /** + * 校验并批量删除请假信息 + */ + Boolean deleteWithValidByIds(List ids); +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/CategoryNameTranslationImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/CategoryNameTranslationImpl.java new file mode 100644 index 0000000..883a967 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/CategoryNameTranslationImpl.java @@ -0,0 +1,31 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.convert.Convert; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.translation.annotation.TranslationType; +import org.dromara.common.translation.core.TranslationInterface; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.constant.FlowConstant; +import org.dromara.workflow.service.IFlwCategoryService; +import org.springframework.stereotype.Service; + +/** + * 流程分类名称翻译实现 + * + * @author AprilWind + */ +@ConditionalOnEnable +@Slf4j +@RequiredArgsConstructor +@Service +@TranslationType(type = FlowConstant.CATEGORY_ID_TO_NAME) +public class CategoryNameTranslationImpl implements TranslationInterface { + + private final IFlwCategoryService flwCategoryService; + + @Override + public String translation(Object key, String other) { + return flwCategoryService.selectCategoryNameById(Convert.toLong(key)); + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwCategoryServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwCategoryServiceImpl.java new file mode 100644 index 0000000..cd29057 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwCategoryServiceImpl.java @@ -0,0 +1,258 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.tree.Tree; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.ObjectUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.TreeBuildUtils; +import org.dromara.common.mybatis.helper.DataBaseHelper; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.warm.flow.core.service.DefService; +import org.dromara.warm.flow.orm.entity.FlowDefinition; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.constant.FlowConstant; +import org.dromara.workflow.domain.FlowCategory; +import org.dromara.workflow.domain.bo.FlowCategoryBo; +import org.dromara.workflow.domain.vo.FlowCategoryVo; +import org.dromara.workflow.mapper.FlwCategoryMapper; +import org.dromara.workflow.service.IFlwCategoryService; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * 流程分类Service业务层处理 + * + * @author may + */ +@ConditionalOnEnable +@RequiredArgsConstructor +@Service +public class FlwCategoryServiceImpl implements IFlwCategoryService { + + private final DefService defService; + private final FlwCategoryMapper baseMapper; + + /** + * 查询流程分类 + * + * @param categoryId 主键 + * @return 流程分类 + */ + @Override + public FlowCategoryVo queryById(Long categoryId) { + return baseMapper.selectVoById(categoryId); + } + + /** + * 根据流程分类ID查询流程分类名称 + * + * @param categoryId 流程分类ID + * @return 流程分类名称 + */ + @Cacheable(cacheNames = FlowConstant.FLOW_CATEGORY_NAME, key = "#categoryId") + @Override + public String selectCategoryNameById(Long categoryId) { + if (ObjectUtil.isNull(categoryId)) { + return null; + } + FlowCategory category = baseMapper.selectOne(new LambdaQueryWrapper() + .select(FlowCategory::getCategoryName).eq(FlowCategory::getCategoryId, categoryId)); + return ObjectUtils.notNullGetter(category, FlowCategory::getCategoryName); + } + + /** + * 查询符合条件的流程分类列表 + * + * @param bo 查询条件 + * @return 流程分类列表 + */ + @Override + public List queryList(FlowCategoryBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + /** + * 查询流程分类树结构信息 + * + * @param category 流程分类信息 + * @return 流程分类树信息集合 + */ + @Override + public List> selectCategoryTreeList(FlowCategoryBo category) { + List categoryList = this.queryList(category); + if (CollUtil.isEmpty(categoryList)) { + return CollUtil.newArrayList(); + } + return TreeBuildUtils.buildMultiRoot( + categoryList, + node -> String.valueOf(node.getCategoryId()), + node -> String.valueOf(node.getParentId()), + (node, treeNode) -> treeNode + .setId(String.valueOf(node.getCategoryId())) + .setParentId(String.valueOf(node.getParentId())) + .setName(node.getCategoryName()) + .setWeight(node.getOrderNum()) + ); + } + + /** + * 校验流程分类是否有数据权限 + * + * @param categoryId 流程分类ID + */ + @Override + public void checkCategoryDataScope(Long categoryId) { + if (ObjectUtil.isNull(categoryId)) { + return; + } + if (LoginHelper.isSuperAdmin()) { + return; + } + if (baseMapper.countCategoryById(categoryId) == 0) { + throw new ServiceException("没有权限访问流程分类数据!"); + } + } + + /** + * 校验流程分类名称是否唯一 + * + * @param category 流程分类信息 + * @return 结果 + */ + @Override + public boolean checkCategoryNameUnique(FlowCategoryBo category) { + boolean exist = baseMapper.exists(new LambdaQueryWrapper() + .eq(FlowCategory::getCategoryName, category.getCategoryName()) + .eq(FlowCategory::getParentId, category.getParentId()) + .ne(ObjectUtil.isNotNull(category.getCategoryId()), FlowCategory::getCategoryId, category.getCategoryId())); + return !exist; + } + + /** + * 查询流程分类是否存在流程定义 + * + * @param categoryId 流程分类ID + * @return 结果 true 存在 false 不存在 + */ + @Override + public boolean checkCategoryExistDefinition(Long categoryId) { + FlowDefinition definition = new FlowDefinition(); + definition.setCategory(categoryId.toString()); + return defService.exists(definition); + } + + /** + * 是否存在流程分类子节点 + * + * @param categoryId 流程分类ID + * @return 结果 + */ + @Override + public boolean hasChildByCategoryId(Long categoryId) { + return baseMapper.exists(new LambdaQueryWrapper() + .eq(FlowCategory::getParentId, categoryId)); + } + + private LambdaQueryWrapper buildQueryWrapper(FlowCategoryBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(FlowCategory::getDelFlag, SystemConstants.NORMAL); + lqw.eq(ObjectUtil.isNotNull(bo.getCategoryId()), FlowCategory::getCategoryId, bo.getCategoryId()); + lqw.eq(ObjectUtil.isNotNull(bo.getParentId()), FlowCategory::getParentId, bo.getParentId()); + lqw.like(StringUtils.isNotBlank(bo.getCategoryName()), FlowCategory::getCategoryName, bo.getCategoryName()); + lqw.orderByAsc(FlowCategory::getAncestors); + lqw.orderByAsc(FlowCategory::getParentId); + lqw.orderByAsc(FlowCategory::getOrderNum); + lqw.orderByAsc(FlowCategory::getCategoryId); + return lqw; + } + + /** + * 新增流程分类 + * + * @param bo 流程分类 + * @return 是否新增成功 + */ + @Override + public int insertByBo(FlowCategoryBo bo) { + FlowCategory info = baseMapper.selectById(bo.getParentId()); + FlowCategory category = MapstructUtils.convert(bo, FlowCategory.class); + category.setAncestors(info.getAncestors() + StringUtils.SEPARATOR + category.getParentId()); + return baseMapper.insert(category); + } + + /** + * 修改流程分类 + * + * @param bo 流程分类 + * @return 是否修改成功 + */ + @CacheEvict(cacheNames = FlowConstant.FLOW_CATEGORY_NAME, key = "#bo.categoryId") + @Override + public int updateByBo(FlowCategoryBo bo) { + FlowCategory category = MapstructUtils.convert(bo, FlowCategory.class); + FlowCategory oldCategory = baseMapper.selectById(category.getCategoryId()); + if (ObjectUtil.isNull(oldCategory)) { + throw new ServiceException("流程分类不存在,无法修改"); + } + if (!oldCategory.getParentId().equals(category.getParentId())) { + // 如果是新父流程分类 则校验是否具有新父流程分类权限 避免越权 + this.checkCategoryDataScope(category.getParentId()); + FlowCategory newParentCategory = baseMapper.selectById(category.getParentId()); + if (ObjectUtil.isNotNull(newParentCategory)) { + String newAncestors = newParentCategory.getAncestors() + StringUtils.SEPARATOR + newParentCategory.getCategoryId(); + String oldAncestors = oldCategory.getAncestors(); + category.setAncestors(newAncestors); + updateCategoryChildren(category.getCategoryId(), newAncestors, oldAncestors); + } + } else { + category.setAncestors(oldCategory.getAncestors()); + } + return baseMapper.updateById(category); + } + + /** + * 修改子元素关系 + * + * @param categoryId 被修改的流程分类ID + * @param newAncestors 新的父ID集合 + * @param oldAncestors 旧的父ID集合 + */ + private void updateCategoryChildren(Long categoryId, String newAncestors, String oldAncestors) { + List children = baseMapper.selectList(new LambdaQueryWrapper() + .apply(DataBaseHelper.findInSet(categoryId, "ancestors"))); + List list = new ArrayList<>(); + for (FlowCategory child : children) { + FlowCategory category = new FlowCategory(); + category.setCategoryId(child.getCategoryId()); + category.setAncestors(child.getAncestors().replaceFirst(oldAncestors, newAncestors)); + list.add(category); + } + if (CollUtil.isNotEmpty(list)) { + baseMapper.updateBatchById(list); + } + } + + /** + * 删除流程分类信息 + * + * @param categoryId 主键 + * @return 是否删除成功 + */ + @CacheEvict(cacheNames = FlowConstant.FLOW_CATEGORY_NAME, key = "#categoryId") + @Override + public int deleteWithValidById(Long categoryId) { + return baseMapper.deleteById(categoryId); + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwChartExtServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwChartExtServiceImpl.java new file mode 100644 index 0000000..b552bd9 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwChartExtServiceImpl.java @@ -0,0 +1,247 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.dto.UserDTO; +import org.dromara.common.core.service.DeptService; +import org.dromara.common.core.service.DictService; +import org.dromara.common.core.service.UserService; +import org.dromara.common.core.utils.DateUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.warm.flow.core.dto.DefJson; +import org.dromara.warm.flow.core.dto.NodeJson; +import org.dromara.warm.flow.core.dto.PromptContent; +import org.dromara.warm.flow.core.enums.NodeType; +import org.dromara.warm.flow.core.utils.MapUtil; +import org.dromara.warm.flow.orm.entity.FlowHisTask; +import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper; +import org.dromara.warm.flow.ui.service.ChartExtService; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.constant.FlowConstant; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * 流程图提示信息 + * + * @author AprilWind + */ +@ConditionalOnEnable +@Slf4j +@RequiredArgsConstructor +@Service +public class FlwChartExtServiceImpl implements ChartExtService { + + private final UserService userService; + private final DeptService deptService; + private final FlowHisTaskMapper flowHisTaskMapper; + private final DictService dictService; + + /** + * 设置流程图提示信息 + * + * @param defJson 流程定义json对象 + */ + @Override + public void execute(DefJson defJson) { + // 临时修复 后续版本将通过defjson获取流程实例ID + String[] parts = ServletUtils.getRequest().getRequestURI().split("/"); + Long instanceId = Convert.toLong(parts[parts.length - 1]); + + // 根据流程实例ID查询所有相关的历史任务列表 + List flowHisTasks = this.getHisTaskGroupedByNode(instanceId); + if (CollUtil.isEmpty(flowHisTasks)) { + return; + } + + // 按节点编号(nodeCode)对历史任务进行分组 + Map> groupedByNode = StreamUtils.groupByKey(flowHisTasks, FlowHisTask::getNodeCode); + + // 批量查询所有审批人的用户信息 + List userDTOList = userService.selectListByIds(StreamUtils.toList(flowHisTasks, e -> Convert.toLong(e.getApprover()))); + + // 将查询到的用户列表转换为以用户ID为key的映射 + Map userMap = StreamUtils.toIdentityMap(userDTOList, UserDTO::getUserId); + + Map dictType = dictService.getAllDictByDictType(FlowConstant.WF_TASK_STATUS); + + // 遍历流程定义中的每个节点,调用处理方法,将对应节点的任务列表及用户信息传入,生成扩展提示内容 + for (NodeJson nodeJson : defJson.getNodeList()) { + // 获取当前节点对应的历史任务列表,如果没有则返回空列表避免空指针 + List taskList = groupedByNode.get(nodeJson.getNodeCode()); + if (CollUtil.isEmpty(taskList)) { + continue; + } + // 处理当前节点的扩展信息,包括构建审批人提示内容等 + this.processNodeExtInfo(nodeJson, taskList, userMap, dictType); + } + } + + /** + * 初始化流程图提示信息 + * + * @param defJson 流程定义json对象 + */ + @Override + public void initPromptContent(DefJson defJson) { + defJson.setTopText("流程名称: " + defJson.getFlowName()); + defJson.getNodeList().forEach(nodeJson -> { + nodeJson.setPromptContent( + new PromptContent() + // 提示信息 + .setInfo( + CollUtil.newArrayList( + new PromptContent.InfoItem() + .setPrefix("任务名称: ") + .setContent(nodeJson.getNodeName()) + .setContentStyle(Map.of( + "border", "1px solid #d1e9ff", + "backgroundColor", "#e8f4ff", + "padding", "4px 8px", + "borderRadius", "4px" + )) + .setRowStyle(Map.of( + "fontWeight", "bold", + "margin", "0 0 6px 0", + "padding", "0 0 8px 0", + "borderBottom", "1px solid #ccc" + )) + ) + ) + // 弹窗样式 + .setDialogStyle(MapUtil.mergeAll( + "position", "absolute", + "backgroundColor", "#fff", + "border", "1px solid #ccc", + "borderRadius", "4px", + "boxShadow", "0 2px 8px rgba(0, 0, 0, 0.15)", + "padding", "8px 12px", + "fontSize", "14px", + "zIndex", "1000", + "maxWidth", "500px", + "overflowY", "visible", + "overflowX", "hidden", + "color", "#333", + "pointerEvents", "auto", + "scrollbarWidth", "thin" + )) + ); + }); + } + + /** + * 处理节点的扩展信息,构建用于流程图悬浮提示的内容 + * + * @param nodeJson 当前节点对象 + * @param taskList 当前节点对应的历史审批任务列表 + */ + private void processNodeExtInfo(NodeJson nodeJson, List taskList, Map userMap, Map dictType) { + + // 获取节点提示内容对象中的 info 列表,用于追加提示项 + List info = nodeJson.getPromptContent().getInfo(); + + // 遍历所有任务记录,构建提示内容 + for (FlowHisTask task : taskList) { + UserDTO userDTO = userMap.get(Convert.toLong(task.getApprover())); + if (ObjectUtil.isEmpty(userDTO)) { + continue; + } + + // 查询用户所属部门名称 + String deptName = deptService.selectDeptNameByIds(Convert.toStr(userDTO.getDeptId())); + + // 添加标题项,如:👤 张三(市场部) + info.add(new PromptContent.InfoItem() + .setPrefix(StringUtils.format("👥 {}({})", userDTO.getNickName(), deptName)) + .setPrefixStyle(Map.of( + "fontWeight", "bold", + "fontSize", "15px", + "color", "#333" + )) + .setRowStyle(Map.of( + "margin", "8px 0", + "borderBottom", "1px dashed #ccc" + )) + ); + + // 添加具体信息项:账号、耗时、时间 + info.add(buildInfoItem("用户账号", userDTO.getUserName())); + info.add(buildInfoItem("审批状态", dictType.get(task.getFlowStatus()))); + info.add(buildInfoItem("审批耗时", DateUtils.getTimeDifference(task.getUpdateTime(), task.getCreateTime()))); + info.add(buildInfoItem("办理时间", DateUtils.formatDateTime(task.getUpdateTime()))); + } + } + + /** + * 构建单条提示内容对象 InfoItem,用于悬浮窗显示(key: value) + * + * @param key 字段名(作为前缀) + * @param value 字段值 + * @return 提示项对象 + */ + private PromptContent.InfoItem buildInfoItem(String key, String value) { + return new PromptContent.InfoItem() + // 前缀 + .setPrefix(key + ": ") + // 前缀样式 + .setPrefixStyle(Map.of( + "textAlign", "right", + "color", "#444", + "userSelect", "none", + "display", "inline-block", + "width", "100px", + "paddingRight", "8px", + "fontWeight", "500", + "fontSize", "14px", + "lineHeight", "24px", + "verticalAlign", "middle" + )) + // 内容 + .setContent(value) + // 内容样式 + .setContentStyle(Map.of( + "backgroundColor", "#f7faff", + "color", "#005cbf", + "padding", "4px 8px", + "fontSize", "14px", + "borderRadius", "4px", + "whiteSpace", "normal", + "border", "1px solid #d0e5ff", + "userSelect", "text", + "lineHeight", "20px" + )) + // 行样式 + .setRowStyle(Map.of( + "color", "#222", + "alignItems", "center", + "display", "flex", + "marginBottom", "6px", + "fontWeight", "400", + "fontSize", "14px" + )); + } + + /** + * 根据流程实例ID获取历史任务列表 + * + * @param instanceId 流程实例ID + * @return 历史任务列表 + */ + public List getHisTaskGroupedByNode(Long instanceId) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.eq(FlowHisTask::getInstanceId, instanceId) + .eq(FlowHisTask::getNodeType, NodeType.BETWEEN.getKey()) + .orderByDesc(FlowHisTask::getCreateTime, FlowHisTask::getUpdateTime); + return flowHisTaskMapper.selectList(wrapper); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwCommonServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwCommonServiceImpl.java new file mode 100644 index 0000000..4c77a59 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwCommonServiceImpl.java @@ -0,0 +1,122 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.dto.UserDTO; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mail.utils.MailUtils; +import org.dromara.common.sse.dto.SseMessageDto; +import org.dromara.common.sse.utils.SseMessageUtils; +import org.dromara.warm.flow.core.entity.Node; +import org.dromara.warm.flow.core.entity.Task; +import org.dromara.warm.flow.core.enums.SkipType; +import org.dromara.warm.flow.core.service.NodeService; +import org.dromara.warm.flow.orm.entity.FlowTask; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.enums.MessageTypeEnum; +import org.dromara.workflow.service.IFlwCommonService; +import org.dromara.workflow.service.IFlwTaskAssigneeService; +import org.dromara.workflow.service.IFlwTaskService; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + + +/** + * 工作流工具 + * + * @author LionLi + */ +@ConditionalOnEnable +@Slf4j +@RequiredArgsConstructor +@Service +public class FlwCommonServiceImpl implements IFlwCommonService { + private final NodeService nodeService; + + /** + * 构建工作流用户 + * + * @param permissionList 办理用户 + * @return 用户 + */ + @Override + public List buildUser(List permissionList) { + if (CollUtil.isEmpty(permissionList)) { + return List.of(); + } + IFlwTaskAssigneeService taskAssigneeService = SpringUtils.getBean(IFlwTaskAssigneeService.class); + String processedBys = CollUtil.join(permissionList, StringUtils.SEPARATOR); + // 根据 processedBy 前缀判断处理人类型,分别获取用户列表 + List users = taskAssigneeService.fetchUsersByStorageIds(processedBys); + + return StreamUtils.toList(users, userDTO -> String.valueOf(userDTO.getUserId())); + } + + + /** + * 发送消息 + * + * @param flowName 流程定义名称 + * @param messageType 消息类型 + * @param message 消息内容,为空则发送默认配置的消息内容 + */ + @Override + public void sendMessage(String flowName, Long instId, List messageType, String message) { + IFlwTaskService flwTaskService = SpringUtils.getBean(IFlwTaskService.class); + List userList = new ArrayList<>(); + List list = flwTaskService.selectByInstId(instId); + if (StringUtils.isBlank(message)) { + message = "有新的【" + flowName + "】单据已经提交至您,请您及时处理。"; + } + for (Task task : list) { + List users = flwTaskService.currentTaskAllUser(task.getId()); + if (CollUtil.isNotEmpty(users)) { + userList.addAll(users); + } + } + if (CollUtil.isNotEmpty(userList)) { + for (String code : messageType) { + MessageTypeEnum messageTypeEnum = MessageTypeEnum.getByCode(code); + if (ObjectUtil.isNotEmpty(messageTypeEnum)) { + switch (messageTypeEnum) { + case SYSTEM_MESSAGE: + SseMessageDto dto = new SseMessageDto(); + dto.setUserIds(StreamUtils.toList(userList, UserDTO::getUserId).stream().distinct().collect(Collectors.toList())); + dto.setMessage(message); + SseMessageUtils.publishMessage(dto); + break; + case EMAIL_MESSAGE: + MailUtils.sendText(StreamUtils.join(userList, UserDTO::getEmail), "单据审批提醒", message); + break; + case SMS_MESSAGE: + //todo 短信发送 + break; + default: + throw new IllegalStateException("Unexpected value: " + messageTypeEnum); + } + } + } + } + } + + + /** + * 申请人节点编码 + * + * @param definitionId 流程定义id + * @return 申请人节点编码 + */ + @Override + public String applyNodeCode(Long definitionId) { + Node startNode = nodeService.getStartNode(definitionId); + Node nextNode = nodeService.getNextNode(definitionId, startNode.getNodeCode(), null, SkipType.PASS.getKey()); + return nextNode.getNodeCode(); + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwDefinitionServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwDefinitionServiceImpl.java new file mode 100644 index 0000000..a590345 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwDefinitionServiceImpl.java @@ -0,0 +1,198 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.io.IoUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.warm.flow.core.dto.DefJson; +import org.dromara.warm.flow.core.enums.NodeType; +import org.dromara.warm.flow.core.enums.PublishStatus; +import org.dromara.warm.flow.core.service.DefService; +import org.dromara.warm.flow.orm.entity.FlowDefinition; +import org.dromara.warm.flow.orm.entity.FlowHisTask; +import org.dromara.warm.flow.orm.entity.FlowNode; +import org.dromara.warm.flow.orm.mapper.FlowDefinitionMapper; +import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper; +import org.dromara.warm.flow.orm.mapper.FlowNodeMapper; +import org.dromara.warm.flow.orm.mapper.FlowSkipMapper; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.domain.vo.FlowDefinitionVo; +import org.dromara.workflow.mapper.FlwCategoryMapper; +import org.dromara.workflow.service.IFlwCommonService; +import org.dromara.workflow.service.IFlwDefinitionService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * 流程定义 服务层实现 + * + * @author may + */ +@ConditionalOnEnable +@Slf4j +@RequiredArgsConstructor +@Service +public class FlwDefinitionServiceImpl implements IFlwDefinitionService { + + private final DefService defService; + private final FlowDefinitionMapper flowDefinitionMapper; + private final FlowHisTaskMapper flowHisTaskMapper; + private final FlowNodeMapper flowNodeMapper; + private final FlowSkipMapper flowSkipMapper; + private final FlwCategoryMapper flwCategoryMapper; + private final IFlwCommonService flwCommonService; + + /** + * 查询流程定义列表 + * + * @param flowDefinition 流程定义信息 + * @param pageQuery 分页 + * @return 返回分页列表 + */ + @Override + public TableDataInfo queryList(FlowDefinition flowDefinition, PageQuery pageQuery) { + LambdaQueryWrapper wrapper = buildQueryWrapper(flowDefinition); + wrapper.eq(FlowDefinition::getIsPublish, PublishStatus.PUBLISHED.getKey()); + Page page = flowDefinitionMapper.selectPage(pageQuery.build(), wrapper); + List list = BeanUtil.copyToList(page.getRecords(), FlowDefinitionVo.class); + return new TableDataInfo<>(list, page.getTotal()); + } + + /** + * 查询未发布的流程定义列表 + * + * @param flowDefinition 流程定义信息 + * @param pageQuery 分页 + * @return 返回分页列表 + */ + @Override + public TableDataInfo unPublishList(FlowDefinition flowDefinition, PageQuery pageQuery) { + LambdaQueryWrapper wrapper = buildQueryWrapper(flowDefinition); + wrapper.in(FlowDefinition::getIsPublish, Arrays.asList(PublishStatus.UNPUBLISHED.getKey(), PublishStatus.EXPIRED.getKey())); + Page page = flowDefinitionMapper.selectPage(pageQuery.build(), wrapper); + List list = BeanUtil.copyToList(page.getRecords(), FlowDefinitionVo.class); + return new TableDataInfo<>(list, page.getTotal()); + } + + private LambdaQueryWrapper buildQueryWrapper(FlowDefinition flowDefinition) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.like(StringUtils.isNotBlank(flowDefinition.getFlowCode()), FlowDefinition::getFlowCode, flowDefinition.getFlowCode()); + wrapper.like(StringUtils.isNotBlank(flowDefinition.getFlowName()), FlowDefinition::getFlowName, flowDefinition.getFlowName()); + if (StringUtils.isNotBlank(flowDefinition.getCategory())) { + List categoryIds = flwCategoryMapper.selectCategoryIdsByParentId(Convert.toLong(flowDefinition.getCategory())); + wrapper.in(FlowDefinition::getCategory, StreamUtils.toList(categoryIds, Convert::toStr)); + } + wrapper.orderByDesc(FlowDefinition::getCreateTime); + return wrapper; + } + + /** + * 发布流程定义 + * + * @param id 流程定义id + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean publish(Long id) { + List flowNodes = flowNodeMapper.selectList(new LambdaQueryWrapper().eq(FlowNode::getDefinitionId, id)); + List errorMsg = new ArrayList<>(); + if (CollUtil.isNotEmpty(flowNodes)) { + for (FlowNode flowNode : flowNodes) { + String applyNodeCode = flwCommonService.applyNodeCode(id); + if (StringUtils.isBlank(flowNode.getPermissionFlag()) && !applyNodeCode.equals(flowNode.getNodeCode()) && NodeType.BETWEEN.getKey().equals(flowNode.getNodeType())) { + errorMsg.add(flowNode.getNodeName()); + } + } + if (CollUtil.isNotEmpty(errorMsg)) { + throw new ServiceException("节点【" + StringUtils.join(errorMsg, ",") + "】未配置办理人!"); + } + } + return defService.publish(id); + } + + /** + * 导入流程定义 + * + * @param file 文件 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean importJson(MultipartFile file, String category) { + try { + DefJson defJson = JsonUtils.parseObject(file.getBytes(), DefJson.class); + defJson.setCategory(category); + defService.importDef(defJson); + } catch (IOException e) { + log.error("读取文件流错误: {}", e.getMessage(), e); + throw new IllegalStateException("文件读取失败,请检查文件内容", e); + } + return true; + } + + /** + * 导出流程定义 + * + * @param id 流程定义id + * @param response 响应 + * @throws IOException 异常 + */ + @Override + public void exportDef(Long id, HttpServletResponse response) throws IOException { + byte[] data = defService.exportJson(id).getBytes(StandardCharsets.UTF_8); + // 设置响应头和内容类型 + response.reset(); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/text"); + response.setHeader("Content-Disposition", "attachment;"); + response.addHeader("Content-Length", "" + data.length); + IoUtil.write(response.getOutputStream(), false, data); + } + + /** + * 删除流程定义 + * + * @param ids 流程定义id + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeDef(List ids) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.in(FlowHisTask::getDefinitionId, ids); + List flowHisTasks = flowHisTaskMapper.selectList(wrapper); + if (CollUtil.isNotEmpty(flowHisTasks)) { + List flowDefinitions = flowDefinitionMapper.selectByIds(StreamUtils.toList(flowHisTasks, FlowHisTask::getDefinitionId)); + if (CollUtil.isNotEmpty(flowDefinitions)) { + String join = StreamUtils.join(flowDefinitions, FlowDefinition::getFlowCode); + log.info("流程定义【{}】已被使用不可被删除!", join); + throw new ServiceException("流程定义【" + join + "】已被使用不可被删除!"); + } + } + try { + defService.removeDef(ids); + } catch (Exception e) { + log.error("Error removing flow definitions: {}", e.getMessage(), e); + throw new RuntimeException("Failed to remove flow definitions", e); + } + return true; + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwInstanceServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwInstanceServiceImpl.java new file mode 100644 index 0000000..f5d9549 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwInstanceServiceImpl.java @@ -0,0 +1,430 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.dto.UserDTO; +import org.dromara.common.core.enums.BusinessStatusEnum; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.warm.flow.core.constant.ExceptionCons; +import org.dromara.warm.flow.core.dto.FlowParams; +import org.dromara.warm.flow.core.entity.Definition; +import org.dromara.warm.flow.core.entity.Instance; +import org.dromara.warm.flow.core.entity.Task; +import org.dromara.warm.flow.core.enums.NodeType; +import org.dromara.warm.flow.core.service.DefService; +import org.dromara.warm.flow.core.service.InsService; +import org.dromara.warm.flow.core.service.TaskService; +import org.dromara.warm.flow.orm.entity.FlowHisTask; +import org.dromara.warm.flow.orm.entity.FlowInstance; +import org.dromara.warm.flow.orm.entity.FlowTask; +import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper; +import org.dromara.warm.flow.orm.mapper.FlowInstanceMapper; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.enums.TaskStatusEnum; +import org.dromara.workflow.domain.bo.FlowCancelBo; +import org.dromara.workflow.domain.bo.FlowInstanceBo; +import org.dromara.workflow.domain.bo.FlowInvalidBo; +import org.dromara.workflow.domain.vo.FlowHisTaskVo; +import org.dromara.workflow.domain.vo.FlowInstanceVo; +import org.dromara.workflow.handler.FlowProcessEventHandler; +import org.dromara.workflow.mapper.FlwCategoryMapper; +import org.dromara.workflow.mapper.FlwInstanceMapper; +import org.dromara.workflow.service.IFlwInstanceService; +import org.dromara.workflow.service.IFlwTaskService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 流程实例 服务层实现 + * + * @author may + */ +@ConditionalOnEnable +@Slf4j +@RequiredArgsConstructor +@Service +public class FlwInstanceServiceImpl implements IFlwInstanceService { + + private final InsService insService; + private final DefService defService; + private final TaskService taskService; + private final FlowHisTaskMapper flowHisTaskMapper; + private final FlowInstanceMapper flowInstanceMapper; + private final FlowProcessEventHandler flowProcessEventHandler; + private final IFlwTaskService flwTaskService; + private final FlwInstanceMapper flwInstanceMapper; + private final FlwCategoryMapper flwCategoryMapper; + + /** + * 分页查询正在运行的流程实例 + * + * @param flowInstanceBo 流程实例 + * @param pageQuery 分页 + */ + @Override + public TableDataInfo selectRunningInstanceList(FlowInstanceBo flowInstanceBo, PageQuery pageQuery) { + QueryWrapper queryWrapper = buildQueryWrapper(flowInstanceBo); + queryWrapper.in("fi.flow_status", BusinessStatusEnum.runningStatus()); + Page page = flwInstanceMapper.selectInstanceList(pageQuery.build(), queryWrapper); + return TableDataInfo.build(page); + } + + /** + * 分页查询已结束的流程实例 + * + * @param flowInstanceBo 流程实例 + * @param pageQuery 分页 + */ + @Override + public TableDataInfo selectFinishInstanceList(FlowInstanceBo flowInstanceBo, PageQuery pageQuery) { + QueryWrapper queryWrapper = buildQueryWrapper(flowInstanceBo); + queryWrapper.in("fi.flow_status", BusinessStatusEnum.finishStatus()); + Page page = flwInstanceMapper.selectInstanceList(pageQuery.build(), queryWrapper); + return TableDataInfo.build(page); + } + + /** + * 根据业务id查询流程实例详细信息 + * + * @param businessId 业务id + * @return 结果 + */ + @Override + public FlowInstanceVo queryByBusinessId(Long businessId) { + FlowInstance instance = this.selectInstByBusinessId(String.valueOf(businessId)); + FlowInstanceVo instanceVo = BeanUtil.toBean(instance, FlowInstanceVo.class); + Definition definition = defService.getById(instanceVo.getDefinitionId()); + instanceVo.setFlowName(definition.getFlowName()); + instanceVo.setFlowCode(definition.getFlowCode()); + instanceVo.setVersion(definition.getVersion()); + instanceVo.setFormCustom(definition.getFormCustom()); + instanceVo.setFormPath(definition.getFormPath()); + instanceVo.setCategory(definition.getCategory()); + return instanceVo; + } + + /** + * 通用查询条件 + * + * @param flowInstanceBo 查询条件 + * @return 查询条件构造方法 + */ + private QueryWrapper buildQueryWrapper(FlowInstanceBo flowInstanceBo) { + QueryWrapper queryWrapper = Wrappers.query(); + queryWrapper.like(StringUtils.isNotBlank(flowInstanceBo.getNodeName()), "fi.node_name", flowInstanceBo.getNodeName()); + queryWrapper.like(StringUtils.isNotBlank(flowInstanceBo.getFlowName()), "fd.flow_name", flowInstanceBo.getFlowName()); + queryWrapper.like(StringUtils.isNotBlank(flowInstanceBo.getFlowCode()), "fd.flow_code", flowInstanceBo.getFlowCode()); + if (StringUtils.isNotBlank(flowInstanceBo.getCategory())) { + List categoryIds = flwCategoryMapper.selectCategoryIdsByParentId(Convert.toLong(flowInstanceBo.getCategory())); + queryWrapper.in("fd.category", StreamUtils.toList(categoryIds, Convert::toStr)); + } + queryWrapper.eq(StringUtils.isNotBlank(flowInstanceBo.getBusinessId()), "fi.business_id", flowInstanceBo.getBusinessId()); + queryWrapper.in(CollUtil.isNotEmpty(flowInstanceBo.getCreateByIds()), "fi.create_by", flowInstanceBo.getCreateByIds()); + queryWrapper.eq("fi.del_flag", "0"); + queryWrapper.orderByDesc("fi.create_time"); + return queryWrapper; + } + + /** + * 根据业务id查询流程实例 + * + * @param businessId 业务id + */ + @Override + public FlowInstance selectInstByBusinessId(String businessId) { + return flowInstanceMapper.selectOne(new LambdaQueryWrapper().eq(FlowInstance::getBusinessId, businessId)); + } + + /** + * 按照实例id查询流程实例 + * + * @param instanceId 实例id + */ + @Override + public FlowInstance selectInstById(Long instanceId) { + return flowInstanceMapper.selectById(instanceId); + } + + /** + * 按照实例id查询流程实例 + * + * @param instanceIds 实例id + */ + @Override + public List selectInstListByIdList(List instanceIds) { + return flowInstanceMapper.selectByIds(instanceIds); + } + + /** + * 按照业务id删除流程实例 + * + * @param businessIds 业务id + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteByBusinessIds(List businessIds) { + List flowInstances = flowInstanceMapper.selectList(new LambdaQueryWrapper().in(FlowInstance::getBusinessId, StreamUtils.toList(businessIds, Convert::toStr))); + if (CollUtil.isEmpty(flowInstances)) { + log.warn("未找到对应的流程实例信息,无法执行删除操作。"); + return false; + } + return insService.remove(StreamUtils.toList(flowInstances, FlowInstance::getId)); + } + + /** + * 按照实例id删除流程实例 + * + * @param instanceIds 实例id + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteByInstanceIds(List instanceIds) { + // 获取实例信息 + List instances = insService.getByIds(instanceIds); + if (CollUtil.isEmpty(instances)) { + log.warn("未找到对应的流程实例信息,无法执行删除操作。"); + return false; + } + // 获取定义信息 + Map definitionMap = defService.getByIds( + StreamUtils.toList(instances, Instance::getDefinitionId) + ).stream().collect(Collectors.toMap(Definition::getId, definition -> definition)); + + // 逐一触发删除事件 + instances.forEach(instance -> { + Definition definition = definitionMap.get(instance.getDefinitionId()); + if (ObjectUtil.isNull(definition)) { + log.warn("实例 ID: {} 对应的流程定义信息未找到,跳过删除事件触发。", instance.getId()); + return; + } + flowProcessEventHandler.processDeleteHandler(definition.getFlowCode(), instance.getBusinessId()); + }); + + // 删除实例 + return insService.remove(instanceIds); + } + + /** + * 撤销流程 + * + * @param bo 参数 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean cancelProcessApply(FlowCancelBo bo) { + try { + Instance instance = selectInstByBusinessId(bo.getBusinessId()); + if (instance == null) { + throw new ServiceException(ExceptionCons.NOT_FOUNT_INSTANCE); + } + Definition definition = defService.getById(instance.getDefinitionId()); + if (definition == null) { + throw new ServiceException(ExceptionCons.NOT_FOUNT_DEF); + } + String message = bo.getMessage(); + String userIdStr = LoginHelper.getUserIdStr(); + BusinessStatusEnum.checkCancelStatus(instance.getFlowStatus()); + FlowParams flowParams = FlowParams.build() + .message(message) + .flowStatus(BusinessStatusEnum.CANCEL.getStatus()) + .hisStatus(BusinessStatusEnum.CANCEL.getStatus()) + .handler(userIdStr) + .ignore(true); + taskService.revoke(instance.getId(), flowParams); + } catch (Exception e) { + log.error("撤销失败: {}", e.getMessage(), e); + throw new ServiceException(e.getMessage()); + } + return true; + } + + /** + * 获取当前登陆人发起的流程实例 + * + * @param instanceBo 流程实例 + * @param pageQuery 分页 + */ + @Override + public TableDataInfo selectCurrentInstanceList(FlowInstanceBo instanceBo, PageQuery pageQuery) { + QueryWrapper queryWrapper = buildQueryWrapper(instanceBo); + queryWrapper.eq("fi.create_by", LoginHelper.getUserIdStr()); + Page page = flwInstanceMapper.selectInstanceList(pageQuery.build(), queryWrapper); + return TableDataInfo.build(page); + } + + /** + * 获取流程图,流程记录 + * + * @param businessId 业务id + */ + @Override + public Map flowHisTaskList(String businessId) { + FlowInstance flowInstance = this.selectInstByBusinessId(businessId); + if (ObjectUtil.isNull(flowInstance)) { + throw new ServiceException(ExceptionCons.NOT_FOUNT_INSTANCE); + } + Long instanceId = flowInstance.getId(); + //运行中的任务 + List list = new ArrayList<>(); + List flowTaskList = flwTaskService.selectByInstId(instanceId); + if (CollUtil.isNotEmpty(flowTaskList)) { + List flowHisTaskVos = BeanUtil.copyToList(flowTaskList, FlowHisTaskVo.class); + for (FlowHisTaskVo flowHisTaskVo : flowHisTaskVos) { + flowHisTaskVo.setFlowStatus(TaskStatusEnum.WAITING.getStatus()); + flowHisTaskVo.setUpdateTime(null); + flowHisTaskVo.setRunDuration(null); + List allUser = flwTaskService.currentTaskAllUser(flowHisTaskVo.getId()); + if (CollUtil.isNotEmpty(allUser)) { + String join = StreamUtils.join(allUser, e -> String.valueOf(e.getUserId())); + flowHisTaskVo.setApprover(join); + } + if (BusinessStatusEnum.isDraftOrCancelOrBack(flowInstance.getFlowStatus())) { + flowHisTaskVo.setApprover(LoginHelper.getUserIdStr()); + flowHisTaskVo.setApproveName(LoginHelper.getLoginUser().getNickname()); + } + } + list.addAll(flowHisTaskVos); + } + //历史任务 + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.eq(FlowHisTask::getInstanceId, instanceId) + .eq(FlowHisTask::getNodeType, NodeType.BETWEEN.getKey()) + .orderByDesc(FlowHisTask::getCreateTime, FlowHisTask::getUpdateTime); + List flowHisTasks = flowHisTaskMapper.selectList(wrapper); + if (CollUtil.isNotEmpty(flowHisTasks)) { + list.addAll(BeanUtil.copyToList(flowHisTasks, FlowHisTaskVo.class)); + } + return Map.of("list", list, "instanceId", instanceId); + } + + /** + * 按照实例id更新状态 + * + * @param instanceId 实例id + * @param status 状态 + */ + @Override + public void updateStatus(Long instanceId, String status) { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.set(FlowInstance::getFlowStatus, status); + wrapper.eq(FlowInstance::getId, instanceId); + flowInstanceMapper.update(wrapper); + } + + /** + * 获取流程变量 + * + * @param instanceId 实例id + */ + @Override + public Map instanceVariable(Long instanceId) { + FlowInstance flowInstance = flowInstanceMapper.selectById(instanceId); + Map variableMap = Optional.ofNullable(flowInstance.getVariableMap()).orElse(Collections.emptyMap()); + List> variableList = variableMap.entrySet().stream() + .map(entry -> Map.of("key", entry.getKey(), "value", entry.getValue())) + .toList(); + return Map.of("variableList", variableList, "variable", flowInstance.getVariable()); + } + + /** + * 设置流程变量 + * + * @param instanceId 实例id + * @param variable 流程变量 + */ + @Override + public void setVariable(Long instanceId, Map variable) { + Instance instance = insService.getById(instanceId); + if (instance != null) { + taskService.mergeVariable(instance, variable); + insService.updateById(instance); + } + } + + /** + * 按任务id查询实例 + * + * @param taskId 任务id + */ + @Override + public FlowInstance selectByTaskId(Long taskId) { + Task task = taskService.getById(taskId); + if (task == null) { + FlowHisTask flowHisTask = flwTaskService.selectHisTaskById(taskId); + if (flowHisTask != null) { + return this.selectInstById(flowHisTask.getInstanceId()); + } + } else { + return this.selectInstById(task.getInstanceId()); + } + return null; + } + + /** + * 按任务id查询实例 + * + * @param taskIdList 任务id + */ + @Override + public List selectByTaskIdList(List taskIdList) { + if (CollUtil.isEmpty(taskIdList)) { + return Collections.emptyList(); + } + Set instanceIds = new HashSet<>(); + List flowTaskList = flwTaskService.selectByIdList(taskIdList); + for (FlowTask flowTask : flowTaskList) { + instanceIds.add(flowTask.getInstanceId()); + } + List flowHisTaskList = flwTaskService.selectHisTaskByIdList(taskIdList); + for (FlowHisTask flowHisTask : flowHisTaskList) { + instanceIds.add(flowHisTask.getInstanceId()); + } + if (!instanceIds.isEmpty()) { + return this.selectInstListByIdList(new ArrayList<>(instanceIds)); + } + return Collections.emptyList(); + } + + /** + * 作废流程 + * + * @param bo 参数 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean processInvalid(FlowInvalidBo bo) { + try { + Instance instance = insService.getById(bo.getId()); + if (instance != null) { + BusinessStatusEnum.checkInvalidStatus(instance.getFlowStatus()); + } + FlowParams flowParams = FlowParams.build() + .message(bo.getComment()) + .flowStatus(BusinessStatusEnum.INVALID.getStatus()) + .hisStatus(TaskStatusEnum.INVALID.getStatus()) + .ignore(true); + taskService.terminationByInsId(bo.getId(), flowParams); + return true; + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new ServiceException(e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwNodeExtServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwNodeExtServiceImpl.java new file mode 100644 index 0000000..8b33590 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwNodeExtServiceImpl.java @@ -0,0 +1,243 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.dto.DictTypeDTO; +import org.dromara.common.core.service.DictService; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.warm.flow.ui.service.NodeExtService; +import org.dromara.warm.flow.ui.vo.NodeExt; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.enums.ButtonPermissionEnum; +import org.dromara.workflow.common.enums.NodeExtEnum; +import org.dromara.workflow.domain.vo.ButtonPermissionVo; +import org.dromara.workflow.service.IFlwNodeExtService; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 流程设计器-节点扩展属性 + * + * @author AprilWind + */ +@ConditionalOnEnable +@Slf4j +@RequiredArgsConstructor +@Service +public class FlwNodeExtServiceImpl implements NodeExtService, IFlwNodeExtService { + + /** + * 存储不同 dictType 对应的配置信息 + */ + private static final Map CHILD_NODE_MAP = new HashMap<>(); + + static { + CHILD_NODE_MAP.put(ButtonPermissionEnum.class.getSimpleName(), + new ButtonPermission("权限按钮", 4, false, true)); + } + + private final DictService dictService; + + /** + * 获取节点扩展属性 + * + * @return 节点扩展属性列表 + */ + @Override + public List getNodeExt() { + List nodeExtList = new ArrayList<>(); + // 构建按钮权限页面 + nodeExtList.add(buildNodeExt("wf_button_tab", "权限", 2, + List.of(ButtonPermissionEnum.class))); + // 自定义构建 规则参考 NodeExt 与 warm-flow文档说明 + // nodeExtList.add(buildNodeExt("xxx_xxx", "xxx", 1, List); + return nodeExtList; + } + + /** + * 构建一个 `NodeExt` 对象 + * + * @param code 唯一编码 + * @param name 名称(新页签时,作为页签名称) + * @param type 节点类型(1: 基础设置,2: 新页签) + * @param sources 数据来源(枚举类或字典类型) + * @return 构建的 `NodeExt` 对象 + */ + @SuppressWarnings("unchecked cast") + private NodeExt buildNodeExt(String code, String name, int type, List sources) { + NodeExt nodeExt = new NodeExt(); + nodeExt.setCode(code); + nodeExt.setType(type); + nodeExt.setName(name); + nodeExt.setChilds(sources.stream() + .map(source -> { + if (source instanceof Class clazz && NodeExtEnum.class.isAssignableFrom(clazz)) { + return buildChildNode((Class) clazz); + } else if (source instanceof String dictType) { + return buildChildNode(dictType); + } + return null; + }) + .filter(ObjectUtil::isNotNull) + .toList() + ); + return nodeExt; + } + + /** + * 根据枚举类型构建一个 `ChildNode` 对象 + * + * @param enumClass 枚举类,必须实现 `NodeExtEnum` 接口 + * @return 构建的 `ChildNode` 对象 + */ + private NodeExt.ChildNode buildChildNode(Class enumClass) { + if (!enumClass.isEnum()) { + return null; + } + String simpleName = enumClass.getSimpleName(); + NodeExt.ChildNode childNode = buildChildNodeMap(simpleName); + // 编码,此json中唯 + childNode.setCode(simpleName); + // 字典,下拉框和复选框时用到 + childNode.setDict(Arrays.stream(enumClass.getEnumConstants()) + .map(NodeExtEnum.class::cast) + .map(x -> + new NodeExt.DictItem(x.getLabel(), x.getValue(), x.isSelected()) + ).toList()); + return childNode; + } + + /** + * 根据字典类型构建 `ChildNode` 对象 + * + * @param dictType 字典类型 + * @return 构建的 `ChildNode` 对象 + */ + private NodeExt.ChildNode buildChildNode(String dictType) { + DictTypeDTO dictTypeDTO = dictService.getDictType(dictType); + if (ObjectUtil.isNull(dictTypeDTO)) { + return null; + } + NodeExt.ChildNode childNode = buildChildNodeMap(dictType); + // 编码,此json中唯一 + childNode.setCode(dictType); + // label名称 + childNode.setLabel(dictTypeDTO.getDictName()); + // 描述 + childNode.setDesc(dictTypeDTO.getRemark()); + // 字典,下拉框和复选框时用到 + childNode.setDict(dictService.getDictData(dictType) + .stream().map(x -> + new NodeExt.DictItem(x.getDictLabel(), x.getDictValue(), Convert.toBool(x.getIsDefault(), false)) + ).toList()); + return childNode; + } + + /** + * 根据 CHILD_NODE_MAP 中的配置信息,构建一个基本的 ChildNode 对象 + * 该方法用于设置 ChildNode 的常规属性,例如 label、type、是否必填、是否多选等 + * + * @param key CHILD_NODE_MAP 的 key + * @return 返回构建好的 ChildNode 对象 + */ + private NodeExt.ChildNode buildChildNodeMap(String key) { + NodeExt.ChildNode childNode = new NodeExt.ChildNode(); + ButtonPermission bp = CHILD_NODE_MAP.get(key); + if (bp == null) { + childNode.setType(1); + childNode.setMust(false); + childNode.setMultiple(true); + return childNode; + } + // label名称 + childNode.setLabel(bp.label()); + // 1:输入框 2:输入框 3:下拉框 4:选择框 + childNode.setType(bp.type()); + // 是否必填 + childNode.setMust(bp.must()); + // 是否多选 + childNode.setMultiple(bp.multiple()); + return childNode; + } + + /** + * 从扩展属性构建按钮权限列表:根据 ext 中记录的权限值,标记每个按钮是否勾选 + * + * @param ext 扩展属性 JSON 字符串 + * @return 按钮权限 VO 列表 + */ + @Override + public List buildButtonPermissionsFromExt(String ext) { + // 解析 ext 为 Map>,用于标记权限 + Map> permissionMap = JsonUtils.parseArray(ext, ButtonPermissionVo.class) + .stream() + .collect(Collectors.toMap( + ButtonPermissionVo::getCode, + item -> StringUtils.splitList(item.getValue()).stream() + .map(String::trim) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toSet()), + (a, b) -> b, + HashMap::new + )); + + // 构建按钮权限列表,标记哪些按钮在 permissionMap 中出现(表示已勾选) + return buildPermissionsFromSources(permissionMap, List.of(ButtonPermissionEnum.class)); + } + + /** + * 将权限映射与按钮权限来源(枚举类或字典类型)进行匹配,生成权限视图列表 + *

+ * 使用说明: + * - sources 支持传入多个来源类型,支持 NodeExtEnum 枚举类 或 字典类型字符串(dictType) + * - 若需要扩展更多按钮权限,只需在 sources 中新增对应的枚举类或字典类型 + *

+ * 示例: + * buildPermissionsFromSources(permissionMap, List.of(ButtonPermissionEnum.class, "custom_button_dict")); + * + * @param permissionMap 权限映射 + * @param sources 枚举类或字典类型列表 + * @return 按钮权限视图对象列表 + */ + @SuppressWarnings("unchecked cast") + private List buildPermissionsFromSources(Map> permissionMap, List sources) { + return sources.stream() + .flatMap(source -> { + if (source instanceof Class clazz && NodeExtEnum.class.isAssignableFrom(clazz)) { + Set selectedSet = permissionMap.getOrDefault(clazz.getSimpleName(), Collections.emptySet()); + return extractDictItems(this.buildChildNode((Class) clazz), selectedSet).stream(); + } else if (source instanceof String dictType) { + Set selectedSet = permissionMap.getOrDefault(dictType, Collections.emptySet()); + return extractDictItems(this.buildChildNode(dictType), selectedSet).stream(); + } + return Stream.empty(); + }).toList(); + } + + /** + * 从节点子项中提取字典项,并构建按钮权限视图对象列表 + * + * @param childNode 子节点 + * @param selectedSet 已选中的值集 + * @return 按钮权限视图对象列表 + */ + private List extractDictItems(NodeExt.ChildNode childNode, Set selectedSet) { + return Optional.ofNullable(childNode) + .map(NodeExt.ChildNode::getDict) + .orElse(List.of()) + .stream() + .map(dict -> new ButtonPermissionVo(dict.getValue(), selectedSet.contains(dict.getValue()))) + .toList(); + } + + record ButtonPermission(String label, Integer type, Boolean must, Boolean multiple) { + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskAssigneeServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskAssigneeServiceImpl.java new file mode 100644 index 0000000..85d1907 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskAssigneeServiceImpl.java @@ -0,0 +1,254 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.Pair; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.dto.DeptDTO; +import org.dromara.common.core.domain.dto.TaskAssigneeDTO; +import org.dromara.common.core.domain.dto.UserDTO; +import org.dromara.common.core.domain.model.TaskAssigneeBody; +import org.dromara.common.core.enums.FormatsType; +import org.dromara.common.core.service.DeptService; +import org.dromara.common.core.service.TaskAssigneeService; +import org.dromara.common.core.service.UserService; +import org.dromara.common.core.utils.DateUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.warm.flow.ui.dto.HandlerFunDto; +import org.dromara.warm.flow.ui.dto.HandlerQuery; +import org.dromara.warm.flow.ui.dto.TreeFunDto; +import org.dromara.warm.flow.ui.service.HandlerSelectService; +import org.dromara.warm.flow.ui.vo.HandlerFeedBackVo; +import org.dromara.warm.flow.ui.vo.HandlerSelectVo; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.enums.TaskAssigneeEnum; +import org.dromara.workflow.service.IFlwTaskAssigneeService; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * 流程设计器-获取办理人权限设置列表 + * + * @author AprilWind + */ +@ConditionalOnEnable +@Slf4j +@RequiredArgsConstructor +@Service +public class FlwTaskAssigneeServiceImpl implements IFlwTaskAssigneeService, HandlerSelectService { + + private static final String DEFAULT_GROUP_NAME = "默认分组"; + private final TaskAssigneeService taskAssigneeService; + private final UserService userService; + private final DeptService deptService; + + /** + * 获取办理人权限设置列表tabs页签 + * + * @return tabs页签 + */ + @Override + public List getHandlerType() { + return TaskAssigneeEnum.getAssigneeTypeList(); + } + + /** + * 获取办理列表, 同时构建左侧部门树状结构 + * + * @param query 查询条件 + * @return HandlerSelectVo + */ + @Override + public HandlerSelectVo getHandlerSelect(HandlerQuery query) { + // 获取任务办理类型 + TaskAssigneeEnum type = TaskAssigneeEnum.fromDesc(query.getHandlerType()); + // 转换查询条件为 TaskAssigneeBody + TaskAssigneeBody taskQuery = BeanUtil.toBean(query, TaskAssigneeBody.class); + + // 统一查询并构建业务数据 + TaskAssigneeDTO dto = fetchTaskAssigneeData(type, taskQuery); + List depts = fetchDeptData(type); + + return getHandlerSelectVo(buildHandlerData(dto, type), buildDeptTree(depts)); + } + + /** + * 办理人权限名称回显 + * + * @param storageIds 入库主键集合 + * @return 结果 + */ + @Override + public List handlerFeedback(List storageIds) { + if (CollUtil.isEmpty(storageIds)) { + return Collections.emptyList(); + } + // 解析并归类 ID,同时记录原始顺序和对应解析结果 + Map> typeIdMap = new EnumMap<>(TaskAssigneeEnum.class); + Map> parsedMap = new LinkedHashMap<>(); + for (String storageId : storageIds) { + Pair parsed = this.parseStorageId(storageId); + parsedMap.put(storageId, parsed); + if (parsed != null) { + typeIdMap.computeIfAbsent(parsed.getKey(), k -> new ArrayList<>()).add(parsed.getValue()); + } + } + + // 查询所有类型对应的 ID 名称映射 + Map> nameMap = new EnumMap<>(TaskAssigneeEnum.class); + typeIdMap.forEach((type, ids) -> nameMap.put(type, this.getNamesByType(type, ids))); + + // 组装返回结果,保持原始顺序 + return parsedMap.entrySet().stream() + .map(entry -> { + String storageId = entry.getKey(); + Pair parsed = entry.getValue(); + String handlerName = (parsed == null) ? null + : nameMap.getOrDefault(parsed.getKey(), Collections.emptyMap()) + .get(parsed.getValue()); + return new HandlerFeedBackVo(storageId, handlerName); + }).toList(); + } + + /** + * 根据任务办理类型查询对应的数据 + */ + private TaskAssigneeDTO fetchTaskAssigneeData(TaskAssigneeEnum type, TaskAssigneeBody taskQuery) { + return switch (type) { + case USER -> taskAssigneeService.selectUsersByTaskAssigneeList(taskQuery); + case ROLE -> taskAssigneeService.selectRolesByTaskAssigneeList(taskQuery); + case DEPT -> taskAssigneeService.selectDeptsByTaskAssigneeList(taskQuery); + case POST -> taskAssigneeService.selectPostsByTaskAssigneeList(taskQuery); + }; + } + + /** + * 根据任务办理类型获取部门数据 + */ + private List fetchDeptData(TaskAssigneeEnum type) { + if (type == TaskAssigneeEnum.USER || type == TaskAssigneeEnum.DEPT || type == TaskAssigneeEnum.POST) { + return deptService.selectDeptsByList(); + } + return new ArrayList<>(); + } + + /** + * 构建部门树状结构 + */ + private TreeFunDto buildDeptTree(List depts) { + return new TreeFunDto<>(depts) + .setId(dept -> String.valueOf(dept.getDeptId())) + .setName(DeptDTO::getDeptName) + .setParentId(dept -> String.valueOf(dept.getParentId())); + } + + /** + * 构建任务办理人数据 + */ + private HandlerFunDto buildHandlerData(TaskAssigneeDTO dto, TaskAssigneeEnum type) { + return new HandlerFunDto<>(dto.getList(), dto.getTotal()) + .setStorageId(assignee -> type.getCode() + assignee.getStorageId()) + .setHandlerCode(assignee -> StringUtils.blankToDefault(assignee.getHandlerCode(), "无")) + .setHandlerName(assignee -> StringUtils.blankToDefault(assignee.getHandlerName(), "无")) + .setGroupName(assignee -> StringUtils.defaultIfBlank( + Optional.ofNullable(assignee.getGroupName()) + .map(deptService::selectDeptNameByIds) + .orElse(DEFAULT_GROUP_NAME), DEFAULT_GROUP_NAME)) + .setCreateTime(assignee -> DateUtils.parseDateToStr(FormatsType.YYYY_MM_DD_HH_MM_SS, assignee.getCreateTime())); + } + + /** + * 批量解析多个存储标识符(storageIds),按类型分类并合并查询用户列表 + * 输入格式支持多个以逗号分隔的标识(如 "user:123,role:456,789") + * 会自动去重返回结果,非法格式的标识将被忽略 + * + * @param storageIds 多个存储标识符字符串(逗号分隔) + * @return 合并后的用户列表,去重后返回,非法格式的标识将被跳过 + */ + @Override + public List fetchUsersByStorageIds(String storageIds) { + if (StringUtils.isEmpty(storageIds)) { + return List.of(); + } + Map> typeIdMap = new EnumMap<>(TaskAssigneeEnum.class); + for (String storageId : storageIds.split(StringUtils.SEPARATOR)) { + Pair parsed = this.parseStorageId(storageId); + if (parsed != null) { + typeIdMap.computeIfAbsent(parsed.getKey(), k -> new ArrayList<>()).add(parsed.getValue()); + } + } + return typeIdMap.entrySet().stream() + .flatMap(entry -> this.getUsersByType(entry.getKey(), entry.getValue()).stream()) + .distinct() + .toList(); + } + + /** + * 根据指定的任务分配类型(TaskAssigneeEnum)和 ID 列表,获取对应的用户信息列表 + * + * @param type 任务分配类型,表示用户、角色、部门或其他(TaskAssigneeEnum 枚举值) + * @param ids 与指定分配类型关联的 ID 列表(例如用户ID、角色ID、部门ID等) + * @return 返回包含用户信息的列表。如果类型为用户(USER),则通过用户ID列表查询; + * 如果类型为角色(ROLE),则通过角色ID列表查询; + * 如果类型为部门(DEPT),则通过部门ID列表查询; + * 如果类型为岗位(POST)或无法识别的类型,则返回空列表 + */ + private List getUsersByType(TaskAssigneeEnum type, List ids) { + return switch (type) { + case USER -> userService.selectListByIds(ids); + case ROLE -> userService.selectUsersByRoleIds(ids); + case DEPT -> userService.selectUsersByDeptIds(ids); + case POST -> userService.selectUsersByPostIds(ids); + }; + } + + /** + * 根据任务分配类型和对应 ID 列表,批量查询名称映射关系 + * + * @param type 分配类型(用户、角色、部门、岗位) + * @param ids ID 列表(如用户ID、角色ID等) + * @return 返回 Map,其中 key 为 ID,value 为对应的名称 + */ + private Map getNamesByType(TaskAssigneeEnum type, List ids) { + return switch (type) { + case USER -> userService.selectUserNamesByIds(ids); + case ROLE -> userService.selectRoleNamesByIds(ids); + case DEPT -> userService.selectDeptNamesByIds(ids); + case POST -> userService.selectPostNamesByIds(ids); + }; + } + + /** + * 解析 storageId 字符串,返回类型和ID的组合 + * + * @param storageId 例如 "user:123" 或 "456" + * @return Pair(TaskAssigneeEnum, Long),如果格式非法返回 null + */ + private Pair parseStorageId(String storageId) { + if (StringUtils.isBlank(storageId)) { + return null; + } + // 跳过以 $ 或 # 开头的字符串 + if (StringUtils.startsWith(storageId, "$") || StringUtils.startsWith(storageId, "#")) { + log.debug("跳过 storageId 解析,检测到内置变量表达式:{}", storageId); + return null; + } + try { + String[] parts = storageId.split(StrUtil.COLON, 2); + if (parts.length < 2) { + return Pair.of(TaskAssigneeEnum.USER, Convert.toLong(parts[0])); + } else { + TaskAssigneeEnum type = TaskAssigneeEnum.fromCode(parts[0] + StrUtil.COLON); + return Pair.of(type, Convert.toLong(parts[1])); + } + } catch (Exception e) { + log.warn("解析 storageId 失败,格式非法:{},错误信息:{}", storageId, e.getMessage()); + return null; + } + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskServiceImpl.java new file mode 100644 index 0000000..bfd34e6 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskServiceImpl.java @@ -0,0 +1,755 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.dto.StartProcessReturnDTO; +import org.dromara.common.core.domain.dto.UserDTO; +import org.dromara.common.core.enums.BusinessStatusEnum; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.UserService; +import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.warm.flow.core.FlowEngine; +import org.dromara.warm.flow.core.dto.FlowParams; +import org.dromara.warm.flow.core.entity.*; +import org.dromara.warm.flow.core.enums.NodeType; +import org.dromara.warm.flow.core.enums.SkipType; +import org.dromara.warm.flow.core.service.*; +import org.dromara.warm.flow.core.utils.ExpressionUtil; +import org.dromara.warm.flow.core.utils.MapUtil; +import org.dromara.warm.flow.orm.entity.*; +import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper; +import org.dromara.warm.flow.orm.mapper.FlowInstanceMapper; +import org.dromara.warm.flow.orm.mapper.FlowNodeMapper; +import org.dromara.warm.flow.orm.mapper.FlowTaskMapper; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.common.constant.FlowConstant; +import org.dromara.workflow.common.enums.TaskAssigneeType; +import org.dromara.workflow.common.enums.TaskStatusEnum; +import org.dromara.workflow.domain.bo.*; +import org.dromara.workflow.domain.vo.FlowHisTaskVo; +import org.dromara.workflow.domain.vo.FlowTaskVo; +import org.dromara.workflow.mapper.FlwCategoryMapper; +import org.dromara.workflow.mapper.FlwTaskMapper; +import org.dromara.workflow.service.IFlwCommonService; +import org.dromara.workflow.service.IFlwNodeExtService; +import org.dromara.workflow.service.IFlwTaskAssigneeService; +import org.dromara.workflow.service.IFlwTaskService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +import static org.dromara.workflow.common.constant.FlowConstant.*; + +/** + * 任务 服务层实现 + * + * @author may + */ +@ConditionalOnEnable +@Slf4j +@RequiredArgsConstructor +@Service +public class FlwTaskServiceImpl implements IFlwTaskService { + + private final TaskService taskService; + private final InsService insService; + private final DefService defService; + private final HisTaskService hisTaskService; + private final NodeService nodeService; + private final FlowInstanceMapper flowInstanceMapper; + private final FlowTaskMapper flowTaskMapper; + private final FlowHisTaskMapper flowHisTaskMapper; + private final IdentifierGenerator identifierGenerator; + private final UserService userService; + private final FlwTaskMapper flwTaskMapper; + private final FlwCategoryMapper flwCategoryMapper; + private final FlowNodeMapper flowNodeMapper; + private final IFlwTaskAssigneeService flwTaskAssigneeService; + private final IFlwCommonService flwCommonService; + private final IFlwNodeExtService flwNodeExtService; + + /** + * 启动任务 + * + * @param startProcessBo 启动流程参数 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public StartProcessReturnDTO startWorkFlow(StartProcessBo startProcessBo) { + String businessId = startProcessBo.getBusinessId(); + if (StringUtils.isBlank(businessId)) { + throw new ServiceException("启动工作流时必须包含业务ID"); + } + // 启动流程实例(提交申请) + Map variables = startProcessBo.getVariables(); + // 流程发起人 + variables.put(INITIATOR, LoginHelper.getUserIdStr()); + // 业务id + variables.put(BUSINESS_ID, businessId); + FlowInstance flowInstance = flowInstanceMapper.selectOne(new LambdaQueryWrapper<>(FlowInstance.class) + .eq(FlowInstance::getBusinessId, businessId)); + if (ObjectUtil.isNotNull(flowInstance)) { + BusinessStatusEnum.checkStartStatus(flowInstance.getFlowStatus()); + List taskList = taskService.list(new FlowTask().setInstanceId(flowInstance.getId())); + taskService.mergeVariable(flowInstance, variables); + insService.updateById(flowInstance); + StartProcessReturnDTO dto = new StartProcessReturnDTO(); + dto.setProcessInstanceId(taskList.get(0).getInstanceId()); + dto.setTaskId(taskList.get(0).getId()); + return dto; + } + FlowParams flowParams = FlowParams.build() + .flowCode(startProcessBo.getFlowCode()) + .variable(startProcessBo.getVariables()) + .flowStatus(BusinessStatusEnum.DRAFT.getStatus()); + Instance instance; + try { + instance = insService.start(businessId, flowParams); + } catch (Exception e) { + throw new ServiceException(e.getMessage()); + } + // 申请人执行流程 + List taskList = taskService.list(new FlowTask().setInstanceId(instance.getId())); + if (taskList.size() > 1) { + throw new ServiceException("请检查流程第一个环节是否为申请人!"); + } + StartProcessReturnDTO dto = new StartProcessReturnDTO(); + dto.setProcessInstanceId(instance.getId()); + dto.setTaskId(taskList.get(0).getId()); + return dto; + } + + /** + * 办理任务 + * + * @param completeTaskBo 办理任务参数 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean completeTask(CompleteTaskBo completeTaskBo) { + try { + // 获取任务ID并查询对应的流程任务和实例信息 + Long taskId = completeTaskBo.getTaskId(); + List messageType = completeTaskBo.getMessageType(); + String notice = completeTaskBo.getNotice(); + // 获取抄送人 + List flowCopyList = completeTaskBo.getFlowCopyList(); + // 设置抄送人 + completeTaskBo.getVariables().put(FlowConstant.FLOW_COPY_LIST, flowCopyList); + // 消息类型 + completeTaskBo.getVariables().put(FlowConstant.MESSAGE_TYPE, messageType); + // 消息通知 + completeTaskBo.getVariables().put(FlowConstant.MESSAGE_NOTICE, notice); + + + FlowTask flowTask = flowTaskMapper.selectById(taskId); + if (ObjectUtil.isNull(flowTask)) { + throw new ServiceException("流程任务不存在或任务已审批!"); + } + Instance ins = insService.getById(flowTask.getInstanceId()); + // 检查流程状态是否为草稿、已撤销或已退回状态,若是则执行流程提交监听 + if (BusinessStatusEnum.isDraftOrCancelOrBack(ins.getFlowStatus())) { + completeTaskBo.getVariables().put(FlowConstant.SUBMIT, true); + } + // 设置弹窗处理人 + Map assigneeMap = setPopAssigneeMap(completeTaskBo.getAssigneeMap(), ins.getVariableMap()); + if (CollUtil.isNotEmpty(assigneeMap)) { + completeTaskBo.getVariables().putAll(assigneeMap); + } + // 构建流程参数,包括变量、跳转类型、消息、处理人、权限等信息 + FlowParams flowParams = FlowParams.build() + .variable(completeTaskBo.getVariables()) + .skipType(SkipType.PASS.getKey()) + .message(completeTaskBo.getMessage()) + .flowStatus(BusinessStatusEnum.WAITING.getStatus()) + .hisStatus(TaskStatusEnum.PASS.getStatus()) + .hisTaskExt(completeTaskBo.getFileId()); + // 执行任务跳转,并根据返回的处理人设置下一步处理人 + taskService.skip(taskId, flowParams); + return true; + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new ServiceException(e.getMessage()); + } + } + + /** + * 设置弹窗处理人 + * + * @param assigneeMap 处理人 + * @param variablesMap 变量 + */ + private Map setPopAssigneeMap(Map assigneeMap, Map variablesMap) { + Map map = new HashMap<>(); + if (CollUtil.isEmpty(assigneeMap)) { + return map; + } + for (Map.Entry entry : assigneeMap.entrySet()) { + if (variablesMap.containsKey(entry.getKey())) { + String userIds = variablesMap.get(entry.getKey()).toString(); + if (StringUtils.isNotBlank(userIds)) { + Set hashSet = new HashSet<>(); + //弹窗传入的选人 + List popUserIds = Arrays.asList(entry.getValue().toString().split(StringUtils.SEPARATOR)); + //已有的选人 + List variableUserIds = Arrays.asList(userIds.split(StringUtils.SEPARATOR)); + hashSet.addAll(popUserIds); + hashSet.addAll(variableUserIds); + map.put(entry.getKey(), String.join(StringUtils.SEPARATOR, hashSet)); + } + } else { + map.put(entry.getKey(), entry.getValue()); + } + } + return map; + } + + /** + * 添加抄送人 + * + * @param task 任务信息 + * @param flowCopyList 抄送人 + */ + @Override + public void setCopy(Task task, List flowCopyList) { + if (CollUtil.isEmpty(flowCopyList)) { + return; + } + // 添加抄送人记录 + FlowHisTask flowHisTask = flowHisTaskMapper.selectList(new LambdaQueryWrapper<>(FlowHisTask.class).eq(FlowHisTask::getTaskId, task.getId())).get(0); + FlowNode flowNode = new FlowNode(); + flowNode.setNodeCode(flowHisTask.getTargetNodeCode()); + flowNode.setNodeName(flowHisTask.getTargetNodeName()); + //生成新的任务id + long taskId = identifierGenerator.nextId(null).longValue(); + task.setId(taskId); + task.setNodeName("【抄送】" + task.getNodeName()); + Date updateTime = new Date(flowHisTask.getUpdateTime().getTime() - 1000); + FlowParams flowParams = FlowParams.build() + .skipType(SkipType.NONE.getKey()) + .hisStatus(TaskStatusEnum.COPY.getStatus()) + .message("【抄送给】" + StreamUtils.join(flowCopyList, FlowCopyBo::getUserName)); + HisTask hisTask = hisTaskService.setSkipHisTask(task, flowNode, flowParams); + hisTask.setCreateTime(updateTime); + hisTask.setUpdateTime(updateTime); + hisTaskService.save(hisTask); + List userList = flowCopyList.stream() + .map(flowCopy -> { + FlowUser flowUser = new FlowUser(); + flowUser.setType(TaskAssigneeType.COPY.getCode()); + flowUser.setProcessedBy(String.valueOf(flowCopy.getUserId())); + flowUser.setAssociated(taskId); + return flowUser; + }).collect(Collectors.toList()); + // 批量保存抄送人员 + FlowEngine.userService().saveBatch(userList); + } + + /** + * 查询当前用户的待办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @Override + public TableDataInfo pageByTaskWait(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + QueryWrapper queryWrapper = buildQueryWrapper(flowTaskBo); + queryWrapper.eq("t.node_type", NodeType.BETWEEN.getKey()); + queryWrapper.in("t.processed_by", LoginHelper.getUserIdStr()); + queryWrapper.in("t.flow_status", BusinessStatusEnum.WAITING.getStatus()); + Page page = this.getFlowTaskVoPage(pageQuery, queryWrapper); + return TableDataInfo.build(page); + } + + /** + * 查询当前用户的已办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @Override + public TableDataInfo pageByTaskFinish(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + QueryWrapper queryWrapper = buildQueryWrapper(flowTaskBo); + queryWrapper.eq("t.node_type", NodeType.BETWEEN.getKey()); + queryWrapper.in("t.approver", LoginHelper.getUserIdStr()); + queryWrapper.orderByDesc("t.create_time").orderByDesc("t.update_time"); + Page page = flwTaskMapper.getListFinishTask(pageQuery.build(), queryWrapper); + return TableDataInfo.build(page); + } + + /** + * 查询待办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @Override + public TableDataInfo pageByAllTaskWait(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + QueryWrapper queryWrapper = buildQueryWrapper(flowTaskBo); + queryWrapper.eq("t.node_type", NodeType.BETWEEN.getKey()); + Page page = getFlowTaskVoPage(pageQuery, queryWrapper); + return TableDataInfo.build(page); + } + + private Page getFlowTaskVoPage(PageQuery pageQuery, QueryWrapper queryWrapper) { + Page page = flwTaskMapper.getListRunTask(pageQuery.build(), queryWrapper); + List records = page.getRecords(); + if (CollUtil.isNotEmpty(records)) { + List taskIds = StreamUtils.toList(records, FlowTaskVo::getId); + Map> listMap = currentTaskAllUser(taskIds); + records.forEach(t -> { + List userList = listMap.getOrDefault(t.getId(), Collections.emptyList()); + if (CollUtil.isNotEmpty(userList)) { + t.setAssigneeIds(StreamUtils.join(userList, e -> String.valueOf(e.getUserId()))); + t.setAssigneeNames(StreamUtils.join(userList, UserDTO::getNickName)); + } + }); + } + return page; + } + + /** + * 查询已办任务 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @Override + public TableDataInfo pageByAllTaskFinish(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + QueryWrapper queryWrapper = buildQueryWrapper(flowTaskBo); + Page page = flwTaskMapper.getListFinishTask(pageQuery.build(), queryWrapper); + return TableDataInfo.build(page); + } + + /** + * 查询当前用户的抄送 + * + * @param flowTaskBo 参数 + * @param pageQuery 分页 + */ + @Override + public TableDataInfo pageByTaskCopy(FlowTaskBo flowTaskBo, PageQuery pageQuery) { + QueryWrapper queryWrapper = buildQueryWrapper(flowTaskBo); + queryWrapper.in("t.processed_by", LoginHelper.getUserIdStr()); + Page page = flwTaskMapper.getTaskCopyByPage(pageQuery.build(), queryWrapper); + return TableDataInfo.build(page); + } + + private QueryWrapper buildQueryWrapper(FlowTaskBo flowTaskBo) { + QueryWrapper wrapper = Wrappers.query(); + wrapper.like(StringUtils.isNotBlank(flowTaskBo.getNodeName()), "t.node_name", flowTaskBo.getNodeName()); + wrapper.like(StringUtils.isNotBlank(flowTaskBo.getFlowName()), "t.flow_name", flowTaskBo.getFlowName()); + wrapper.like(StringUtils.isNotBlank(flowTaskBo.getFlowCode()), "t.flow_code", flowTaskBo.getFlowCode()); + wrapper.in(CollUtil.isNotEmpty(flowTaskBo.getCreateByIds()), "t.create_by", flowTaskBo.getCreateByIds()); + if (StringUtils.isNotBlank(flowTaskBo.getCategory())) { + List categoryIds = flwCategoryMapper.selectCategoryIdsByParentId(Convert.toLong(flowTaskBo.getCategory())); + wrapper.in("t.category", StreamUtils.toList(categoryIds, Convert::toStr)); + } + wrapper.orderByDesc("t.create_time"); + return wrapper; + } + + /** + * 驳回任务 + * + * @param bo 参数 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean backProcess(BackProcessBo bo) { + try { + Long taskId = bo.getTaskId(); + String notice = bo.getNotice(); + List messageType = bo.getMessageType(); + String message = bo.getMessage(); + FlowTask task = flowTaskMapper.selectById(taskId); + if (ObjectUtil.isNull(task)) { + throw new ServiceException("任务不存在!"); + } + Instance inst = insService.getById(task.getInstanceId()); + BusinessStatusEnum.checkBackStatus(inst.getFlowStatus()); + Long definitionId = task.getDefinitionId(); + String applyNodeCode = flwCommonService.applyNodeCode(definitionId); + + Map variable = new HashMap<>(); + // 消息类型 + variable.put("messageType", messageType); + // 消息通知 + variable.put("notice", notice); + + FlowParams flowParams = FlowParams.build() + .nodeCode(bo.getNodeCode()) + .variable(variable) + .message(message) + .skipType(SkipType.REJECT.getKey()) + .flowStatus(applyNodeCode.equals(bo.getNodeCode()) ? TaskStatusEnum.BACK.getStatus() : TaskStatusEnum.WAITING.getStatus()) + .hisStatus(TaskStatusEnum.BACK.getStatus()) + .hisTaskExt(bo.getFileId()); + taskService.skip(task.getId(), flowParams); + return true; + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new ServiceException(e.getMessage()); + } + } + + /** + * 获取可驳回的前置节点 + * + * @param definitionId 流程定义id + * @param nowNodeCode 当前节点 + */ + @Override + public List getBackTaskNode(Long definitionId, String nowNodeCode) { + List nodeCodes = nodeService.getByNodeCodes(Collections.singletonList(nowNodeCode), definitionId); + if (!CollUtil.isNotEmpty(nodeCodes)) { + return nodeCodes; + } + //判断是否配置了固定驳回节点 + Node node = nodeCodes.get(0); + if (StringUtils.isNotBlank(node.getAnyNodeSkip())) { + return nodeService.getByNodeCodes(Collections.singletonList(node.getAnyNodeSkip()), definitionId); + } + //获取可驳回的前置节点 + List nodes = nodeService.previousNodeList(definitionId, nowNodeCode); + if (CollUtil.isNotEmpty(nodes)) { + return StreamUtils.filter(nodes, e -> NodeType.BETWEEN.getKey().equals(e.getNodeType())); + } + return nodes; + } + + /** + * 终止任务 + * + * @param bo 参数 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean terminationTask(FlowTerminationBo bo) { + try { + Long taskId = bo.getTaskId(); + Task task = taskService.getById(taskId); + if (task == null) { + throw new ServiceException("任务不存在!"); + } + Instance instance = insService.getById(task.getInstanceId()); + if (ObjectUtil.isNotNull(instance)) { + BusinessStatusEnum.checkInvalidStatus(instance.getFlowStatus()); + } + FlowParams flowParams = FlowParams.build() + .message(bo.getComment()) + .flowStatus(BusinessStatusEnum.TERMINATION.getStatus()) + .hisStatus(TaskStatusEnum.TERMINATION.getStatus()); + taskService.termination(taskId, flowParams); + return true; + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new ServiceException(e.getMessage()); + } + } + + /** + * 按照任务id查询任务 + * + * @param taskIdList 任务id + */ + @Override + public List selectByIdList(List taskIdList) { + return flowTaskMapper.selectList(new LambdaQueryWrapper<>(FlowTask.class) + .in(FlowTask::getId, taskIdList)); + } + + /** + * 按照任务id查询任务 + * + * @param taskId 任务id + */ + @Override + public FlowTaskVo selectById(Long taskId) { + Task task = taskService.getById(taskId); + if (ObjectUtil.isNull(task)) { + return null; + } + FlowTaskVo flowTaskVo = BeanUtil.toBean(task, FlowTaskVo.class); + Instance instance = insService.getById(task.getInstanceId()); + Definition definition = defService.getById(task.getDefinitionId()); + flowTaskVo.setFlowStatus(instance.getFlowStatus()); + flowTaskVo.setVersion(definition.getVersion()); + flowTaskVo.setFlowCode(definition.getFlowCode()); + flowTaskVo.setFlowName(definition.getFlowName()); + flowTaskVo.setBusinessId(instance.getBusinessId()); + FlowNode flowNode = this.getByNodeCode(flowTaskVo.getNodeCode(), instance.getDefinitionId()); + if (ObjectUtil.isNull(flowNode)) { + throw new NullPointerException("当前【" + flowTaskVo.getNodeCode() + "】节点编码不存在"); + } + //设置按钮权限 + flowTaskVo.setButtonList(flwNodeExtService.buildButtonPermissionsFromExt(flowNode.getExt())); + flowTaskVo.setNodeRatio(flowNode.getNodeRatio()); + flowTaskVo.setApplyNode(flowNode.getNodeCode().equals(flwCommonService.applyNodeCode(task.getDefinitionId()))); + return flowTaskVo; + } + + /** + * 获取下一节点信息 + * + * @param bo 参数 + */ + @Override + public List getNextNodeList(FlowNextNodeBo bo) { + Long taskId = bo.getTaskId(); + Map variables = bo.getVariables(); + Task task = taskService.getById(taskId); + Instance instance = insService.getById(task.getInstanceId()); + Definition definition = defService.getById(task.getDefinitionId()); + Map mergeVariable = MapUtil.mergeAll(instance.getVariableMap(), variables); + // 获取下一节点列表 + List nextNodeList = nodeService.getNextNodeList(task.getDefinitionId(), task.getNodeCode(), null, SkipType.PASS.getKey(), mergeVariable); + List nextFlowNodes = BeanUtil.copyToList(nextNodeList, FlowNode.class); + // 只获取中间节点 + nextFlowNodes = StreamUtils.filter(nextFlowNodes, node -> NodeType.BETWEEN.getKey().equals(node.getNodeType())); + if (CollUtil.isNotEmpty(nextNodeList)) { + // 构建以下节点数据 + List buildNextTaskList = StreamUtils.toList(nextNodeList, node -> taskService.addTask(node, instance, definition, FlowParams.build())); + // 办理人变量替换 + ExpressionUtil.evalVariable(buildNextTaskList, + FlowParams.build() + .variable(mergeVariable) + ); + for (FlowNode flowNode : nextFlowNodes) { + buildNextTaskList.stream().filter(t -> t.getNodeCode().equals(flowNode.getNodeCode())).findFirst().ifPresent(t -> { + if (CollUtil.isNotEmpty(t.getPermissionList())) { + List users = flwTaskAssigneeService.fetchUsersByStorageIds(String.join(StringUtils.SEPARATOR, t.getPermissionList())); + if (CollUtil.isNotEmpty(users)) { + flowNode.setPermissionFlag(StreamUtils.join(users, e -> String.valueOf(e.getUserId()))); + } + } + }); + } + } + return nextFlowNodes; + } + + /** + * 按照任务id查询任务 + * + * @param taskIdList 任务id + * @return 结果 + */ + @Override + public List selectHisTaskByIdList(List taskIdList) { + return flowHisTaskMapper.selectList(new LambdaQueryWrapper<>(FlowHisTask.class) + .in(FlowHisTask::getId, taskIdList)); + } + + /** + * 按照任务id查询任务 + * + * @param taskId 任务id + * @return 结果 + */ + @Override + public FlowHisTask selectHisTaskById(Long taskId) { + return flowHisTaskMapper.selectOne(new LambdaQueryWrapper<>(FlowHisTask.class) + .eq(FlowHisTask::getId, taskId)); + } + + /** + * 按照实例id查询任务 + * + * @param instanceIdList 流程实例id + */ + @Override + public List selectByInstIdList(List instanceIdList) { + return flowTaskMapper.selectList(new LambdaQueryWrapper<>(FlowTask.class) + .in(FlowTask::getInstanceId, instanceIdList)); + } + + /** + * 按照实例id查询任务 + * + * @param instanceId 流程实例id + */ + @Override + public List selectByInstId(Long instanceId) { + return flowTaskMapper.selectList(new LambdaQueryWrapper<>(FlowTask.class) + .eq(FlowTask::getInstanceId, instanceId)); + } + + /** + * 任务操作 + * + * @param bo 参数 + * @param taskOperation 操作类型,委派 delegateTask、转办 transferTask、加签 addSignature、减签 reductionSignature + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean taskOperation(TaskOperationBo bo, String taskOperation) { + FlowParams flowParams = FlowParams.build() + .message(bo.getMessage()); + if (LoginHelper.isSuperAdmin()) { + flowParams.ignore(true); + } + + // 根据操作类型构建 FlowParams + switch (taskOperation) { + case DELEGATE_TASK, TRANSFER_TASK -> { + ValidatorUtils.validate(bo, AddGroup.class); + flowParams.addHandlers(Collections.singletonList(bo.getUserId())); + } + case ADD_SIGNATURE -> { + ValidatorUtils.validate(bo, EditGroup.class); + flowParams.addHandlers(bo.getUserIds()); + } + case REDUCTION_SIGNATURE -> { + ValidatorUtils.validate(bo, EditGroup.class); + flowParams.reductionHandlers(bo.getUserIds()); + } + default -> { + log.error("Invalid operation type:{} ", taskOperation); + throw new ServiceException("Invalid operation type " + taskOperation); + } + } + + Long taskId = bo.getTaskId(); + Task task = taskService.getById(taskId); + FlowNode flowNode = getByNodeCode(task.getNodeCode(), task.getDefinitionId()); + if ("addSignature".equals(taskOperation) || "reductionSignature".equals(taskOperation)) { + if (flowNode.getNodeRatio().compareTo(BigDecimal.ZERO) == 0) { + throw new ServiceException(task.getNodeName() + "不是会签节点!"); + } + } + // 设置任务状态并执行对应的任务操作 + switch (taskOperation) { + //委派任务 + case DELEGATE_TASK -> { + flowParams.hisStatus(TaskStatusEnum.DEPUTE.getStatus()); + return taskService.depute(taskId, flowParams); + } + //转办任务 + case TRANSFER_TASK -> { + flowParams.hisStatus(TaskStatusEnum.TRANSFER.getStatus()); + return taskService.transfer(taskId, flowParams); + } + //加签,增加办理人 + case ADD_SIGNATURE -> { + flowParams.hisStatus(TaskStatusEnum.SIGN.getStatus()); + return taskService.addSignature(taskId, flowParams); + } + //减签,减少办理人 + case REDUCTION_SIGNATURE -> { + flowParams.hisStatus(TaskStatusEnum.SIGN_OFF.getStatus()); + return taskService.reductionSignature(taskId, flowParams); + } + default -> { + log.error("Invalid operation type:{} ", taskOperation); + throw new ServiceException("Invalid operation type " + taskOperation); + } + } + } + + /** + * 修改任务办理人(此方法将会批量修改所有任务的办理人) + * + * @param taskIdList 任务id + * @param userId 用户id + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateAssignee(List taskIdList, String userId) { + if (CollUtil.isEmpty(taskIdList)) { + return false; + } + try { + List flowTasks = this.selectByIdList(taskIdList); + // 批量删除现有任务的办理人记录 + if (CollUtil.isNotEmpty(flowTasks)) { + FlowEngine.userService().deleteByTaskIds(StreamUtils.toList(flowTasks, FlowTask::getId)); + List userList = flowTasks.stream() + .map(flowTask -> { + FlowUser flowUser = new FlowUser(); + flowUser.setType(TaskAssigneeType.APPROVER.getCode()); + flowUser.setProcessedBy(userId); + flowUser.setAssociated(flowTask.getId()); + return flowUser; + }) + .collect(Collectors.toList()); + if (CollUtil.isNotEmpty(userList)) { + FlowEngine.userService().saveBatch(userList); + } + } + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new ServiceException(e.getMessage()); + } + return true; + } + + /** + * 获取任务所有办理人 + * + * @param taskIdList 任务id + */ + @Override + public Map> currentTaskAllUser(List taskIdList) { + Map> map = new HashMap<>(); + // 获取与当前任务关联的用户列表 + List associatedUsers = FlowEngine.userService().getByAssociateds(taskIdList); + Map> listMap = StreamUtils.groupByKey(associatedUsers, User::getAssociated); + for (Map.Entry> entry : listMap.entrySet()) { + List value = entry.getValue(); + if (CollUtil.isNotEmpty(value)) { + List userDtoList = userService.selectListByIds(StreamUtils.toList(value, e -> Convert.toLong(e.getProcessedBy()))); + map.put(entry.getKey(), userDtoList); + } + } + return map; + } + + /** + * 获取当前任务的所有办理人 + * + * @param taskId 任务id + */ + @Override + public List currentTaskAllUser(Long taskId) { + // 获取与当前任务关联的用户列表 + List userList = FlowEngine.userService().getByAssociateds(Collections.singletonList(taskId)); + if (CollUtil.isEmpty(userList)) { + return Collections.emptyList(); + } + return userService.selectListByIds(StreamUtils.toList(userList, e -> Convert.toLong(e.getProcessedBy()))); + } + + /** + * 按照节点编码查询节点 + * + * @param nodeCode 节点编码 + * @param definitionId 流程定义id + */ + @Override + public FlowNode getByNodeCode(String nodeCode, Long definitionId) { + return flowNodeMapper.selectOne(new LambdaQueryWrapper() + .eq(FlowNode::getNodeCode, nodeCode) + .eq(FlowNode::getDefinitionId, definitionId)); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/TestLeaveServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/TestLeaveServiceImpl.java new file mode 100644 index 0000000..1df697c --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/TestLeaveServiceImpl.java @@ -0,0 +1,198 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.domain.event.ProcessDeleteEvent; +import org.dromara.common.core.domain.event.ProcessEvent; +import org.dromara.common.core.domain.event.ProcessTaskEvent; +import org.dromara.common.core.enums.BusinessStatusEnum; +import org.dromara.common.core.service.WorkflowService; +import org.dromara.common.core.utils.MapstructUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.mybatis.core.domain.BaseEntity; +import org.dromara.common.mybatis.core.page.PageQuery; +import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.domain.TestLeave; +import org.dromara.workflow.domain.bo.TestLeaveBo; +import org.dromara.workflow.domain.vo.TestLeaveVo; +import org.dromara.workflow.mapper.TestLeaveMapper; +import org.dromara.workflow.service.ITestLeaveService; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +/** + * 请假Service业务层处理 + * + * @author may + * @date 2023-07-21 + */ +@ConditionalOnEnable +@RequiredArgsConstructor +@Service +@Slf4j +public class TestLeaveServiceImpl implements ITestLeaveService { + + private final TestLeaveMapper baseMapper; + private final WorkflowService workflowService; + + /** + * spel条件表达:判断小于2 + * + * @param leaveDays 待判断的变量(可不传自行返回true或false) + * @return boolean + */ + public boolean eval(Integer leaveDays) { + if (leaveDays <= 2) { + return true; + } + return false; + } + + /** + * 查询请假 + */ + @Override + public TestLeaveVo queryById(Long id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询请假列表 + */ + @Override + public TableDataInfo queryPageList(TestLeaveBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询请假列表 + */ + @Override + public List queryList(TestLeaveBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(TestLeaveBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(StringUtils.isNotBlank(bo.getLeaveType()), TestLeave::getLeaveType, bo.getLeaveType()); + lqw.ge(bo.getStartLeaveDays() != null, TestLeave::getLeaveDays, bo.getStartLeaveDays()); + lqw.le(bo.getEndLeaveDays() != null, TestLeave::getLeaveDays, bo.getEndLeaveDays()); + lqw.orderByDesc(BaseEntity::getCreateTime); + return lqw; + } + + /** + * 新增请假 + */ + @Override + public TestLeaveVo insertByBo(TestLeaveBo bo) { + long day = DateUtil.betweenDay(bo.getStartDate(), bo.getEndDate(), true); + // 截止日期也算一天 + bo.setLeaveDays((int) day + 1); + TestLeave add = MapstructUtils.convert(bo, TestLeave.class); + if (StringUtils.isBlank(add.getStatus())) { + add.setStatus(BusinessStatusEnum.DRAFT.getStatus()); + } + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return MapstructUtils.convert(add, TestLeaveVo.class); + } + + /** + * 修改请假 + */ + @Override + public TestLeaveVo updateByBo(TestLeaveBo bo) { + TestLeave update = MapstructUtils.convert(bo, TestLeave.class); + baseMapper.updateById(update); + return MapstructUtils.convert(update, TestLeaveVo.class); + } + + /** + * 批量删除请假 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteWithValidByIds(List ids) { + workflowService.deleteInstance(ids); + return baseMapper.deleteByIds(ids) > 0; + } + + /** + * 总体流程监听(例如: 草稿,撤销,退回,作废,终止,已完成,单任务完成等) + * 正常使用只需#processEvent.flowCode=='leave1' + * 示例为了方便则使用startsWith匹配了全部示例key + * + * @param processEvent 参数 + */ + @EventListener(condition = "#processEvent.flowCode.startsWith('leave')") + public void processHandler(ProcessEvent processEvent) { + log.info("当前任务执行了{}", processEvent.toString()); + TestLeave testLeave = baseMapper.selectById(Convert.toLong(processEvent.getBusinessId())); + testLeave.setStatus(processEvent.getStatus()); + // 用于例如审批附件 审批意见等 存储到业务表内 自行根据业务实现存储流程 + Map params = processEvent.getParams(); + if (MapUtil.isNotEmpty(params)) { + // 历史任务扩展(通常为附件) + String hisTaskExt = Convert.toStr(params.get("hisTaskExt")); + // 办理人 + String handler = Convert.toStr(params.get("handler")); + // 办理意见 + String message = Convert.toStr(params.get("message")); + } + if (processEvent.getSubmit()) { + testLeave.setStatus(BusinessStatusEnum.WAITING.getStatus()); + } + baseMapper.updateById(testLeave); + } + + /** + * 执行任务创建监听 + * 示例:也可通过 @EventListener(condition = "#processTaskEvent.flowCode=='leave1'")进行判断 + * 在方法中判断流程节点key + * if ("xxx".equals(processTaskEvent.getNodeCode())) { + * //执行业务逻辑 + * } + * + * @param processTaskEvent 参数 + */ + @EventListener(condition = "#processTaskEvent.flowCode.startsWith('leave')") + public void processTaskHandler(ProcessTaskEvent processTaskEvent) { + log.info("当前任务创建了{}", processTaskEvent.toString()); + } + + /** + * 监听删除流程事件 + * 正常使用只需#processDeleteEvent.flowCode=='leave1' + * 示例为了方便则使用startsWith匹配了全部示例key + * + * @param processDeleteEvent 参数 + */ + @EventListener(condition = "#processDeleteEvent.flowCode.startsWith('leave')") + public void processDeleteHandler(ProcessDeleteEvent processDeleteEvent) { + log.info("监听删除流程事件,当前任务执行了{}", processDeleteEvent.toString()); + TestLeave testLeave = baseMapper.selectById(Convert.toLong(processDeleteEvent.getBusinessId())); + if (ObjectUtil.isNull(testLeave)) { + return; + } + baseMapper.deleteById(testLeave.getId()); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/WorkflowServiceImpl.java b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/WorkflowServiceImpl.java new file mode 100644 index 0000000..9c18380 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/WorkflowServiceImpl.java @@ -0,0 +1,141 @@ +package org.dromara.workflow.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.dto.CompleteTaskDTO; +import org.dromara.common.core.domain.dto.StartProcessDTO; +import org.dromara.common.core.domain.dto.StartProcessReturnDTO; +import org.dromara.common.core.service.WorkflowService; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.warm.flow.orm.entity.FlowInstance; +import org.dromara.workflow.common.ConditionalOnEnable; +import org.dromara.workflow.domain.bo.CompleteTaskBo; +import org.dromara.workflow.domain.bo.StartProcessBo; +import org.dromara.workflow.service.IFlwDefinitionService; +import org.dromara.workflow.service.IFlwInstanceService; +import org.dromara.workflow.service.IFlwTaskService; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * 通用 工作流服务实现 + * + * @author may + */ +@ConditionalOnEnable +@RequiredArgsConstructor +@Service +public class WorkflowServiceImpl implements WorkflowService { + + private final IFlwInstanceService flwInstanceService; + private final IFlwDefinitionService flwDefinitionService; + private final IFlwTaskService flwTaskService; + + /** + * 删除流程实例 + * + * @param businessIds 业务id + * @return 结果 + */ + @Override + public boolean deleteInstance(List businessIds) { + return flwInstanceService.deleteByBusinessIds(businessIds); + } + + /** + * 获取当前流程状态 + * + * @param taskId 任务id + */ + @Override + public String getBusinessStatusByTaskId(Long taskId) { + FlowInstance flowInstance = flwInstanceService.selectByTaskId(taskId); + return ObjectUtil.isNotNull(flowInstance) ? flowInstance.getFlowStatus() : StringUtils.EMPTY; + } + + /** + * 获取当前流程状态 + * + * @param businessId 业务id + */ + @Override + public String getBusinessStatus(String businessId) { + FlowInstance flowInstance = flwInstanceService.selectInstByBusinessId(businessId); + return ObjectUtil.isNotNull(flowInstance) ? flowInstance.getFlowStatus() : StringUtils.EMPTY; + } + + /** + * 设置流程变量 + * + * @param instanceId 流程实例id + * @param variables 流程变量 + */ + @Override + public void setVariable(Long instanceId, Map variables) { + flwInstanceService.setVariable(instanceId, variables); + } + + /** + * 获取流程变量 + * + * @param instanceId 流程实例id + */ + @Override + public Map instanceVariable(Long instanceId) { + return flwInstanceService.instanceVariable(instanceId); + } + + /** + * 按照业务id查询流程实例id + * + * @param businessId 业务id + * @return 结果 + */ + @Override + public Long getInstanceIdByBusinessId(String businessId) { + FlowInstance flowInstance = flwInstanceService.selectInstByBusinessId(businessId); + return ObjectUtil.isNotNull(flowInstance) ? flowInstance.getId() : null; + } + + /** + * 启动流程 + * + * @param startProcess 参数 + */ + @Override + public StartProcessReturnDTO startWorkFlow(StartProcessDTO startProcess) { + return flwTaskService.startWorkFlow(BeanUtil.toBean(startProcess, StartProcessBo.class)); + } + + /** + * 办理任务 + * 系统后台发起审批 无用户信息 需要忽略权限 + * completeTask.getVariables().put("ignore", true); + * + * @param completeTask 参数 + */ + @Override + public boolean completeTask(CompleteTaskDTO completeTask) { + return flwTaskService.completeTask(BeanUtil.toBean(completeTask, CompleteTaskBo.class)); + } + + /** + * 办理任务 + * + * @param taskId 任务ID + * @param message 办理意见 + */ + @Override + public boolean completeTask(Long taskId, String message) { + CompleteTaskBo completeTask = new CompleteTaskBo(); + completeTask.setTaskId(taskId); + completeTask.setMessage(message); + // 忽略权限(系统后台发起审批 无用户信息 需要忽略权限) + completeTask.getVariables().put("ignore", true); + return flwTaskService.completeTask(completeTask); + } + +} diff --git a/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/package-info.md b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/package-info.md new file mode 100644 index 0000000..c938b1e --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/package-info.md @@ -0,0 +1,3 @@ +java包使用 `.` 分割 resource 目录使用 `/` 分割 +
+此文件目的 防止文件夹粘连找不到 `xml` 文件 \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwCategoryMapper.xml b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwCategoryMapper.xml new file mode 100644 index 0000000..1433aa7 --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwCategoryMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwInstanceMapper.xml b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwInstanceMapper.xml new file mode 100644 index 0000000..414f61b --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwInstanceMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwTaskMapper.xml b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwTaskMapper.xml new file mode 100644 index 0000000..4301dea --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/FlwTaskMapper.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + diff --git a/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/TestLeaveMapper.xml b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/TestLeaveMapper.xml new file mode 100644 index 0000000..235484a --- /dev/null +++ b/ruoyi-modules/ruoyi-workflow/src/main/resources/mapper/workflow/TestLeaveMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-plugins/.DS_Store b/ruoyi-plugins/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7dc81b4c8241c216df0a4f475f261c8b7303b7ff GIT binary patch literal 6148 zcmeHKPfrs;6n_I%wji=VtB5h#*oz4mL8}QdhPq&s7z3dMi-2{v9m<4drrF(6fspj9 zCqIB6z;9sU#iJLm-i#kXuikt!vm2D^)fh7`nfH71{><+DCOfkO0K{$8rvYpLu&_zY z^rQNX$hd4>(vx_45s4au!By~p`R%CLm2Dh`0mHz*#(?~GEeN3n0`}pj@7E86kFmFY zGhk6XHT8+~^kz<-?z8%=0c*3oE!ySSi(8TFh3m4nB1GV)w&$-h+OFEUIl*I(aT=-u zR;QS<@tEyfH0>wuNDvQ>NR@UVDeX41JiJ##ea9L`*ynskb{ibqFTD_^*F zd%3z3>~ir!6G?F2Q8Jid3NO*HTAs6$8=s_Y&)G z>^CtdckEEZ-47|{YP`j*t%$~zpX{}&JrvbK5d=#jVlfkZZ6iXy7h<}*z_$cGaSyp( zY|2oyO2Y`TtERH%g`vkA)ZO0FT?zyZ91*YF13!3X#ZU*S9H zBR08ACPFdO=12pvlwe0A RdQi-dfTF=D!@yr<;0JP&%#8p5 literal 0 HcmV?d00001 diff --git a/ruoyi-server/Dockerfile b/ruoyi-server/Dockerfile new file mode 100644 index 0000000..c713956 --- /dev/null +++ b/ruoyi-server/Dockerfile @@ -0,0 +1,30 @@ +# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/ +FROM bellsoft/liberica-openjdk-rocky:17.0.15-cds +#FROM bellsoft/liberica-openjdk-rocky:21.0.7-cds + +LABEL maintainer="Lion Li" + +RUN mkdir -p /ruoyi/server/logs \ + /ruoyi/server/temp \ + /ruoyi/skywalking/agent + +WORKDIR /ruoyi/server + +ENV SERVER_PORT=8080 SNAIL_PORT=28080 LANG=C.UTF-8 LC_ALL=C.UTF-8 JAVA_OPTS="" + +EXPOSE ${SERVER_PORT} +# 暴露 snail job 客户端端口 用于定时任务调度中心通信 +EXPOSE ${SNAIL_PORT} + +ADD ./target/ruoyi-server.jar ./app.jar + +SHELL ["/bin/bash", "-c"] + +ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -Dserver.port=${SERVER_PORT} \ + -Dsnail-job.port=${SNAIL_PORT} \ + # 应用名称 如果想区分集群节点监控 改成不同的名称即可 + #-Dskywalking.agent.service_name=ruoyi-server \ + #-javaagent:/ruoyi/skywalking/agent/skywalking-agent.jar \ + -XX:+HeapDumpOnOutOfMemoryError -XX:+UseZGC ${JAVA_OPTS} \ + -jar app.jar + diff --git a/ruoyi-server/docs/LOGIN_COMPREHENSIVE_GUIDE.md b/ruoyi-server/docs/LOGIN_COMPREHENSIVE_GUIDE.md new file mode 100644 index 0000000..927b51d --- /dev/null +++ b/ruoyi-server/docs/LOGIN_COMPREHENSIVE_GUIDE.md @@ -0,0 +1,718 @@ +# 登录模块综合开发指南(LOGIN_COMPREHENSIVE_GUIDE) + +版本:v1.1.0(2025-12-27) +适用范围:Sys(后台管理端)、App(移动端)两套登录体系及微信公众号整合 +最后更新:2025-12-27 + +--- + +## 目录 +1. 登录模块概述 +2. 详细开发指南 +3. 登录流程示意图 +4. 实现方案 +5. 模块规范 +6. App 会话管理 API +7. Sys 在线用户管理 API +8. Sys 登录日志管理 API +9. 相关代码路径参考 + +--- + +## 1. 登录模块概述 + +- 背景与目的 + - 系统同时支持 Sys 与 App 两套登录体系,分别面向管理员与 C 端用户,要求登录态、权限与数据完全隔离,同时提供统一的扩展与维护指引。 + - 采用策略模式(Strategy Pattern)实现登录方式的可插拔扩展,基于 Sa-Token 管理会话与权限,支持验证码、限流、并发与设备维度会话。 + - 集成微信公众号能力:App 用于授权登录与业务交互;Sys 用于管理员绑定与模板消息推送。 + +- 目标 + - 统一规范开发流程与接口定义,保障多端、多方式登录的稳定性与可扩展性。 + - 为新增登录方式与微信能力提供清晰的落地步骤与测试方案。 + +--- + +## 2. 详细开发指南 + +### 2.1 多用户体系与会话隔离 +- 物理隔离 + 逻辑统一:`sys_user` 与 `app_user` 两套用户体系;统一使用 Sa-Token 管理会话。 +- LoginId 前缀隔离: + - Sys:`sys_user:{id}` + - App:`app:{id}` +- 会话校验(Sys 全局拦截):参考 SecurityConfig 的 `StpUtil.checkLogin()` 与路由校验,确保 Token 与 clientid 一致。 + +### 2.2 App 开发要点 +- 策略模式登录:`IAppAuthStrategy` 以 `grantType` 路由到具体策略(密码、短信、社交、微信等)。 +- 验证码与密码重试:`captcha.enable=true` 可开关;错误次数与锁定时间通过配置项控制。 +- 信息补全向导(过滤器):登录后若用户资料未完善,仅放行“查询个人信息/绑定手机号”接口,其余拦截返回 403。 + +### 2.3 Sys 开发要点 +- 安全加固: + - 异地登录检测:比对最近一次登录 IP 归属地变化并告警(日志/邮件)。 + - 强制改密策略:登录后检查密码更新时间是否超期,超期在 Session 写入标记,拦截除改密外所有接口。 +- 管理员微信绑定与消息推送:后台生成带参二维码,管理员扫码绑定 `openId/unionId`,用于模板消息推送。 + +(最小改动策略) +- 保留现有 Sys 登录与权限实现,不做核心代码改造。 +- 新增或接入的仅为“管理员微信绑定二维码生成”“SCAN/SUBSCRIBE 回调绑定”“模板消息推送”三个能力点;其余增强(如异地登录与强制改密)可按需增量加入,不影响主流程。 + +### 2.4 微信公众号整合(双端) +- App:OAuth2 获取 `openId/unionId`,优先 `unionId` 进行跨应用账号识别;若未绑定则返回 401 并引导绑定手机号。 +- Sys:生成 `scene_str=sys_user:{userId}` 的二维码,接收 `SCAN/SUBSCRIBE` 事件解析并绑定,后续通过模板消息进行系统通知与订单推送。 + +--- + +## 3. 登录流程示意图 + +### 3.1 App 登录总体流程 +```mermaid +sequenceDiagram + participant Client as 客户端 + participant Controller as AppAuthController + participant Strategy as IAppAuthStrategy + participant Service as AppLoginService + participant Redis as Redis + Client->>Controller: POST /app/auth/login + Controller->>Strategy: authenticate(body, client, grantType) + Strategy->>Service: checkLogin(...) + Service->>Redis: PWD_ERR_CNT_KEY + Strategy->>Controller: LoginVo(token, expire) + Controller-->>Client: 返回结果 +``` + +### 3.2 App 微信公众号授权登录 +```mermaid +sequenceDiagram + participant H5 as 前端网页 + participant Wx as 微信OAuth2 + participant AppAPI as AppAuthController + participant MP as WxMpService + H5->>Wx: 跳转授权,获取code + H5->>AppAPI: grantType=wechat_mp, code + AppAPI->>MP: getAccessToken(code) + MP-->>AppAPI: openId, accessToken + AppAPI->>MP: userInfo(openId) + MP-->>AppAPI: unionId + AppAPI->>DB: 查询app_social_auth by unionId + alt 已绑定 + AppAPI-->>H5: 登录成功 + else 未绑定 + AppAPI-->>H5: 401_BIND_REQUIRED + tempKey + end +``` + +### 3.3 Sys 管理员绑定与模板消息推送 +```mermaid +flowchart LR + A[管理员点击绑定] --> B[生成带参二维码 scene_str=sys_user:{userId}] + B --> C[管理员扫码] + C --> D[WxMpPortal接收SCAN/SUBSCRIBE] + D --> E[解析EventKey与OpenId] + E --> F[查询UnionId] + F --> G[更新管理员绑定信息] + G --> H[SysMessageService发送模板消息] +``` + +--- + +## 4. 实现方案 + +### 4.1 架构与技术选型 +- Sa-Token:统一的会话与权限管理(Token 名称、并发、多设备)。 +- 策略模式:按 `grantType` 定位不同登录策略,低耦合、易扩展。 +- WxJava:`ruoyi-mp` 模块提供的公众号 OAuth2 与模板消息能力。 +- 线程池与并发:按需启用全局线程池或在 JDK21 使用虚拟线程。 + +### 4.2 关键实现细节 +- LoginId 前缀实现:`LoginUser.getLoginId()` 返回 `userType + ":" + userId`。 +- App 密码策略:开启验证码校验时执行 `validateCaptcha()`;错误次数与锁定时间通过配置项 `user.password.maxRetryCount`、`user.password.lockTime` 控制。 +- App 信息补全:过滤器中检查 `infoComplete`,仅放行指定白名单接口(配置项 `user.auth.whitelist`),其余拦截返回 403。 +- App 微信授权:`getAccessToken(code) → userInfo(openId)`,优先使用 `unionId` 识别;未绑定返回 401 与临时 Key,引导绑定手机号。 +- Sys 管理员绑定:二维码 `scene_str=sys_user:{userId}`;接收事件后解析 `EventKey` 与 `OpenId`,查询 `unionId` 并写入绑定信息;后续根据 `openId` 推送模板消息。 +- 异地登录检测:在记录登录信息时调用 `LoginRiskService.checkRisk(userId, ip)`,比较归属地变化并告警。 +- 强制改密:登录后检查密码更新时间是否超期,标记 Session 并在拦截器中只放行改密接口。 + +### 4.4 配置项说明 +- **验证码配置**(`application.yml`): + ```yaml + captcha: + enable: true # 是否启用验证码校验 + type: MATH # 验证码类型(MATH数学计算 / CHAR字符验证) + category: CIRCLE # 干扰类型(LINE线段 / CIRCLE圆圈 / SHEAR扭曲) + numberLength: 1 # 数字验证码位数 + charLength: 4 # 字符验证码长度 + ``` +- **用户密码策略配置**(`application.yml`): + ```yaml + user: + password: + maxRetryCount: 5 # 密码最大错误次数 + lockTime: 10 # 密码锁定时间(分钟) + ``` +- **App端验证码与白名单配置**(`application.yml`): + ```yaml + user: + auth: + captcha: + enabled: false # App端验证码开关 + type: math # 验证码类型 + whitelist: /app/user/profile,/app/user/bind,/app/auth/logout # 信息补全白名单 + ``` +- **客户端配置**:在 `sys_client` 表中配置客户端信息,包括 `client_id`、`grant_type`(支持多个,逗号分隔)、`status`、`timeout`、`active_timeout` 等。 + +### 4.3 OAuth 与 MP 职责划分 +- 第三方登录(OAuth):使用 justauth + - 属性来源:`application*.yml` 中的 `justauth.type.*` + - 代码路径:属性绑定 [SocialProperties](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/config/properties/SocialProperties.java),工厂方法 [SocialUtils.getAuthRequest](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-common/ruoyi-common-social/src/main/java/org/dromara/common/social/utils/SocialUtils.java#L35-L74) + - 使用位置:App 端策略 [AppWechatMpAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMpAuthStrategy.java)、[AppWechatOpenAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatOpenAuthStrategy.java)、[AppWechatMiniprogramAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMiniprogramAuthStrategy.java) + - 说明:source 包含 `wechat_mp`、`wechat_open`、`wechat_miniprogram`、`github`、`gitee` 等,统一由 justauth 提供 OAuth 能力 +- 公众号服务端能力(MP):使用 `wx.*` + - 属性来源:`application*.yml` 中的 `wx.mp.*`、`wx.miniapp.*`、`wx.pay.*` + - 代码路径:属性绑定 [WxMpProperties](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpProperties.java),装配 [WxMpConfiguration](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/config/WxMpConfiguration.java) + - 使用位置:消息入口 [WxMpPortalController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpPortalController.java)、二维码接口 [WxMpQrCodeController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpQrCodeController.java) + - 说明:MP 提供消息接收、二维码、模板消息推送等服务端功能,不用于 OAuth 登录 + +--- + +## 5. 模块规范 + +### 5.1 接口定义与参数 + +#### Sys 登录接口 +- **路径**:`/auth/login` +- **方法**:`POST` +- **支持的grantType**: + - `password` - 密码登录(参数:`username`, `password`, 可选 `code/uuid`验证码) + - `sms` - 短信登录(参数:`phonenumber`, `smsCode`) + - `email` - 邮箱登录(参数:`email`, `emailCode`) + - `social` - 第三方登录(参数:`source`, `socialCode`, `socialState`) +- **通用参数**:`clientId`(必填) +- **返回**:`LoginVo`(包含 `accessToken`, `expireIn`, `clientId`) +- **代码路径**:[AuthController.login](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java#L75-L101) + +#### Sys 退出登录接口 +- **路径**:`/auth/logout` +- **方法**:`POST` +- **权限**:需要登录态 +- **说明**:退出当前登录会话,清除Token +- **代码路径**:[AuthController.logout](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java#L164-L168) + +#### Sys 用户注册接口 +- **路径**:`/auth/register` +- **方法**:`POST` +- **参数**:`username`, `password`, `userType`, 可选 `code/uuid`(验证码) +- **说明**:系统用户注册,需系统开启注册功能 +- **代码路径**:[AuthController.register](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java#L174-L181) + +#### Sys 第三方绑定接口 +- **获取绑定URL**:`GET /auth/binding/{source}?domain={domain}` + - 生成第三方授权跳转URL,用于绑定第三方账号 +- **回调绑定**:`POST /auth/social/callback` + - 前端回调后绑定第三方账号(需token) +- **取消绑定**:`DELETE /auth/unlock/{socialId}` + - 取消已绑定的第三方账号(需token) +- **代码路径**:[AuthController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java#L109-L158) + +#### App 登录接口 +- **路径**:`/app/auth/login` +- **方法**:`POST` +- **支持的grantType**: + - `app_password` - 密码登录(参数:`username`, `password`) + - `sms` - 短信登录(参数:`phonenumber`, `smsCode`) + - `email` - 邮箱登录(参数:`email`, `emailCode`) + - `social` - 第三方登录(参数:`source`, `socialCode`, `socialState`) + - `wechat_mp` - 微信公众号登录(参数:`socialCode`, `socialState`) + - `wechat_open` - 微信开放平台登录(参数:`socialCode`, `socialState`) + - `wechat_miniprogram` - 微信小程序登录(参数:`socialCode`, `socialState`) +- **通用参数**:`clientId`(必填) +- **返回**:`LoginVo`(包含 `accessToken`, `expireIn`, `clientId`) +- **代码路径**:[AppAuthController.login](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java#L69-L93) + +#### App 退出登录接口 +- **路径**:`/app/auth/logout` +- **方法**:`POST` +- **权限**:需要登录态 +- **说明**:退出当前登录会话,清除Token +- **代码路径**:[AppAuthController.logout](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java#L100-L104) + +#### App 用户注册接口 +- **路径**:`/app/auth/register` +- **方法**:`POST` +- **参数**:`username`, `password` +- **限流**:60秒内最多1次(按IP) +- **说明**:App用户注册,需系统开启注册功能 +- **代码路径**:[AppAuthController.register](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java#L115-L132) + +#### App 配置接口 +- **路径**:`/app/auth/config` +- **方法**:`GET` +- **说明**:获取App端配置信息(如注册开关等) +- **代码路径**:[AppAuthController.getConfig](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java#L139-L145) + +#### 验证码接口 +- **图形验证码**:`GET /auth/code` + - 返回:`CaptchaVo`(包含 `uuid`, `img`(Base64图片), `captchaEnabled`) + - 限流:60秒内最多10次(按IP) + - **代码路径**:[CaptchaController.getCode](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/CaptchaController.java#L112-L121) +- **短信验证码**:`GET /resource/sms/code?phonenumber={phonenumber}` + - 限流:60秒内最多1次(按手机号) + - **代码路径**:[CaptchaController.smsCode](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/CaptchaController.java#L59-L76) +- **邮箱验证码**:`GET /resource/email/code?email={email}` + - 限流:60秒内最多1次(按邮箱) + - **代码路径**:[CaptchaController.emailCode](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/CaptchaController.java#L83-L90) + +#### 管理员绑定微信(建议) +- **生成绑定二维码**:`POST /wx/mp/qrcode/{appid}/forever?sceneStr=sys_user:{userId}` + - 生成永久二维码,scene_str = sys_user:{userId} +- **获取二维码URL**:`GET /wx/mp/qrcode/{appid}/url?ticket={ticket}` + - 根据ticket获取二维码图片URL +- **事件回调**:`WxMpPortalController` 处理 `SCAN/SUBSCRIBE` 事件并完成绑定 + - **代码路径**:[WxMpPortalController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-mp/src/main/java/org/dromara/mp/controller/WxMpPortalController.java) + +### 5.2 会话与权限规范 +- Token 名称:`Authorization`(统一)。 +- 并发策略:`is-concurrent=true` 支持多设备,按需要设置 `deviceType`。 +- 登录态隔离:Sys 与 App 使用不同 LoginId 前缀与独立会话空间。 +- 鉴权方式:Sys 采用注解鉴权(`@SaCheckPermission` 等);App 采用资源级鉴权(业务层校验 `userId` 所属资源)。 + +### 5.3 错误码与处理 +- `401_BIND_REQUIRED`:微信授权成功但未绑定手机号,引导前端跳转绑定流程。 +- `403_UPDATE_PWD`:Sys 账号密码超期未改,拦截除改密外所有接口。 +- `403_INFO_INCOMPLETE`:App 用户信息未补全,仅放行资料完善接口。 + +### 5.4 约束与约定 +- 不混用 Sys 与 App 的用户体系与权限模型。 +- 微信授权优先使用 `unionId` 进行跨应用识别;若缺失则降级使用 `openId`。 +- 新增登录方式遵循 `{grantType}AppAuthStrategy` 命名与策略路由约定。 + +### 5.5 App 角色与关联关系 +- 表结构与关系: + - 用户表:`app_user`,参见 [ry_app.sql:app_user](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/script/sql/ry_app.sql#L6-L27) + - 角色表:`app_role`(角色键如 `app_user`、`app_vip`),参见 [ry_app.sql:app_role](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/script/sql/ry_app.sql#L35-L53) + - 关联表:`app_user_role`(多对多关联),参见 [ry_app.sql:app_user_role](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/script/sql/ry_app.sql#L56-L64) +- 规范说明: + - App 端角色用于业务权益与标签管理(如普通用户、VIP),不等同于 Sys 端的 RBAC 功能权限。 + - 业务层根据 `role_key`(如 `app_vip`)判断权益与展示(价格、特权、活动资格等),避免与后台菜单/按钮权限混用。 + - 登录后构建的 `AppLoginUser` 可加载角色集合用于业务判定;权限校验仍以资源归属为主(基于 `userId`)。 +- 第三方授权表: + - `app_social_auth` 用于存储平台标识(`platform`/`platform_uid`)与微信 `open_id`/`union_id`,参见 [ry_app.sql:app_social_auth](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/script/sql/ry_app.sql#L67-L85) + - App 微信登录优先使用 `union_id` 进行账号打通;若缺失则降级使用 `open_id`。 + +### 5.6 调用实例(curl) +- Sys 密码登录(grantType=password): +```bash +curl -sS -X POST "http://localhost:8080/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "e5cd7e4891bf95d1d19206ce24a7b32e", + "grantType": "password", + "username": "admin", + "password": "admin123", + "code": "a1b2", + "uuid": "captcha-uuid" + }' +``` +- Sys 短信登录(grantType=sms): +```bash +curl -sS -X POST "http://localhost:8080/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "e5cd7e4891bf95d1d19206ce24a7b32e", + "grantType": "sms", + "phonenumber": "13800000000", + "smsCode": "123456" + }' +``` +- Sys 邮箱登录(grantType=email): +```bash +curl -sS -X POST "http://localhost:8080/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "e5cd7e4891bf95d1d19206ce24a7b32e", + "grantType": "email", + "email": "admin@example.com", + "emailCode": "654321" + }' +``` +- Sys 第三方登录(grantType=social,source∈{github,gitee,wechat_open,wechat_mp…}): +```bash +curl -sS -X POST "http://localhost:8080/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "e5cd7e4891bf95d1d19206ce24a7b32e", + "grantType": "social", + "source": "github", + "socialCode": "oauth-code", + "socialState": "state-123" + }' +``` +- App 密码登录(grantType=app_password): +```bash +curl -sS -X POST "http://localhost:8080/app/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "app_password", + "username": "appuser", + "password": "admin123" + }' +``` +- App 短信登录(grantType=sms): +```bash +curl -sS -X POST "http://localhost:8080/app/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "sms", + "phonenumber": "13800000000", + "smsCode": "123456" + }' +``` +- App 邮箱登录(grantType=email): +```bash +curl -sS -X POST "http://localhost:8080/app/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "email", + "email": "user@example.com", + "emailCode": "654321" + }' +``` +- App 第三方登录(grantType=social,source∈{github,gitee,wechat_open…}): +```bash +curl -sS -X POST "http://localhost:8080/app/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "social", + "source": "gitee", + "socialCode": "oauth-code", + "socialState": "state-xyz" + }' +``` +- App 微信公众号登录(grantType=wechat_mp,前端已拿到 code 与 state): +```bash +curl -sS -X POST "http://localhost:8080/app/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "wechat_mp", + "socialCode": "wx-oauth-code", + "socialState": "state-123" + }' +``` +- App 微信开放平台登录(grantType=wechat_open): +```bash +curl -sS -X POST "http://localhost:8080/app/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "wechat_open", + "socialCode": "wx-open-code", + "socialState": "state-456" + }' +``` +- App 微信小程序登录(grantType=wechat_miniprogram): +```bash +curl -sS -X POST "http://localhost:8080/app/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "wechat_miniprogram", + "socialCode": "wx-mini-code", + "socialState": "state-789" + }' +``` +- Sys 管理员绑定二维码(生成永久二维码 ticket 与 URL): +```bash +# 生成永久二维码,scene_str = sys_user:{userId} +curl -sS -X POST "http://localhost:8080/wx/mp/qrcode/{appid}/forever" \ + -d "sceneStr=sys_user:1" + +# 根据 ticket 获取二维码 URL +curl -sS -X GET "http://localhost:8080/wx/mp/qrcode/{appid}/url" \ + --data-urlencode "ticket=YOUR_TICKET" +``` +- 获取公众号配置: +```bash +curl -sS "http://localhost:8080/wx/mp/portal/{appid}/config" +``` +- App 用户信息补全白名单接口示例(占位): +```bash +# 获取个人信息 +curl -sS "http://localhost:8080/app/user/profile" -H "Authorization: Bearer YOUR_TOKEN" + +# 绑定手机号 +curl -sS -X POST "http://localhost:8080/app/user/bind" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ "phone": "13800000000", "smsCode": "123456" }' +``` +- Sys 退出登录: +```bash +curl -sS -X POST "http://localhost:8080/auth/logout" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- App 退出登录: +```bash +curl -sS -X POST "http://localhost:8080/app/auth/logout" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- Sys 用户注册: +```bash +curl -sS -X POST "http://localhost:8080/auth/register" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "newuser", + "password": "password123", + "userType": "sys_user", + "code": "a1b2", + "uuid": "captcha-uuid" + }' +``` +- App 用户注册: +```bash +curl -sS -X POST "http://localhost:8080/app/auth/register" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "appuser", + "password": "password123" + }' +``` +- 获取图形验证码: +```bash +curl -sS "http://localhost:8080/auth/code" +# 返回: {"code":200,"data":{"uuid":"xxx","img":"data:image/png;base64,...","captchaEnabled":true}} +``` +- 获取短信验证码: +```bash +curl -sS "http://localhost:8080/resource/sms/code?phonenumber=13800000000" +``` +- 获取邮箱验证码: +```bash +curl -sS "http://localhost:8080/resource/email/code?email=user@example.com" +``` +- 获取App配置: +```bash +curl -sS "http://localhost:8080/app/auth/config" +# 返回: {"code":200,"data":{"registerEnabled":true}} +``` +- Sys 获取第三方绑定URL: +```bash +curl -sS "http://localhost:8080/auth/binding/github?domain=http://localhost:8080" +# 返回: {"code":200,"data":"https://github.com/login/oauth/authorize?..."} +``` +- Sys 第三方绑定回调: +```bash +curl -sS -X POST "http://localhost:8080/auth/social/callback" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "source": "github", + "socialCode": "oauth-code", + "socialState": "state-123" + }' +``` +- Sys 取消第三方绑定: +```bash +curl -sS -X DELETE "http://localhost:8080/auth/unlock/1" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +--- + +## 6. App 会话管理 API + +- 基础路径:`/app/session` +- 鉴权:登录态(Sa-Token) + +### 6.1 接口列表 +- `GET /app/session/active` 获取当前用户活跃会话列表 +- `POST /app/session/terminate` 终止指定会话(参数:`sessionId`) +- `POST /app/session/heartbeat` 会话续期(参数:`sessionId`,默认延长至 30 天) +- `GET /app/session/sse` 订阅会话事件(SSE),事件名:`session`,数据格式:`:` + +### 6.2 会话数据结构(AppSession) +- `sessionId` 会话ID(token值) +- `userId` 用户ID +- `deviceType` 设备类型(Web/iOS/Android/Desktop) +- `deviceId` 设备标识(`X-Device-Id` 或生成) +- `userAgent` UA 字符串 +- `ip` 登录IP +- `location` 地理位置(预留) +- `loginAt` 登录时间戳(毫秒) +- `expireAt` 过期时间戳(毫秒) +- `active` 活跃状态 +- `token` 令牌原文 + +### 6.3 存储键规范 +- 索引:`APP:SESS_INDEX:{userId}` +- 会话:`APP:SESS:{userId}:{sessionId}`(TTL 与 token 同步) +- 事件通道:`APP:SESS_EVT:{userId}` + +### 6.4 调用示例(curl) +- 获取活跃会话: +```bash +curl -sS "http://localhost:8080/app/session/active" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- 终止指定会话: +```bash +curl -sS -X POST "http://localhost:8080/app/session/terminate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ "sessionId": "TOKEN_VALUE_TO_TERMINATE" }' +``` +- 会话续期(心跳): +```bash +curl -sS -X POST "http://localhost:8080/app/session/heartbeat" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ "sessionId": "CURRENT_TOKEN_VALUE" }' +``` +- 订阅会话事件(SSE): +```bash +curl -sS "http://localhost:8080/app/session/sse" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 6.5 优化建议 +- 统一设备标识头 `X-Device-Id`,策略层在登录时读取并写入会话,便于同账号多设备管理。 +- 事件命名与负载标准化:`:`(TYPE ∈ {login, logout, heartbeat, terminate})。 +- TTL 与 Sa-Token 同步:确保 `APP:SESS:{userId}:{sessionId}` 的过期时间与 Token 过期保持一致,避免“僵尸会话”。 + +--- + +## 7. Sys 在线用户管理 API + +- 基础路径:`/monitor/online` +- 鉴权:登录态(Sa-Token),需要权限 `monitor:online:*` + +### 7.1 接口列表 +- `GET /monitor/online/list` 获取在线用户监控列表(支持按IP、用户名筛选) +- `GET /monitor/online` 获取当前用户登录的在线设备列表 +- `DELETE /monitor/online/{tokenId}` 强退指定用户(需要权限 `monitor:online:forceLogout`) +- `DELETE /monitor/online/myself/{tokenId}` 强退当前用户的指定设备 + +### 7.2 在线用户数据结构(SysUserOnline) +- `tokenId` 会话编号(Token值) +- `userName` 用户名称 +- `deptName` 部门名称 +- `clientKey` 客户端标识 +- `deviceType` 设备类型 +- `ipaddr` 登录IP地址 +- `loginLocation` 登录地点 +- `browser` 浏览器类型 +- `os` 操作系统 +- `loginTime` 登录时间(时间戳,毫秒) + +### 7.3 调用示例(curl) +- 获取在线用户列表: +```bash +curl -sS "http://localhost:8080/monitor/online/list?ipaddr=127.0.0.1&userName=admin" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- 获取当前用户在线设备: +```bash +curl -sS "http://localhost:8080/monitor/online" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- 强退指定用户: +```bash +curl -sS -X DELETE "http://localhost:8080/monitor/online/TOKEN_VALUE" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- 强退当前用户的指定设备: +```bash +curl -sS -X DELETE "http://localhost:8080/monitor/online/myself/TOKEN_VALUE" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- **代码路径**:[SysUserOnlineController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysUserOnlineController.java) + +--- + +## 8. Sys 登录日志管理 API + +- 基础路径:`/monitor/logininfor` +- 鉴权:登录态(Sa-Token),需要权限 `monitor:logininfor:*` + +### 8.1 接口列表 +- `GET /monitor/logininfor/list` 获取系统访问记录列表(分页,支持按IP、状态、用户名、时间范围筛选) +- `POST /monitor/logininfor/export` 导出系统访问记录列表(需要权限 `monitor:logininfor:export`) +- `DELETE /monitor/logininfor/{infoIds}` 批量删除登录日志(需要权限 `monitor:logininfor:remove`) +- `DELETE /monitor/logininfor/clean` 清理系统访问记录(需要权限 `monitor:logininfor:remove`) +- `GET /monitor/logininfor/unlock/{userName}` 解锁指定用户的登录锁定(需要权限 `monitor:logininfor:unlock`) + +### 8.2 登录日志数据结构(SysLogininfor) +- `infoId` 日志ID +- `userName` 用户账号 +- `clientKey` 客户端标识 +- `deviceType` 设备类型 +- `status` 登录状态(0成功 1失败) +- `ipaddr` 登录IP地址 +- `loginLocation` 登录地点 +- `browser` 浏览器类型 +- `os` 操作系统 +- `msg` 提示消息 +- `loginTime` 访问时间 + +### 8.3 调用示例(curl) +- 获取登录日志列表: +```bash +curl -sS "http://localhost:8080/monitor/logininfor/list?ipaddr=127.0.0.1&status=0&userName=admin&beginTime=2024-01-01&endTime=2024-12-31&pageNum=1&pageSize=10" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- 导出登录日志: +```bash +curl -sS -X POST "http://localhost:8080/monitor/logininfor/export" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -o logininfor.xlsx +``` +- 批量删除登录日志: +```bash +curl -sS -X DELETE "http://localhost:8080/monitor/logininfor/1,2,3" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- 清理登录日志: +```bash +curl -sS -X DELETE "http://localhost:8080/monitor/logininfor/clean" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- 解锁用户登录: +```bash +curl -sS "http://localhost:8080/monitor/logininfor/unlock/admin" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` +- **代码路径**:[SysLogininforController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysLogininforController.java) + +--- + +## 9. 相关代码路径参考 + +### 9.1 核心控制器 +- **Sys登录控制器**:[AuthController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java) +- **App登录控制器**:[AppAuthController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java) +- **验证码控制器**:[CaptchaController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/controller/CaptchaController.java) + +### 9.2 核心服务 +- **Sys登录服务**:[SysLoginService](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/service/SysLoginService.java) +- **App登录服务**:[AppLoginService](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/AppLoginService.java) +- **Sys注册服务**:[SysRegisterService](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/service/SysRegisterService.java) + +### 9.3 登录策略 +- **Sys策略接口**:[IAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/service/IAuthStrategy.java) +- **App策略接口**:[IAppAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/IAppAuthStrategy.java) +- **密码策略**:[PasswordAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java), [AppPasswordAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppPasswordAuthStrategy.java) +- **短信策略**:[SmsAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java), [AppSmsAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSmsAuthStrategy.java) +- **邮箱策略**:[EmailAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/service/impl/EmailAuthStrategy.java), [AppEmailAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppEmailAuthStrategy.java) +- **第三方策略**:[SocialAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/web/service/impl/SocialAuthStrategy.java), [AppSocialAuthStrategy](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSocialAuthStrategy.java) + +### 9.4 管理接口 +- **在线用户管理**:[SysUserOnlineController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysUserOnlineController.java) +- **登录日志管理**:[SysLogininforController](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/monitor/SysLogininforController.java) +- **登录日志服务**:[SysLogininforService](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/SysLogininforService.java), [SysLogininforServiceImpl](file:///Users/lihaha/Documents/Projecte/PoJie/PoJie/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysLogininforServiceImpl.java) + +--- + +所有代码与配置参考项目既有实现与路径,确保可在 IDE 中点击跳转进行联查与验证。 diff --git a/ruoyi-server/pom.xml b/ruoyi-server/pom.xml new file mode 100644 index 0000000..158172f --- /dev/null +++ b/ruoyi-server/pom.xml @@ -0,0 +1,188 @@ + + + + org.dromara + PoJie + ${revision} + + 4.0.0 + jar + ruoyi-server + + + 服务启动入口 + + + + + + + + + + + + + + + + + + + + + + + + org.postgresql + postgresql + + + + + + + + + org.dromara + ruoyi-common-doc + + + + org.dromara + ruoyi-common-social + + + + org.dromara + ruoyi-common-ratelimiter + + + + org.dromara + ruoyi-common-mail + + + + + org.dromara + ruoyi-common-mybatis + + + + org.dromara + ruoyi-system + + + + org.dromara + ruoyi-member + ${revision} + + + + org.dromara + ruoyi-mp + + + + org.dromara + ruoyi-job + + + + + org.dromara + ruoyi-generator + + + + + org.dromara + ruoyi-workflow + + + + + org.dromara + ruoyi-knowledge + + + + + org.dromara + ruoyi-ai + + + + + org.dromara + ruoyi-chat + + + + + org.dromara + ruoyi-pay + + + + de.codecentric + spring-boot-admin-starter-client + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + + + + + + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + org.apache.maven.plugins + maven-war-plugin + ${maven-war-plugin.version} + + false + ${project.artifactId} + + + + + + diff --git a/ruoyi-server/src/main/java/org/dromara/PoJieApplication.java b/ruoyi-server/src/main/java/org/dromara/PoJieApplication.java new file mode 100644 index 0000000..33dae93 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/PoJieApplication.java @@ -0,0 +1,22 @@ +package org.dromara; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup; + +/** + * 启动程序 + * + * @author pengles + */ +@SpringBootApplication +public class PoJieApplication { + + public static void main(String[] args) { + SpringApplication application = new SpringApplication(PoJieApplication.class); + application.setApplicationStartup(new BufferingApplicationStartup(2048)); + application.run(args); + System.out.println("(♥◠‿◠)ノ゙ PoJie启动成功 ლ(´ڡ`ლ)゙"); + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/PoJieServletInitializer.java b/ruoyi-server/src/main/java/org/dromara/PoJieServletInitializer.java new file mode 100644 index 0000000..86a95a1 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/PoJieServletInitializer.java @@ -0,0 +1,18 @@ +package org.dromara; + +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; + +/** + * web容器中进行部署 + * + * @author pengles + */ +public class PoJieServletInitializer extends SpringBootServletInitializer { + + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { + return application.sources(PoJieApplication.class); + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java b/ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java new file mode 100644 index 0000000..7f15d55 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/controller/AppAuthController.java @@ -0,0 +1,321 @@ +package org.dromara.app.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import cn.hutool.core.codec.Base64; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import me.zhyd.oauth.utils.AuthStateUtils; +import org.dromara.app.domain.vo.AppConfigVo; +import org.dromara.app.service.AppLoginService; +import org.dromara.app.service.IAppAuthStrategy; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginBody; +import org.dromara.common.core.domain.model.RegisterBody; +import org.dromara.common.core.domain.model.SocialLoginBody; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.ratelimiter.annotation.RateLimiter; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.social.config.properties.SocialLoginConfigProperties; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.SocialUtils; +import org.dromara.member.domain.bo.AppUserBo; +import org.dromara.member.service.AppSocialAuthService; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.service.SysClientService; +import org.dromara.system.service.SysConfigService; +import org.dromara.web.domain.vo.LoginVo; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * App端认证控制器 + * + * @author pengles + */ +@Slf4j +@SaIgnore +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/auth") +public class AppAuthController { + + private final SocialProperties socialProperties; + private final AppLoginService appLoginService; + private final org.dromara.app.service.AppLoginRiskService appLoginRiskService; + private final SysClientService clientService; + private final SysConfigService configService; + private final AppSocialAuthService appSocialAuthService; + + /** + * App端登录方法 + * + * @param body 登录信息 + * @return 结果 + */ + // TODO: App端暂时不加密,先完善用户端的加密方法在加密 + // @ApiEncrypt + @PostMapping("/login") + public R login(@RequestBody String body) { + LoginBody loginBody = JsonUtils.parseObject(body, LoginBody.class); + ValidatorUtils.validate(loginBody); + // 授权类型和客户端id + String clientId = loginBody.getClientId(); + String grantType = loginBody.getGrantType(); + SysClientVo client = clientService.queryByClientId(clientId); + // 查询不到 client 或 client 内不包含 grantType + if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), grantType)) { + log.info("客户端id: {} 认证类型:{} 异常!.", clientId, grantType); + return R.fail(MessageUtils.message("auth.grant.type.error")); + } else if (!SystemConstants.NORMAL.equals(client.getStatus())) { + return R.fail(MessageUtils.message("auth.grant.type.blocked")); + } + // App端登录 + LoginVo loginVo = IAppAuthStrategy.authenticate(body, client, grantType); + + Long userId = LoginHelper.getUserId(); + // 记录登录信息 + appLoginRiskService.checkRisk(userId, LoginHelper.getLoginUser().getIpaddr()); + appLoginService.recordLoginInfo(userId, LoginHelper.getLoginUser().getIpaddr()); + + return R.ok(loginVo); + } + + /** + * App端退出登录 + * + * @return 结果 + */ + @PostMapping("/logout") + public R logout() { + appLoginService.logout(); + return R.ok("退出成功"); + } + + /** + * App端用户注册 + * + * @param user 用户信息 + * @return 结果 + */ + // TODO: App端暂时不加密,先完善用户端的加密方法在加密 + // @ApiEncrypt + @RateLimiter(key = "register", time = 60, count = 1) + @PostMapping("/register") + public R register(@Validated @RequestBody RegisterBody user) { + if (!configService.selectRegisterEnabled()) { + return R.fail("当前系统没有开启注册功能!"); + } + AppUserBo bo = new AppUserBo(); + bo.setUserName(user.getUsername()); + bo.setPassword(user.getPassword()); + bo.setNickName(user.getUsername()); + + // 校验是否已存在 + if (!appLoginService.checkUserNameUnique(bo.getUserName())) { + return R.fail("注册失败,账号已存在"); + } + + // 执行注册 + return appLoginService.register(bo) ? R.ok() : R.fail("注册失败"); + } + + /** + * 获取App端配置信息 + * + * @return 配置信息 + */ + @GetMapping("/config") + public R getConfig() { + AppConfigVo config = new AppConfigVo(); + config.setRegisterEnabled(configService.selectRegisterEnabled()); + // 可以添加更多App端特有的配置 + return R.ok(config); + } + + /** + * 获取登录二维码与票据 + */ + @GetMapping("/qrcode") + public R> getQrCode() { + String ticket = cn.hutool.core.util.IdUtil.fastSimpleUUID(); + RedisUtils.setCacheObject("APP_LOGIN_QR:" + ticket, "PENDING", java.time.Duration.ofMinutes(5)); + String url = "/wx/mp/qrcode/url?ticket=" + ticket; + java.util.Map res = new java.util.HashMap<>(); + res.put("ticket", ticket); + res.put("url", url); + return R.ok(res); + } + + /** + * 轮询二维码登录状态 + */ + @GetMapping("/qrcode/login") + public R pollQrLogin(@RequestParam String ticket) { + String key = "APP_LOGIN_QR:" + ticket; + String status = RedisUtils.getCacheObject(key); + if (status == null) { + return R.ok("EXPIRED"); + } + return R.ok(status); + } + + /** + * 绑定第三方账号 + * + * @param body 第三方账号信息 + * @return 结果 + */ + // TODO: App端暂时不加密,先完善前段的加密方法在加密 + // @ApiEncrypt + @PostMapping("/bind/social") + public R bindSocial(@RequestBody String body) { + SocialLoginBody socialLoginBody = JsonUtils.parseObject(body, SocialLoginBody.class); + ValidatorUtils.validate(socialLoginBody); + + Long userId = LoginHelper.getUserId(); + if (userId == null) { + return R.fail("请先登录"); + } + + try { + // 获取第三方登录信息 + AuthResponse response = SocialUtils.loginAuth( + socialLoginBody.getSource(), socialLoginBody.getSocialCode(), + socialLoginBody.getSocialState(), socialProperties); + AuthUser authUserData = response.getData(); + // 判断授权响应是否成功 + if (!response.ok()) { + return R.fail(response.getMsg()); + } + // 执行绑定逻辑 + appSocialAuthService.socialRegister(authUserData); + log.info("用户 {} 绑定第三方账号成功,平台: {}", userId, socialLoginBody.getSource()); + return R.ok("绑定成功"); + } catch (Exception e) { + log.error("绑定第三方账号失败", e); + return R.fail("绑定失败: " + e.getMessage()); + } + } + + /** + * 解绑第三方账号 + * + * @param platform 第三方平台 + * @return 结果 + */ + @PostMapping("/unbind/social") + public R unbindSocial(@RequestParam String platform) { + Long userId = LoginHelper.getUserId(); + if (userId == null) { + return R.fail("请先登录"); + } + + if (StringUtils.isBlank(platform)) { + return R.fail("平台参数不能为空"); + } + + try { + // 查找并删除绑定关系 + boolean success = appSocialAuthService.deleteByUserIdAndPlatform(userId, platform); + if (success) { + log.info("用户 {} 解绑第三方账号成功,平台: {}", userId, platform); + return R.ok("解绑成功"); + } else { + log.warn("用户 {} 尝试解绑不存在的第三方账号,平台: {}", userId, platform); + return R.fail("未找到绑定关系"); + } + } catch (Exception e) { + log.error("用户 {} 解绑第三方账号失败,平台: {}", userId, platform, e); + return R.fail("解绑失败: " + e.getMessage()); + } + } + + /** + * 获取App端第三方登录跳转URL + * + * @param source 登录来源 + * @param domain 域名 + * @return 结果 + */ + @GetMapping("/binding/{source}") + public R authBinding(@PathVariable("source") String source, @RequestParam String domain) { + SocialLoginConfigProperties obj = socialProperties.getType().get(source); + if (ObjectUtil.isNull(obj)) { + return R.fail(source + "平台账号暂不支持"); + } + AuthRequest authRequest = SocialUtils.getAuthRequest(source, socialProperties); + Map map = new HashMap<>(); + map.put("domain", domain); + map.put("state", AuthStateUtils.createState()); + String authorizeUrl = authRequest.authorize(Base64.encode(JsonUtils.toJsonString(map), StandardCharsets.UTF_8)); + return R.ok("操作成功", authorizeUrl); + } + + /** + * App端社交账号绑定回调(需要token) + * + * @param loginBody 请求体 + * @return 结果 + */ + @PostMapping("/social/binding") + public R socialBinding(@RequestBody SocialLoginBody loginBody) { + // 校验token + Long userId = LoginHelper.getUserId(); + if (userId == null) { + return R.fail("请先登录"); + } + + try { + // 获取第三方登录信息 + AuthResponse response = SocialUtils.loginAuth( + loginBody.getSource(), loginBody.getSocialCode(), + loginBody.getSocialState(), socialProperties); + AuthUser authUserData = response.getData(); + // 判断授权响应是否成功 + if (!response.ok()) { + return R.fail(response.getMsg()); + } + appSocialAuthService.socialRegister(authUserData); + return R.ok("绑定成功"); + } catch (Exception e) { + log.error("社交账号绑定失败", e); + return R.fail("绑定失败: " + e.getMessage()); + } + } + + /** + * 获取用户绑定的第三方账号列表 + * + * @return 绑定列表 + */ + @GetMapping("/social/list") + public R getSocialList() { + Long userId = LoginHelper.getUserId(); + if (userId == null) { + return R.fail("请先登录"); + } + + try { + var socialList = appSocialAuthService.queryByUserId(userId); + return R.ok(socialList); + } catch (Exception e) { + log.error("获取第三方账号绑定列表失败", e); + return R.fail("获取失败: " + e.getMessage()); + } + } + + +} diff --git a/ruoyi-server/src/main/java/org/dromara/app/controller/AppSessionController.java b/ruoyi-server/src/main/java/org/dromara/app/controller/AppSessionController.java new file mode 100644 index 0000000..174cd86 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/controller/AppSessionController.java @@ -0,0 +1,59 @@ +package org.dromara.app.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import lombok.RequiredArgsConstructor; +import org.dromara.app.session.AppSession; +import org.dromara.app.session.AppSessionManager; +import org.dromara.common.core.domain.R; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/session") +public class AppSessionController { + + private final AppSessionManager sessionManager; + + @GetMapping("/active") + @SaCheckLogin + public R> active() { + Long userId = LoginHelper.getUserId(); + return R.ok(sessionManager.list(userId)); + } + + @PostMapping("/terminate") + @SaCheckLogin + public R terminate(@RequestParam String sessionId) { + Long userId = LoginHelper.getUserId(); + return R.ok(sessionManager.terminate(userId, sessionId)); + } + + @PostMapping("/heartbeat") + @SaCheckLogin + public R heartbeat(@RequestParam String sessionId) { + Long userId = LoginHelper.getUserId(); + return R.ok(sessionManager.heartbeat(userId, sessionId, Duration.ofDays(30))); + } + + @GetMapping("/sse") + @SaCheckLogin + public SseEmitter sse() { + Long userId = LoginHelper.getUserId(); + SseEmitter emitter = new SseEmitter(Duration.ofHours(1).toMillis()); + String ch = "APP:SESS_EVT:" + userId; + org.dromara.common.redis.utils.RedisUtils.subscribe(ch, String.class, msg -> { + try { + emitter.send(SseEmitter.event().name("session").data(msg)); + } catch (IOException e) { + emitter.completeWithError(e); + } + }); + return emitter; + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/app/domain/AppLoginUser.java b/ruoyi-server/src/main/java/org/dromara/app/domain/AppLoginUser.java new file mode 100644 index 0000000..4b2ff0b --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/domain/AppLoginUser.java @@ -0,0 +1,118 @@ +package org.dromara.app.domain; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.dromara.common.core.domain.model.LoginUser; + +import java.io.Serial; +import java.util.Set; + +/** + * App登录用户身份权限 + * + * @author pengles + */ +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class AppLoginUser extends LoginUser { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + private Long userId; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 部门名 + */ + private String deptName; + + /** + * 用户唯一标识 + */ + private String token; + + /** + * 用户类型 + */ + private String userType; + + /** + * 登录时间 + */ + private Long loginTime; + + /** + * 过期时间 + */ + private Long expireTime; + + /** + * 登录IP地址 + */ + private String ipaddr; + + /** + * 登录地点 + */ + private String loginLocation; + + /** + * 浏览器类型 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; + + /** + * 菜单权限 + */ + private Set menuPermission; + + /** + * 角色权限 + */ + private Set rolePermission; + + /** + * 用户名 + */ + private String username; + + /** + * 用户昵称 + */ + private String nickname; + + /** + * 信息是否完善 + */ + private Boolean infoComplete; + + /** + * 获取登录id + */ + @Override + public String getLoginId() { + if (userType == null) { + throw new IllegalArgumentException("用户类型不能为空"); + } + if (userId == null) { + throw new IllegalArgumentException("用户ID不能为空"); + } + return userType + ":" + userId; + } + +} \ No newline at end of file diff --git a/ruoyi-server/src/main/java/org/dromara/app/domain/vo/AppConfigVo.java b/ruoyi-server/src/main/java/org/dromara/app/domain/vo/AppConfigVo.java new file mode 100644 index 0000000..6dd6a2f --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/domain/vo/AppConfigVo.java @@ -0,0 +1,13 @@ +package org.dromara.app.domain.vo; + +public class AppConfigVo { + private boolean registerEnabled; + + public boolean isRegisterEnabled() { + return registerEnabled; + } + + public void setRegisterEnabled(boolean registerEnabled) { + this.registerEnabled = registerEnabled; + } +} \ No newline at end of file diff --git a/ruoyi-server/src/main/java/org/dromara/app/listener/AppUserActionListener.java b/ruoyi-server/src/main/java/org/dromara/app/listener/AppUserActionListener.java new file mode 100644 index 0000000..92522f5 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/listener/AppUserActionListener.java @@ -0,0 +1,157 @@ +package org.dromara.app.listener; + +import cn.dev33.satoken.listener.SaTokenListener; +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.http.useragent.UserAgent; +import cn.hutool.http.useragent.UserAgentUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.app.service.AppLoginService; +import org.dromara.common.core.constant.CacheConstants; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.domain.dto.UserOnlineDTO; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ip.AddressUtils; +import org.dromara.common.log.event.LogininforEvent; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * App用户行为 侦听器的实现 + * + * @author pengles + */ +@RequiredArgsConstructor +@Component +@Slf4j +public class AppUserActionListener implements SaTokenListener { + + private final AppLoginService appLoginService; + + /** + * 每次登录时触发 + */ + @Override + public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter) { + UserAgent userAgent = UserAgentUtil.parse(ServletUtils.getRequest().getHeader("User-Agent")); + String ip = ServletUtils.getClientIP(); + UserOnlineDTO dto = new UserOnlineDTO(); + dto.setIpaddr(ip); + dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip)); + dto.setBrowser(userAgent.getBrowser().getName()); + dto.setOs(userAgent.getOs().getName()); + dto.setLoginTime(System.currentTimeMillis()); + dto.setTokenId(tokenValue); + String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY); + dto.setUserName(username); + dto.setClientKey((String) loginParameter.getExtra(LoginHelper.CLIENT_KEY)); + dto.setDeviceType(loginParameter.getDeviceType()); + dto.setDeptName((String) loginParameter.getExtra(LoginHelper.DEPT_NAME_KEY)); + if (loginParameter.getTimeout() == -1) { + RedisUtils.setCacheObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue, dto); + } else { + RedisUtils.setCacheObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue, dto, Duration.ofSeconds(loginParameter.getTimeout())); + } + // 记录登录日志 + LogininforEvent logininforEvent = new LogininforEvent(); + logininforEvent.setUsername(username); + logininforEvent.setStatus(Constants.LOGIN_SUCCESS); + logininforEvent.setMessage(MessageUtils.message("user.login.success")); + logininforEvent.setRequest(ServletUtils.getRequest()); + SpringUtils.context().publishEvent(logininforEvent); + // 更新App用户登录信息 + appLoginService.recordLoginInfo((Long) loginParameter.getExtra(LoginHelper.USER_KEY), ip); + log.info("app user doLogin, userId:{}, token:{}", loginId, tokenValue); + } + + /** + * 每次注销时触发 + */ + @Override + public void doLogout(String loginType, Object loginId, String tokenValue) { + RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue); + log.info("app user doLogout, userId:{}, token:{}", loginId, tokenValue); + } + + /** + * 每次被踢下线时触发 + */ + @Override + public void doKickout(String loginType, Object loginId, String tokenValue) { + RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue); + log.info("app user doKickout, userId:{}, token:{}", loginId, tokenValue); + } + + /** + * 每次被顶下线时触发 + */ + @Override + public void doReplaced(String loginType, Object loginId, String tokenValue) { + RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue); + log.info("app user doReplaced, userId:{}, token:{}", loginId, tokenValue); + } + + /** + * 每次被封禁时触发 + */ + @Override + public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) { + log.info("App用户被禁用: loginType={}, loginId={}, service={}, level={}, disableTime={}", + loginType, loginId, service, level, disableTime); + + // 删除在线用户信息 + String tokenValue = StpUtil.getTokenValueByLoginId(loginId); + if (StringUtils.isNotBlank(tokenValue)) { + RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue); + } + } + + /** + * 每次被解封时触发 + */ + @Override + public void doUntieDisable(String loginType, Object loginId, String service) { + } + + /** + * 每次打开二级认证时触发 + */ + @Override + public void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) { + } + + /** + * 每次关闭二级认证时触发 + */ + @Override + public void doCloseSafe(String loginType, String tokenValue, String service) { + } + + /** + * 每次创建Session时触发 + */ + @Override + public void doCreateSession(String id) { + } + + /** + * 每次注销Session时触发 + */ + @Override + public void doLogoutSession(String id) { + } + + /** + * 每次Token续期时触发 + */ + @Override + public void doRenewTimeout(String loginType, Object loginId, String tokenValue, long timeout) { + } +} \ No newline at end of file diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/AppLoginRiskService.java b/ruoyi-server/src/main/java/org/dromara/app/service/AppLoginRiskService.java new file mode 100644 index 0000000..e15d934 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/AppLoginRiskService.java @@ -0,0 +1,53 @@ +package org.dromara.app.service; + +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.common.core.utils.ip.AddressUtils; +import org.dromara.common.mail.utils.MailUtils; +import org.dromara.member.service.AppUserService; +import org.springframework.stereotype.Service; + +/** + * App登录异地风险检测服务 + * 比对用户上次登录IP归属地与本次登录IP归属地,若发生变化则进行日志与邮件告警。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AppLoginRiskService { + + private final AppUserService appUserService; + + /** + * 执行异地登录风险检测 + * @param userId 当前登录用户ID + * @param currentIp 本次登录IP + */ + public void checkRisk(Long userId, String currentIp) { + AppUserVo user = appUserService.queryById(userId); + if (user == null) { + return; + } + String lastIp = StrUtil.blankToDefault(user.getLoginIp(), ""); + String lastRegion = AddressUtils.getRealAddressByIP(lastIp); + String currentRegion = AddressUtils.getRealAddressByIP(currentIp); + boolean validLast = !AddressUtils.UNKNOWN_IP.equals(lastRegion) && !AddressUtils.UNKNOWN_ADDRESS.equals(lastRegion); + boolean validCurrent = !AddressUtils.UNKNOWN_IP.equals(currentRegion) && !AddressUtils.UNKNOWN_ADDRESS.equals(currentRegion); + boolean isLocal = AddressUtils.LOCAL_ADDRESS.equals(lastRegion) || AddressUtils.LOCAL_ADDRESS.equals(currentRegion); + if (validLast && validCurrent && !isLocal && !StrUtil.equals(lastRegion, currentRegion)) { + log.warn("App用户 {} 异地登录风险, 上次:{}({}), 本次:{}({})", userId, lastRegion, lastIp, currentRegion, currentIp); + String email = user.getEmail(); + if (StrUtil.isNotBlank(email)) { + String subject = "异地登录告警"; + String content = "您的账号检测到异地登录风险\n上次登录: " + lastRegion + " (" + lastIp + ")\n本次登录: " + currentRegion + " (" + currentIp + ")"; + try { + MailUtils.sendText(email, subject, content); + } catch (Exception e) { + log.error("发送异地登录告警邮件失败: {}", e.getMessage()); + } + } + } + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/AppLoginService.java b/ruoyi-server/src/main/java/org/dromara/app/service/AppLoginService.java new file mode 100644 index 0000000..d57a1af --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/AppLoginService.java @@ -0,0 +1,151 @@ +package org.dromara.app.service; + +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.util.ObjectUtil; +import org.dromara.common.core.utils.StringUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.member.domain.AppUser; +import org.dromara.member.domain.bo.AppUserBo; +import org.dromara.common.core.constant.CacheConstants; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.log.event.LogininforEvent; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.member.service.AppUserService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.function.Supplier; + +/** + * App登录校验方法 + * + * @author pengles + */ +@RequiredArgsConstructor +@Slf4j +@Service +public class AppLoginService { + + private final AppUserService appUserService; + private final ApplicationEventPublisher eventPublisher; + @Value("${user.password.maxRetryCount}") + private Integer maxRetryCount; + @Value("${user.password.lockTime}") + private Integer lockTime; + + /** + * 退出登录 + */ + public void logout() { + try { + AppLoginUser loginUser = LoginHelper.getLoginUser(); + if (ObjectUtil.isNotNull(loginUser)) { + StpUtil.logout(); + recordLogininfor(loginUser.getUsername(), Constants.LOGOUT, MessageUtils.message("user.logout.success")); + } + } catch (NotLoginException ignored) { + } + } + + /** + * 注册 + */ + public Boolean register(AppUserBo bo) { + return appUserService.register(bo); + } + + /** + * 校验用户名唯一 + */ + public boolean checkUserNameUnique(String userName) { + return appUserService.checkUserNameUnique(userName); + } + + /** + * 记录登录信息 + * + * @param username 用户名 + * @param status 状态 + * @param message 消息内容 + */ + public void recordLogininfor(String username, String status, String message) { + LogininforEvent logininforEvent = new LogininforEvent(); + logininforEvent.setUsername(username); + logininforEvent.setStatus(status); + logininforEvent.setMessage(message); + logininforEvent.setRequest(ServletUtils.getRequest()); + eventPublisher.publishEvent(logininforEvent); + } + + /** + * 构建登录用户 + */ + public AppLoginUser buildLoginUser(AppUser appUser) { + AppLoginUser loginUser = new AppLoginUser(); + loginUser.setUserId(appUser.getUserId()); + loginUser.setUsername(appUser.getUserName()); + loginUser.setUserType("app"); + loginUser.setInfoComplete(StringUtils.isNotBlank(appUser.getPhone())); + // 可以根据需要添加更多字段 + return loginUser; + } + + /** + * 记录登录信息 + * + * @param userId 用户ID + * @param ip IP地址 + */ + public void recordLoginInfo(Long userId, String ip) { + AppUser appUser = new AppUser(); + appUser.setUserId(userId); + appUser.setLoginIp(ip); + appUser.setLoginDate(LocalDateTime.now()); + appUserService.updateById(appUser); + } + + /** + * 登录校验 + */ + public void checkLogin(LoginType loginType, String username, Supplier supplier) { + String errorKey = CacheConstants.PWD_ERR_CNT_KEY + username; + String loginFail = Constants.LOGIN_FAIL; + + // 获取用户登录错误次数,默认为0 (可自定义限制策略 例如: key + username + ip) + int errorNumber = ObjectUtil.defaultIfNull(RedisUtils.getCacheObject(errorKey), 0); + // 锁定时间内登录 则踢出 + if (errorNumber >= maxRetryCount) { + recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime)); + throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime); + } + + if (supplier.get()) { + // 是否第一次 + errorNumber++; + recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitCount(), errorNumber)); + // 达到规定错误次数 则锁定登录 + if (errorNumber >= maxRetryCount) { + RedisUtils.setCacheObject(errorKey, errorNumber, Duration.ofMinutes(lockTime)); + throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime); + } else { + RedisUtils.setCacheObject(errorKey, errorNumber); + throw new UserException(loginType.getRetryLimitCount(), errorNumber); + } + } + + // 登录成功 清空错误次数 + RedisUtils.deleteObject(errorKey); + } + +} \ No newline at end of file diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/IAppAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/app/service/IAppAuthStrategy.java new file mode 100644 index 0000000..71deda1 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/IAppAuthStrategy.java @@ -0,0 +1,44 @@ +package org.dromara.app.service; + +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; + +/** + * App认证策略 + * + * @author pengles + */ +public interface IAppAuthStrategy { + + String BASE_NAME = "AppAuthStrategy"; + + /** + * 登录 + * + * @param body 登录对象 + * @param client 授权管理视图对象 + * @param grantType 授权类型 + * @return 登录验证信息 + */ + static LoginVo authenticate(String body, SysClientVo client, String grantType) { + // 授权类型和客户端id + String beanName = grantType + BASE_NAME; + if (!SpringUtils.containsBean(beanName)) { + throw new ServiceException("授权类型不正确!"); + } + IAppAuthStrategy instance = SpringUtils.getBean(beanName); + return instance.login(body, client); + } + + /** + * 登录 + * + * @param body 登录对象 + * @param client 授权管理视图对象 + * @return 登录验证信息 + */ + LoginVo login(String body, SysClientVo client); + +} \ No newline at end of file diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppEmailAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppEmailAuthStrategy.java new file mode 100644 index 0000000..ea790a9 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppEmailAuthStrategy.java @@ -0,0 +1,107 @@ +package org.dromara.app.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.app.service.AppLoginService; +import org.dromara.member.service.AppUserService; +import org.dromara.app.service.IAppAuthStrategy; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.domain.model.EmailLoginBody; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.user.CaptchaExpireException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; +import org.springframework.stereotype.Service; + +/** + * App邮箱认证策略 + * + * @author pengles + */ +@Slf4j +@Service("app_email" + IAppAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class AppEmailAuthStrategy implements IAppAuthStrategy { + + private final AppLoginService appLoginService; + private final AppUserService appUserService; + + @Override + public LoginVo login(String body, SysClientVo client) { + EmailLoginBody loginBody = JsonUtils.parseObject(body, EmailLoginBody.class); + ValidatorUtils.validate(loginBody); + String email = loginBody.getEmail(); + String emailCode = loginBody.getEmailCode(); + + AppUserVo user = loadUserByEmail(email); + appLoginService.checkLogin(LoginType.EMAIL, user.getUserName(), () -> !validateEmailCode(email, emailCode)); + // 构建App登录用户 + AppLoginUser loginUser = new AppLoginUser(); + loginUser.setUserId(user.getUserId()); + loginUser.setUsername(user.getUserName()); + loginUser.setUserType("app"); + + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + return loginVo; + } + + /** + * 校验邮箱验证码 + */ + private boolean validateEmailCode(String email, String emailCode) { + String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + email); + if (StringUtils.isBlank(code)) { + appLoginService.recordLogininfor(email, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); + throw new CaptchaExpireException(); + } + return code.equals(emailCode); + } + + /** + * 通过邮箱加载用户信息 + */ + private AppUserVo loadUserByEmail(String email) { + AppUserVo appUser = appUserService.selectUserByEmail(email); + if (ObjectUtil.isNull(appUser)) { + log.info("登录用户:{} 不存在.", email); + throw new UserException("user.not.exists", email); + } else if ("1".equals(appUser.getStatus())) { + log.info("登录用户:{} 已被停用.", email); + throw new UserException("user.blocked", email); + } + // 将 AppUser 转换为 AppUserVo + AppUserVo user = new AppUserVo(); + BeanUtil.copyProperties(appUser, user); + return user; + } + +} \ No newline at end of file diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppPasswordAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppPasswordAuthStrategy.java new file mode 100644 index 0000000..32073f3 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppPasswordAuthStrategy.java @@ -0,0 +1,166 @@ +package org.dromara.app.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.crypto.digest.BCrypt; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.member.domain.AppUser; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.app.service.AppLoginService; +import org.dromara.member.service.AppUserService; +import org.dromara.app.service.IAppAuthStrategy; +import org.dromara.app.session.AppSession; +import org.dromara.app.session.AppSessionManager; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.domain.model.PasswordLoginBody; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.user.CaptchaException; +import org.dromara.common.core.exception.user.CaptchaExpireException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.config.properties.CaptchaProperties; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +/** + * App密码认证策略 + * + * @author pengles + */ +@Slf4j +@Service("app_password" + IAppAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class AppPasswordAuthStrategy implements IAppAuthStrategy { + + private final CaptchaProperties captchaProperties; + private final AppLoginService appLoginService; + private final AppUserService appUserService; + private final AppSessionManager appSessionManager; + + @Override + public LoginVo login(String body, SysClientVo client) { + PasswordLoginBody loginBody = JsonUtils.parseObject(body, PasswordLoginBody.class); + ValidatorUtils.validate(loginBody); + String username = loginBody.getUsername(); + String password = loginBody.getPassword(); + String code = loginBody.getCode(); + String uuid = loginBody.getUuid(); + + boolean captchaEnabled = captchaProperties.getEnable(); + // 验证码开关 +// if (captchaEnabled) { +// validateCaptcha(username, code, uuid); +// } + + AppUser appUser = loadUserByUsername(username); + appLoginService.checkLogin(LoginType.PASSWORD, username, () -> !BCrypt.checkpw(password, appUser.getPassword())); + + // 创建App登录用户 + AppLoginUser loginUser = buildAppLoginUser(appUser); + + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + + // 注册会话 + try { + String ua = ServletUtils.getRequest().getHeader("User-Agent"); + String deviceId = ServletUtils.getRequest().getHeader("X-Device-Id"); + String ip = LoginHelper.getLoginUser().getIpaddr(); + AppSession s = new AppSession(); + s.setSessionId(StpUtil.getTokenValue()); + s.setUserId(loginUser.getUserId()); + s.setDeviceType(client.getDeviceType()); + s.setDeviceId(deviceId != null ? deviceId : UUID.randomUUID().toString()); + s.setUserAgent(ua); + s.setIp(ip); + s.setLocation(""); + long now = Instant.now().toEpochMilli(); + s.setLoginAt(now); + Long ttlSec = StpUtil.getTokenTimeout(); + s.setExpireAt(now + (ttlSec != null ? ttlSec * 1000 : 24 * 3600 * 1000)); + s.setActive(true); + s.setToken(StpUtil.getTokenValue()); + appSessionManager.register(s, Duration.ofSeconds(ttlSec != null ? ttlSec : 86400)); + } catch (Exception ignored) { + } + return loginVo; + } + + /** + * 校验验证码 + * + * @param username 用户名 + * @param code 验证码 + * @param uuid 唯一标识 + */ + private void validateCaptcha(String username, String code, String uuid) { + String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + StringUtils.blankToDefault(uuid, ""); + String captcha = RedisUtils.getCacheObject(verifyKey); + RedisUtils.deleteObject(verifyKey); + if (captcha == null) { + appLoginService.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); + throw new CaptchaExpireException(); + } + if (!code.equalsIgnoreCase(captcha)) { + appLoginService.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")); + throw new CaptchaException(); + } + } + + /** + * 根据用户名加载App用户 + */ + private AppUser loadUserByUsername(String username) { + AppUserVo user = appUserService.selectUserByUserName(username); + if (ObjectUtil.isNull(user)) { + log.info("登录用户:{} 不存在.", username); + throw new UserException("user.not.exists", username); + } else if ("1".equals(user.getStatus())) { + log.info("登录用户:{} 已被停用.", username); + throw new UserException("user.blocked", username); + } + return JsonUtils.parseObject(JsonUtils.toJsonString(user), AppUser.class); + } + + /** + * 构建App登录用户 + */ + private AppLoginUser buildAppLoginUser(AppUser appUser) { + AppLoginUser loginUser = new AppLoginUser(); + loginUser.setUserId(appUser.getUserId()); + loginUser.setUsername(appUser.getUserName()); + loginUser.setUserType("app"); + // 设置其他必要字段 + return loginUser; + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSmsAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSmsAuthStrategy.java new file mode 100644 index 0000000..1065515 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSmsAuthStrategy.java @@ -0,0 +1,114 @@ +package org.dromara.app.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.app.service.AppLoginService; +import org.dromara.member.service.AppUserService; +import org.dromara.app.service.IAppAuthStrategy; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.domain.model.SmsLoginBody; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.user.CaptchaExpireException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; +import org.springframework.stereotype.Service; + +/** + * App短信认证策略 + * + * @author pengles + */ +@Slf4j +@Service("app_sms" + IAppAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class AppSmsAuthStrategy implements IAppAuthStrategy { + + private final AppLoginService appLoginService; + private final AppUserService appUserService; + + @Override + public LoginVo login(String body, SysClientVo client) { + SmsLoginBody loginBody = JsonUtils.parseObject(body, SmsLoginBody.class); + ValidatorUtils.validate(loginBody); + String phonenumber = loginBody.getPhonenumber(); + String smsCode = loginBody.getSmsCode(); + + AppUserVo user = loadUserByPhonenumber(phonenumber); + appLoginService.checkLogin(LoginType.SMS, user.getUserName(), () -> !validateSmsCode(phonenumber, smsCode)); + // 构建App登录用户 + AppLoginUser loginUser = new AppLoginUser(); + loginUser.setUserId(user.getUserId()); + loginUser.setUsername(user.getUserName()); + loginUser.setUserType("app"); + + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + return loginVo; + } + + /** + * 校验短信验证码 + */ + private boolean validateSmsCode(String phonenumber, String smsCode) { + String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + phonenumber); + if (StringUtils.isBlank(code)) { + appLoginService.recordLogininfor(phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); + throw new CaptchaExpireException(); + } + return code.equals(smsCode); + } + + /** + * 通过手机号加载用户信息 + */ + private AppUserVo loadUserByPhonenumber(String phonenumber) { + AppUserVo appUser = appUserService.selectUserByPhone(phonenumber); + if (ObjectUtil.isNull(appUser)) { + log.info("登录用户:{} 不存在.", phonenumber); + throw new UserException("user.not.exists", phonenumber); + } else if ("1".equals(appUser.getStatus())) { + log.info("登录用户:{} 已被停用.", phonenumber); + throw new UserException("user.blocked", phonenumber); + } + // 将 AppUser 转换为 AppUserVo + AppUserVo user = new AppUserVo(); + BeanUtil.copyProperties(appUser, user); + if (ObjectUtil.isNull(user)) { + log.info("登录用户:{} 不存在.", phonenumber); + throw new UserException("user.not.exists", phonenumber); + } else if ("1".equals(user.getStatus())) { + log.info("登录用户:{} 已被停用.", phonenumber); + throw new UserException("user.blocked", phonenumber); + } + return user; + } + +} \ No newline at end of file diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSocialAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSocialAuthStrategy.java new file mode 100644 index 0000000..3f14152 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppSocialAuthStrategy.java @@ -0,0 +1,116 @@ +package org.dromara.app.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.member.domain.AppSocialAuth; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.app.service.AppLoginService; +import org.dromara.member.service.AppSocialAuthService; +import org.dromara.member.service.AppUserService; +import org.dromara.app.service.IAppAuthStrategy; +import org.dromara.common.core.domain.model.SocialLoginBody; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.SocialUtils; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; +import org.springframework.stereotype.Service; + +/** + * App第三方登录认证策略 + * + * @author pengles + */ +@Slf4j +@Service("app_social" + IAppAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class AppSocialAuthStrategy implements IAppAuthStrategy { + + private final SocialProperties socialProperties; + private final AppUserService appUserService; + private final AppLoginService appLoginService; + private final AppSocialAuthService appSocialAuthService; + + @Override + public LoginVo login(String body, SysClientVo client) { + SocialLoginBody loginBody = JsonUtils.parseObject(body, SocialLoginBody.class); + ValidatorUtils.validate(loginBody); + String source = loginBody.getSource(); + String socialCode = loginBody.getSocialCode(); + String socialState = loginBody.getSocialState(); + // 获取第三方登录信息 + AuthRequest authRequest = SocialUtils.getAuthRequest(source, socialProperties); + AuthCallback callback = AuthCallback.builder() + .code(socialCode) + .state(socialState) + .build(); + AuthResponse response = authRequest.login(callback); + if (!response.ok()) { + throw new ServiceException(response.getMsg()); + } + AuthUser authUserData = response.getData(); + + AppUserVo user = loadUserBySocial(authUserData.getSource(), authUserData.getUuid()); + assert user != null; + appLoginService.checkLogin(LoginType.PASSWORD, user.getUserName(), () -> ObjectUtil.isNull(user)); + // 构建App登录用户 + AppLoginUser loginUser = new AppLoginUser(); + loginUser.setUserId(user.getUserId()); + loginUser.setUsername(user.getUserName()); + loginUser.setUserType("app"); + + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + return loginVo; + } + + /** + * 通过第三方信息查找用户 + */ + private AppUserVo loadUserBySocial(String source, String openId) { + // 根据第三方平台信息查找绑定的App用户 + log.info("App端第三方登录查找用户,source: {}, openId: {}", source, openId); + + // 首先通过openId查找 + AppSocialAuth socialAuth = appSocialAuthService.queryByPlatformAndOpenId(source, openId); + + if (socialAuth != null && socialAuth.getUserId() != null) { + // 找到绑定的用户,返回用户信息 + AppUserVo user = appUserService.queryById(socialAuth.getUserId()); + if (user != null) { + log.info("通过openId找到绑定用户,userId: {}, userName: {}", user.getUserId(), user.getUserName()); + return user; + } + } + + log.warn("未找到与第三方账号绑定的App用户,source: {}, openId: {}", source, openId); + return null; + } + +} \ No newline at end of file diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMiniprogramAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMiniprogramAuthStrategy.java new file mode 100644 index 0000000..50f82e0 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMiniprogramAuthStrategy.java @@ -0,0 +1,118 @@ +package org.dromara.app.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.member.service.AppSocialAuthService; +import org.dromara.app.service.IAppAuthStrategy; +import org.dromara.common.core.domain.model.XcxLoginBody; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.SocialUtils; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; +import org.springframework.stereotype.Service; + +/** + * App端微信小程序认证策略 + * 支持微信小程序登录,通过微信小程序授权码获取用户信息并完成登录 + * + * @author pengles + */ +@Slf4j +@Service("app_wechat_miniprogram" + IAppAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class AppWechatMiniprogramAuthStrategy implements IAppAuthStrategy { + + private final SocialProperties socialProperties; + private final AppSocialAuthService appSocialAuthService; + + @Override + public LoginVo login(String body, SysClientVo client) { + XcxLoginBody loginBody = JsonUtils.parseObject(body, XcxLoginBody.class); + ValidatorUtils.validate(loginBody); + + // xcxCode 为小程序调用 wx.login 授权后获取 + String xcxCode = loginBody.getCode(); + // 多个小程序识别使用 + String appid = loginBody.getAppid(); + + // 校验 appid + appsecret + xcxCode 调用登录凭证校验接口 获取 session_key 与 openid + // 使用SocialUtils获取微信小程序的AuthRequest + AuthRequest authRequest = SocialUtils.getAuthRequest("wechat_miniprogram", socialProperties); + if (authRequest == null) { + throw new UserException("未配置微信小程序登录"); + } + AuthCallback authCallback = new AuthCallback(); + authCallback.setCode(xcxCode); + AuthResponse response = authRequest.login(authCallback); + + String openid = null; + String unionId = null; + if (response.ok()) { + AuthToken token = response.getData().getToken(); + openid = token.getOpenId(); + // 微信小程序只有关联到微信开放平台下之后才能获取到 unionId,因此unionId不一定能返回。 + unionId = token.getUnionId(); + } else { + throw new UserException("第三方登录失败,原因:" + response.getMsg()); + } + + AppUserVo user = loadUserByWxId("wechat_miniprogram", openid, unionId); + if (ObjectUtil.isNull(user)) { + throw new UserException("登录失败,请先绑定用户账号"); + } + + // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 + AppLoginUser loginUser = new AppLoginUser(); + loginUser.setUserId(user.getUserId()); + loginUser.setUsername(user.getUserName()); + loginUser.setNickname(user.getNickName()); + loginUser.setUserType("app"); + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + loginVo.setOpenid(openid); + return loginVo; + } + + /** + * 通过微信标识加载用户信息,优先 unionId,回退 openId + */ + private AppUserVo loadUserByWxId(String platform, String openid, String unionId) { + if (ObjectUtil.isNotEmpty(unionId)) { + AppUserVo byUnion = appSocialAuthService.selectUserByPlatformAndUnionId(platform, unionId); + if (ObjectUtil.isNotNull(byUnion)) { + return byUnion; + } + } + return appSocialAuthService.selectUserByPlatformAndOpenId(platform, openid); + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMpAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMpAuthStrategy.java new file mode 100644 index 0000000..b8885a5 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatMpAuthStrategy.java @@ -0,0 +1,130 @@ +package org.dromara.app.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.member.service.AppSocialAuthService; +import org.dromara.app.service.IAppAuthStrategy; +import org.dromara.common.core.domain.model.SocialLoginBody; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.SocialUtils; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; +import org.springframework.stereotype.Service; + +/** + * App端微信公众号认证策略 + * 处理微信公众号环境下的OAuth2登录逻辑 + * 依赖 justauth.type.wechat_mp 配置项 + * + * @author pengles + */ +@Slf4j +@Service("app_wechat_mp" + IAppAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class AppWechatMpAuthStrategy implements IAppAuthStrategy { + + private final SocialProperties socialProperties; + private final AppSocialAuthService appSocialAuthService; + + /** + * 微信公众号登录核心逻辑 + * + * @param body 登录请求体,包含 socialCode 和 socialState + * @param client 客户端配置 + * @return 登录结果 LoginVo + */ + @Override + public LoginVo login(String body, SysClientVo client) { + SocialLoginBody loginBody = JsonUtils.parseObject(body, SocialLoginBody.class); + ValidatorUtils.validate(loginBody); + + String code = loginBody.getSocialCode(); + String state = loginBody.getSocialState(); + + // 使用SocialUtils获取微信公众号的AuthRequest + // 对应 application.yml 中 justauth.type.wechat_mp 的配置 + AuthRequest authRequest = SocialUtils.getAuthRequest("wechat_mp", socialProperties); + if (authRequest == null) { + throw new UserException("未配置微信公众号登录"); + } + + // 构造回调参数 + AuthCallback authCallback = new AuthCallback(); + authCallback.setCode(code); + authCallback.setState(state); + // 执行第三方登录请求,换取 Access Token 和 User Info + AuthResponse response = authRequest.login(authCallback); + + String openid; + String unionId = null; + if (response.ok()) { + AuthUser authUser = response.getData(); + openid = authUser.getUuid(); + if (authUser.getToken() != null) { + unionId = authUser.getToken().getUnionId(); + } + } else { + throw new UserException("第三方登录失败,原因:" + response.getMsg()); + } + + // 根据 OpenId 或 UnionId 查找系统用户 + AppUserVo user = loadUserByWxId("wechat_mp", openid, unionId); + if (ObjectUtil.isNull(user)) { + throw new UserException("登录失败,请先绑定用户账号"); + } + + // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 + AppLoginUser loginUser = new AppLoginUser(); + loginUser.setUserId(user.getUserId()); + loginUser.setUsername(user.getUserName()); + loginUser.setNickname(user.getNickName()); + loginUser.setUserType("app"); + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + loginVo.setOpenid(openid); + return loginVo; + } + + /** + * 通过微信标识加载用户信息,优先使用 unionId(跨应用统一标识),缺失时回退使用 openId + * @param platform 平台标识(wechat_mp / wechat_open / wechat_miniprogram) + * @param openid 微信 openId + * @param unionId 微信 unionId(可能为空) + */ + private AppUserVo loadUserByWxId(String platform, String openid, String unionId) { + if (ObjectUtil.isNotEmpty(unionId)) { + AppUserVo byUnion = appSocialAuthService.selectUserByPlatformAndUnionId(platform, unionId); + if (ObjectUtil.isNotNull(byUnion)) { + return byUnion; + } + } + return appSocialAuthService.selectUserByPlatformAndOpenId(platform, openid); + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatOpenAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatOpenAuthStrategy.java new file mode 100644 index 0000000..b545199 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/service/impl/AppWechatOpenAuthStrategy.java @@ -0,0 +1,128 @@ +package org.dromara.app.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.member.domain.vo.AppUserVo; +import org.dromara.member.service.AppSocialAuthService; +import org.dromara.app.service.IAppAuthStrategy; +import org.dromara.common.core.domain.model.SocialLoginBody; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.SocialUtils; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; +import org.springframework.stereotype.Service; + +/** + * App端微信开放平台认证策略 + * 处理微信开放平台(APP端/网站应用)环境下的OAuth2登录逻辑 + * 依赖 justauth.type.wechat_open 配置项 + * 通常用于APP原生微信登录 + * + * @author pengles + */ +@Slf4j +@Service("app_wechat_open" + IAppAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class AppWechatOpenAuthStrategy implements IAppAuthStrategy { + + private final SocialProperties socialProperties; + private final AppSocialAuthService appSocialAuthService; + + /** + * 微信开放平台登录核心逻辑 + * + * @param body 登录请求体,包含 socialCode 和 socialState + * @param client 客户端配置 + * @return 登录结果 LoginVo + */ + @Override + public LoginVo login(String body, SysClientVo client) { + SocialLoginBody loginBody = JsonUtils.parseObject(body, SocialLoginBody.class); + ValidatorUtils.validate(loginBody); + + String code = loginBody.getSocialCode(); + String state = loginBody.getSocialState(); + + // 使用SocialUtils获取微信开放平台的AuthRequest + // 对应 application.yml 中 justauth.type.wechat_open 的配置 + AuthRequest authRequest = SocialUtils.getAuthRequest("wechat_open", socialProperties); + if (authRequest == null) { + throw new UserException("未配置微信开放平台登录"); + } + + // 构造回调参数 + AuthCallback authCallback = new AuthCallback(); + authCallback.setCode(code); + authCallback.setState(state); + // 执行第三方登录请求,换取 Access Token 和 User Info + AuthResponse response = authRequest.login(authCallback); + + String openid; + String unionId = null; + if (response.ok()) { + AuthUser authUser = response.getData(); + openid = authUser.getUuid(); + if (authUser.getToken() != null) { + unionId = authUser.getToken().getUnionId(); + } + } else { + throw new UserException("第三方登录失败,原因:" + response.getMsg()); + } + + // 根据 OpenId 或 UnionId 查找系统用户 + AppUserVo user = loadUserByWxId("wechat_open", openid, unionId); + if (ObjectUtil.isNull(user)) { + throw new UserException("登录失败,请先绑定用户账号"); + } + + // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 + AppLoginUser loginUser = new AppLoginUser(); + loginUser.setUserId(user.getUserId()); + loginUser.setUsername(user.getUserName()); + loginUser.setNickname(user.getNickName()); + loginUser.setUserType("app"); + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + loginVo.setOpenid(openid); + return loginVo; + } + + /** + * 通过微信标识加载用户信息,优先 unionId,回退 openId + */ + private AppUserVo loadUserByWxId(String platform, String openid, String unionId) { + if (ObjectUtil.isNotEmpty(unionId)) { + AppUserVo byUnion = appSocialAuthService.selectUserByPlatformAndUnionId(platform, unionId); + if (ObjectUtil.isNotNull(byUnion)) { + return byUnion; + } + } + return appSocialAuthService.selectUserByPlatformAndOpenId(platform, openid); + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/app/session/AppSession.java b/ruoyi-server/src/main/java/org/dromara/app/session/AppSession.java new file mode 100644 index 0000000..1304aee --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/session/AppSession.java @@ -0,0 +1,19 @@ +package org.dromara.app.session; + +import lombok.Data; + +@Data +public class AppSession { + private String sessionId; + private Long userId; + private String deviceType; + private String deviceId; + private String userAgent; + private String ip; + private String location; + private long loginAt; + private long expireAt; + private boolean active; + private String token; +} + diff --git a/ruoyi-server/src/main/java/org/dromara/app/session/AppSessionManager.java b/ruoyi-server/src/main/java/org/dromara/app/session/AppSessionManager.java new file mode 100644 index 0000000..9d120bf --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/app/session/AppSessionManager.java @@ -0,0 +1,80 @@ +package org.dromara.app.session; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AppSessionManager { + private static final String KEY_INDEX_FMT = "APP:SESS_INDEX:%s"; + private static final String KEY_SESS_FMT = "APP:SESS:%s:%s"; + private static final String CH_EVT_FMT = "APP:SESS_EVT:%s"; + + public void register(AppSession s, Duration ttl) { + String indexKey = indexKey(s.getUserId()); + String sessKey = sessKey(s.getUserId(), s.getSessionId()); + RedisUtils.addCacheSet(indexKey, sessKey); + RedisUtils.setCacheObject(sessKey, JsonUtils.toJsonString(s), ttl); + publishEvent(s.getUserId(), "REGISTER", s.getSessionId()); + } + + public List list(Long userId) { + String indexKey = indexKey(userId); + Set keys = RedisUtils.getCacheSet(indexKey); + List out = new ArrayList<>(); + if (keys != null) { + for (String k : keys) { + String val = RedisUtils.getCacheObject(k); + if (val != null) { + out.add(JsonUtils.parseObject(val, AppSession.class)); + } + } + } + return out; + } + + public boolean terminate(Long userId, String sessionId) { + String indexKey = indexKey(userId); + String sessKey = sessKey(userId, sessionId); + RedisUtils.deleteObject(sessKey); + try { + org.redisson.api.RSet set = org.dromara.common.redis.utils.RedisUtils.getClient().getSet(indexKey); + set.remove(sessKey); + } catch (Exception ignored) { + } + publishEvent(userId, "TERMINATE", sessionId); + return true; + } + + public boolean heartbeat(Long userId, String sessionId, Duration ttlExtend) { + String sessKey = sessKey(userId, sessionId); + String val = RedisUtils.getCacheObject(sessKey); + if (val == null) return false; + AppSession s = JsonUtils.parseObject(val, AppSession.class); + RedisUtils.setCacheObject(sessKey, JsonUtils.toJsonString(s), ttlExtend); + publishEvent(userId, "HEARTBEAT", sessionId); + return true; + } + + public void publishEvent(Long userId, String type, String sessionId) { + String ch = String.format(CH_EVT_FMT, userId); + String payload = type + ":" + sessionId; + RedisUtils.publish(ch, payload); + } + + private String indexKey(Long userId) { + return String.format(KEY_INDEX_FMT, userId); + } + private String sessKey(Long userId, String sessionId) { + return String.format(KEY_SESS_FMT, userId, sessionId); + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/config/AppInfoCompletionFilter.java b/ruoyi-server/src/main/java/org/dromara/web/config/AppInfoCompletionFilter.java new file mode 100644 index 0000000..780778d --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/config/AppInfoCompletionFilter.java @@ -0,0 +1,73 @@ +package org.dromara.web.config; + +import cn.dev33.satoken.exception.NotLoginException; +import org.dromara.app.domain.AppLoginUser; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.util.AntPathMatcher; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; + +/** + * App用户信息补全过滤器 + * + * @author Pair-Programmer + */ +public class AppInfoCompletionFilter implements Filter { + + @Value("#{'${app.auth.whitelist:/app/user/profile,/app/user/bind,/app/auth/logout}'.split(',')}") + private List whitelist; + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + String uri = httpRequest.getRequestURI(); + + // 仅处理App端请求 + if (StringUtils.startsWith(uri, "/app")) { + try { + // 尝试获取登录用户,若未登录会抛出异常或返回null + LoginUser loginUser = LoginHelper.getLoginUser(); + + // 仅针对App登录用户进行检查 + if (loginUser instanceof AppLoginUser appUser) { + // 如果信息不完善 (infoComplete 为 false 或 null) + if (Boolean.FALSE.equals(appUser.getInfoComplete())) { + // 检查白名单 + boolean isAllowed = false; + if (whitelist != null) { + for (String pattern : whitelist) { + if (pathMatcher.match(pattern.trim(), uri)) { + isAllowed = true; + break; + } + } + } + + if (!isAllowed) { + ServletUtils.renderString((HttpServletResponse) response, + JsonUtils.toJsonString(R.fail(403, "用户信息未补全,请先完善信息"))); + return; + } + } + } + } catch (Exception ignored) { + // 未登录或其他异常(如Token无效),放行交由后续认证过滤器或拦截器处理 + } + } + + chain.doFilter(request, response); + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/config/RiskInterceptor.java b/ruoyi-server/src/main/java/org/dromara/web/config/RiskInterceptor.java new file mode 100644 index 0000000..fc6869d --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/config/RiskInterceptor.java @@ -0,0 +1,52 @@ +package org.dromara.web.config; + +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.util.ObjectUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.dromara.common.core.domain.R; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 风险控制拦截器 + * + * @author Pair-Programmer + */ +public class RiskInterceptor implements HandlerInterceptor { + + public static final String RISK_LOCKED_KEY = "RISK_LOCKED"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 仅处理登录用户 + if (!StpUtil.isLogin()) { + return true; + } + + // 检查 Session 是否有风险锁 + Object isLocked = StpUtil.getSession().get(RISK_LOCKED_KEY); + if (ObjectUtil.isNotNull(isLocked) && (Boolean) isLocked) { + String uri = request.getRequestURI(); + + // 白名单:改密接口、登出接口、获取个人信息(用于页面展示) + // 注意:具体路径需根据实际 Controller 调整 + if (StringUtils.equalsAnyIgnoreCase(uri, + "/system/user/profile/updatePwd", + "/auth/logout", + "/system/user/profile" + )) { + return true; + } + + // 拦截并返回 403 + ServletUtils.renderString(response, + JsonUtils.toJsonString(R.fail(403, "检测到异常登录,请强制修改密码后继续操作"))); + return false; + } + + return true; + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/config/WebMvcPlusConfig.java b/ruoyi-server/src/main/java/org/dromara/web/config/WebMvcPlusConfig.java new file mode 100644 index 0000000..59bd292 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/config/WebMvcPlusConfig.java @@ -0,0 +1,41 @@ +package org.dromara.web.config; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 扩展配置 + * + * @author Pair-Programmer + */ +@Configuration +public class WebMvcPlusConfig implements WebMvcConfigurer { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 注册风险控制拦截器 + registry.addInterceptor(new RiskInterceptor()) + .addPathPatterns("/system/**", "/monitor/**", "/tool/**"); + } + + @Bean + public AppInfoCompletionFilter appInfoCompletionFilter() { + return new AppInfoCompletionFilter(); + } + + @Bean + public FilterRegistrationBean appInfoCompletionFilterRegistration(AppInfoCompletionFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.addUrlPatterns("/app/*"); + registration.setName("appInfoCompletionFilter"); + // 顺序需在 Sa-Token 认证过滤器之后,但在业务逻辑之前 + // Sa-Token 拦截器在 Interceptor 层,Filter 在 Servlet 层。 + // 但 LoginHelper 需要 Token,Token 在 Header 里,随时可取。 + // 关键是 Filter 需要在请求处理前执行。 + registration.setOrder(100); + return registration; + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/config/properties/RiskControlProperties.java b/ruoyi-server/src/main/java/org/dromara/web/config/properties/RiskControlProperties.java new file mode 100644 index 0000000..2326501 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/config/properties/RiskControlProperties.java @@ -0,0 +1,41 @@ +package org.dromara.web.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 风险控制配置 + * + * @author Pair-Programmer + */ +@Data +@Component +@ConfigurationProperties(prefix = "sys.risk") +public class RiskControlProperties { + + /** + * 是否开启风险检测 + */ + private Boolean enabled = true; + + /** + * 风险阈值 (0-100) + */ + private Integer threshold = 60; + + /** + * 异地登录权重 + */ + private Integer geoWeight = 50; + + /** + * 新设备权重 + */ + private Integer deviceWeight = 30; + + /** + * 非工作时间权重 (00:00-06:00) + */ + private Integer timeWeight = 10; +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java b/ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java new file mode 100644 index 0000000..981581a --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/controller/AuthController.java @@ -0,0 +1,208 @@ +package org.dromara.web.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.codec.Base64; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import me.zhyd.oauth.utils.AuthStateUtils; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginBody; +import org.dromara.common.core.domain.model.RegisterBody; +import org.dromara.common.core.domain.model.SocialLoginBody; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.encrypt.annotation.ApiEncrypt; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.social.config.properties.SocialLoginConfigProperties; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.SocialUtils; +import org.dromara.common.sse.dto.SseMessageDto; +import org.dromara.common.sse.utils.SseMessageUtils; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.service.SysClientService; +import org.dromara.system.service.SysConfigService; +import org.dromara.system.service.SysSocialService; +import org.dromara.web.domain.vo.LoginVo; +import org.dromara.web.service.IAuthStrategy; +import org.dromara.web.service.SysLoginService; +import org.dromara.web.service.SysRegisterService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 认证 + * + * @author pengles + */ +@Slf4j +@SaIgnore +@RequiredArgsConstructor +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final SocialProperties socialProperties; + private final SysLoginService loginService; + private final SysRegisterService registerService; + private final SysConfigService configService; + private final SysSocialService socialUserService; + private final SysClientService clientService; + private final ScheduledExecutorService scheduledExecutorService; + + + /** + * 登录方法 + * + * @param body 登录信息 + * @return 结果 + */ + @ApiEncrypt + @PostMapping("/login") + public R login(@RequestBody String body) { + LoginBody loginBody = JsonUtils.parseObject(body, LoginBody.class); + ValidatorUtils.validate(loginBody); + // 授权类型和客户端id + String clientId = loginBody.getClientId(); + String grantType = loginBody.getGrantType(); + SysClientVo client = clientService.queryByClientId(clientId); + // 查询不到 client 或 client 内不包含 grantType + if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), grantType)) { + log.info("客户端id: {} 认证类型:{} 异常!.", clientId, grantType); + return R.fail(MessageUtils.message("auth.grant.type.error")); + } else if (!SystemConstants.NORMAL.equals(client.getStatus())) { + return R.fail(MessageUtils.message("auth.grant.type.blocked")); + } + // 登录 + LoginVo loginVo = IAuthStrategy.login(body, client, grantType); + + Long userId = LoginHelper.getUserId(); + scheduledExecutorService.schedule(() -> { + SseMessageDto dto = new SseMessageDto(); + dto.setMessage("欢迎登录RuoYi-Vue-Plus后台管理系统"); + dto.setUserIds(List.of(userId)); + SseMessageUtils.publishMessage(dto); + }, 5, TimeUnit.SECONDS); + return R.ok(loginVo); + } + + /** + * 获取跳转URL + * + * @param source 登录来源 + * @return 结果 + */ + @GetMapping("/binding/{source}") + public R authBinding(@PathVariable("source") String source, @RequestParam String domain) { + SocialLoginConfigProperties obj = socialProperties.getType().get(source); + if (ObjectUtil.isNull(obj)) { + return R.fail(source + "平台账号暂不支持"); + } + AuthRequest authRequest = SocialUtils.getAuthRequest(source, socialProperties); + Map map = new HashMap<>(); + map.put("domain", domain); + map.put("state", AuthStateUtils.createState()); + String authorizeUrl = authRequest.authorize(Base64.encode(JsonUtils.toJsonString(map), StandardCharsets.UTF_8)); + return R.ok("操作成功", authorizeUrl); + } + + /** + * 前端回调绑定授权(需要token) + * + * @param loginBody 请求体 + * @return 结果 + */ + @PostMapping("/social/callback") + public R socialCallback(@RequestBody SocialLoginBody loginBody) { + // 校验token + StpUtil.checkLogin(); + // 获取第三方登录信息 + AuthResponse response = SocialUtils.loginAuth( + loginBody.getSource(), loginBody.getSocialCode(), + loginBody.getSocialState(), socialProperties); + AuthUser authUserData = response.getData(); + // 判断授权响应是否成功 + if (!response.ok()) { + return R.fail(response.getMsg()); + } + loginService.socialRegister(authUserData); + return R.ok(); + } + + + /** + * 取消授权(需要token) + * + * @param socialId socialId + */ + @DeleteMapping(value = "/unlock/{socialId}") + public R unlockSocial(@PathVariable Long socialId) { + // 校验token + StpUtil.checkLogin(); + Boolean rows = socialUserService.deleteWithValidById(socialId); + return rows ? R.ok() : R.fail("取消授权失败"); + } + + + /** + * 退出登录 + */ + @PostMapping("/logout") + public R logout() { + loginService.logout(); + return R.ok("退出成功"); + } + + /** + * 用户注册 + */ + @ApiEncrypt + @PostMapping("/register") + public R register(@Validated @RequestBody RegisterBody user) { + if (!configService.selectRegisterEnabled()) { + return R.fail("当前系统没有开启注册功能!"); + } + registerService.register(user); + return R.ok(); + } + + /** + * 登录页面租户下拉框(兼容多租户前端) + */ + @GetMapping("/tenant/list") + public R tenantList() { + return R.ok(TenantListVo.DISABLED_TENANT); + } + + /** + * 租户下拉列表 + * + * @param tenantEnabled 租户开关 + */ + public record TenantListVo(boolean tenantEnabled) { + + /** + * 禁用租户,租户开关响应false(即关闭),让前端关闭租户功能 + */ + public static final TenantListVo DISABLED_TENANT = TenantListVo.of(false); + + public static TenantListVo of(boolean tenantEnabled) { + return new TenantListVo(tenantEnabled); + } + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/controller/CaptchaController.java b/ruoyi-server/src/main/java/org/dromara/web/controller/CaptchaController.java new file mode 100644 index 0000000..afd6fe5 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/controller/CaptchaController.java @@ -0,0 +1,154 @@ +package org.dromara.web.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import cn.hutool.captcha.AbstractCaptcha; +import cn.hutool.captcha.generator.CodeGenerator; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.RandomUtil; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.reflect.ReflectUtils; +import org.dromara.common.mail.config.properties.MailProperties; +import org.dromara.common.mail.utils.MailUtils; +import org.dromara.common.ratelimiter.annotation.RateLimiter; +import org.dromara.common.ratelimiter.enums.LimitType; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.web.config.properties.CaptchaProperties; +import org.dromara.common.web.enums.CaptchaType; +import org.dromara.sms4j.api.SmsBlend; +import org.dromara.sms4j.api.entity.SmsResponse; +import org.dromara.sms4j.core.factory.SmsFactory; +import org.dromara.web.domain.vo.CaptchaVo; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.LinkedHashMap; + +/** + * 验证码操作处理 + * + * @author pengles + */ +@SaIgnore +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +public class CaptchaController { + + private final CaptchaProperties captchaProperties; + private final MailProperties mailProperties; + + /** + * 短信验证码 + * + * @param phonenumber 用户手机号 + */ + @RateLimiter(key = "#phonenumber", time = 60, count = 1) + @GetMapping("/resource/sms/code") + public R smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) { + String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber; + String code = RandomUtil.randomNumbers(4); + RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); + // 验证码模板id 自行处理 (查数据库或写死均可) + String templateId = ""; + LinkedHashMap map = new LinkedHashMap<>(1); + map.put("code", code); + SmsBlend smsBlend = SmsFactory.getSmsBlend("config1"); + SmsResponse smsResponse = smsBlend.sendMessage(phonenumber, templateId, map); + if (!smsResponse.isSuccess()) { + log.error("验证码短信发送异常 => {}", smsResponse); + return R.fail(smsResponse.getData().toString()); + } + return R.ok(); + } + + /** + * 邮箱验证码 + * + * @param email 邮箱 + */ + @GetMapping("/resource/email/code") + public R emailCode(@NotBlank(message = "{user.email.not.blank}") String email) { + if (!mailProperties.getEnabled()) { + return R.fail("当前系统没有开启邮箱功能!"); + } + SpringUtils.getAopProxy(this).emailCodeImpl(email); + return R.ok(); + } + + /** + * 邮箱验证码 + * 独立方法避免验证码关闭之后仍然走限流 + */ + @RateLimiter(key = "#email", time = 60, count = 1) + public void emailCodeImpl(String email) { + String key = GlobalConstants.CAPTCHA_CODE_KEY + email; + String code = RandomUtil.randomNumbers(4); + RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); + try { + MailUtils.sendText(email, "登录验证码", "您本次验证码为:" + code + ",有效性为" + Constants.CAPTCHA_EXPIRATION + "分钟,请尽快填写。"); + } catch (Exception e) { + log.error("验证码短信发送异常 => {}", e.getMessage()); + throw new ServiceException(e.getMessage()); + } + } + + /** + * 生成验证码 + */ + @GetMapping("/auth/code") + public R getCode() { + boolean captchaEnabled = captchaProperties.getEnable(); + if (!captchaEnabled) { + CaptchaVo captchaVo = new CaptchaVo(); + captchaVo.setCaptchaEnabled(false); + return R.ok(captchaVo); + } + return R.ok(SpringUtils.getAopProxy(this).getCodeImpl()); + } + + /** + * 生成验证码 + * 独立方法避免验证码关闭之后仍然走限流 + */ + @RateLimiter(time = 60, count = 10, limitType = LimitType.IP) + public CaptchaVo getCodeImpl() { + // 保存验证码信息 + String uuid = IdUtil.simpleUUID(); + String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + uuid; + // 生成验证码 + CaptchaType captchaType = captchaProperties.getType(); + boolean isMath = CaptchaType.MATH == captchaType; + Integer length = isMath ? captchaProperties.getNumberLength() : captchaProperties.getCharLength(); + CodeGenerator codeGenerator = ReflectUtils.newInstance(captchaType.getClazz(), length); + AbstractCaptcha captcha = SpringUtils.getBean(captchaProperties.getCategory().getClazz()); + captcha.setGenerator(codeGenerator); + captcha.createCode(); + // 如果是数学验证码,使用SpEL表达式处理验证码结果 + String code = captcha.getCode(); + if (isMath) { + ExpressionParser parser = new SpelExpressionParser(); + Expression exp = parser.parseExpression(StringUtils.remove(code, "=")); + code = exp.getValue(String.class); + } + RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); + CaptchaVo captchaVo = new CaptchaVo(); + captchaVo.setUuid(uuid); + captchaVo.setImg(captcha.getImageBase64()); + return captchaVo; + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/controller/IndexController.java b/ruoyi-server/src/main/java/org/dromara/web/controller/IndexController.java new file mode 100644 index 0000000..0777fd2 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/controller/IndexController.java @@ -0,0 +1,28 @@ +package org.dromara.web.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 首页 + * + * @author pengles + */ +@SaIgnore +@RequiredArgsConstructor +@RestController +public class IndexController { + + /** + * 访问首页,提示语 + */ + @GetMapping("/") + public String index() { + return StringUtils.format("欢迎使用{}后台管理框架,请通过前端地址访问。", SpringUtils.getApplicationName()); + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/domain/vo/CaptchaVo.java b/ruoyi-server/src/main/java/org/dromara/web/domain/vo/CaptchaVo.java new file mode 100644 index 0000000..8160b58 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/domain/vo/CaptchaVo.java @@ -0,0 +1,28 @@ +package org.dromara.web.domain.vo; + +import lombok.Data; + +/** + * 验证码信息 + * + * @author pengles + */ +@Data +public class CaptchaVo { + + /** + * 是否开启验证码 + */ + private Boolean captchaEnabled = true; + + /** + * 验证码唯一标识 + */ + private String uuid; + + /** + * 验证码图片 + */ + private String img; + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/domain/vo/LoginVo.java b/ruoyi-server/src/main/java/org/dromara/web/domain/vo/LoginVo.java new file mode 100644 index 0000000..0b0afd7 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/domain/vo/LoginVo.java @@ -0,0 +1,54 @@ +package org.dromara.web.domain.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 登录验证信息 + * + * @author pengles + */ +@Data +public class LoginVo { + + /** + * 授权令牌 + */ + @JsonProperty("access_token") + private String accessToken; + + /** + * 刷新令牌 + */ + @JsonProperty("refresh_token") + private String refreshToken; + + /** + * 授权令牌 access_token 的有效期 + */ + @JsonProperty("expire_in") + private Long expireIn; + + /** + * 刷新令牌 refresh_token 的有效期 + */ + @JsonProperty("refresh_expire_in") + private Long refreshExpireIn; + + /** + * 应用id + */ + @JsonProperty("client_id") + private String clientId; + + /** + * 令牌权限 + */ + private String scope; + + /** + * 用户 openid + */ + private String openid; + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/listener/UserActionListener.java b/ruoyi-server/src/main/java/org/dromara/web/listener/UserActionListener.java new file mode 100644 index 0000000..d5c38e9 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/listener/UserActionListener.java @@ -0,0 +1,173 @@ +package org.dromara.web.listener; + +import cn.dev33.satoken.listener.SaTokenListener; +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.http.useragent.UserAgent; +import cn.hutool.http.useragent.UserAgentUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.CacheConstants; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.domain.dto.UserOnlineDTO; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ip.AddressUtils; +import org.dromara.common.log.event.LogininforEvent; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.web.config.properties.RiskControlProperties; +import org.dromara.web.service.RiskControlService; +import org.dromara.web.service.SysLoginService; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * 用户行为 侦听器的实现 + * + * @author pengles + */ +@RequiredArgsConstructor +@Component +@Slf4j +public class UserActionListener implements SaTokenListener { + + private final SysLoginService loginService; + private final RiskControlService riskControlService; + private final RiskControlProperties riskProperties; + + /** + * 每次登录时触发 + */ + @Override + public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter) { + UserAgent userAgent = UserAgentUtil.parse(ServletUtils.getRequest().getHeader("User-Agent")); + String ip = ServletUtils.getClientIP(); + UserOnlineDTO dto = new UserOnlineDTO(); + dto.setIpaddr(ip); + dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip)); + dto.setBrowser(userAgent.getBrowser().getName()); + dto.setOs(userAgent.getOs().getName()); + dto.setLoginTime(System.currentTimeMillis()); + dto.setTokenId(tokenValue); + String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY); + dto.setUserName(username); + dto.setClientKey((String) loginParameter.getExtra(LoginHelper.CLIENT_KEY)); + dto.setDeviceType(loginParameter.getDeviceType()); + dto.setDeptName((String) loginParameter.getExtra(LoginHelper.DEPT_NAME_KEY)); + if (loginParameter.getTimeout() == -1) { + RedisUtils.setCacheObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue, dto); + } else { + RedisUtils.setCacheObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue, dto, Duration.ofSeconds(loginParameter.getTimeout())); + } + + // 风险检测 (仅针对 sys 用户) + // 假设 "login" 是 sys 用户的 loginType (SaToken 默认配置) +// if ("login".equals(loginType)) { +// int score = riskControlService.assessRisk(username, ip, ServletUtils.getRequest().getHeader("User-Agent")); +// if (score > riskProperties.getThreshold()) { +// log.warn("用户 {} 登录风险过高 (score={}),已锁定会话", username, score); +// StpUtil.getSession().set(RiskInterceptor.RISK_LOCKED_KEY, true); +// // 可以在此触发邮件/短信告警 +// } +// } + + // 记录登录日志 + LogininforEvent logininforEvent = new LogininforEvent(); + logininforEvent.setUsername(username); + logininforEvent.setStatus(Constants.LOGIN_SUCCESS); + logininforEvent.setMessage(MessageUtils.message("user.login.success")); + logininforEvent.setRequest(ServletUtils.getRequest()); + SpringUtils.context().publishEvent(logininforEvent); + // 更新登录信息 + loginService.recordLoginInfo((Long) loginParameter.getExtra(LoginHelper.USER_KEY), ip); + log.info("user doLogin, userId:{}, token:{}", loginId, tokenValue); + } + + /** + * 每次注销时触发 + */ + @Override + public void doLogout(String loginType, Object loginId, String tokenValue) { + RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue); + log.info("user doLogout, userId:{}, token:{}", loginId, tokenValue); + } + + /** + * 每次被踢下线时触发 + */ + @Override + public void doKickout(String loginType, Object loginId, String tokenValue) { + RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue); + log.info("user doKickout, userId:{}, token:{}", loginId, tokenValue); + } + + /** + * 每次被顶下线时触发 + */ + @Override + public void doReplaced(String loginType, Object loginId, String tokenValue) { + RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue); + log.info("user doReplaced, userId:{}, token:{}", loginId, tokenValue); + } + + /** + * 每次被封禁时触发 + */ + @Override + public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) { + log.info("用户被禁用: loginType={}, loginId={}, service={}, level={}, disableTime={}", + loginType, loginId, service, level, disableTime); + + // 删除在线用户信息 + String tokenValue = StpUtil.getTokenValueByLoginId(loginId); + if (StringUtils.isNotBlank(tokenValue)) { + RedisUtils.deleteObject(CacheConstants.ONLINE_TOKEN_KEY + tokenValue); + } + } + + /** + * 每次被解封时触发 + */ + @Override + public void doUntieDisable(String loginType, Object loginId, String service) { + } + + /** + * 每次打开二级认证时触发 + */ + @Override + public void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) { + } + + /** + * 每次创建Session时触发 + */ + @Override + public void doCloseSafe(String loginType, String tokenValue, String service) { + } + + /** + * 每次创建Session时触发 + */ + @Override + public void doCreateSession(String id) { + } + + /** + * 每次注销Session时触发 + */ + @Override + public void doLogoutSession(String id) { + } + + /** + * 每次Token续期时触发 + */ + @Override + public void doRenewTimeout(String loginType, Object loginId, String tokenValue, long timeout) { + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/service/IAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/web/service/IAuthStrategy.java new file mode 100644 index 0000000..0578b68 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/service/IAuthStrategy.java @@ -0,0 +1,45 @@ +package org.dromara.web.service; + + +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.web.domain.vo.LoginVo; + +/** + * 授权策略 + * + * @author pengles + */ +public interface IAuthStrategy { + + String BASE_NAME = "AuthStrategy"; + + /** + * 登录 + * + * @param body 登录对象 + * @param client 授权管理视图对象 + * @param grantType 授权类型 + * @return 登录验证信息 + */ + static LoginVo login(String body, SysClientVo client, String grantType) { + // 授权类型和客户端id + String beanName = grantType + BASE_NAME; + if (!SpringUtils.containsBean(beanName)) { + throw new ServiceException("授权类型不正确!"); + } + IAuthStrategy instance = SpringUtils.getBean(beanName); + return instance.login(body, client); + } + + /** + * 登录 + * + * @param body 登录对象 + * @param client 授权管理视图对象 + * @return 登录验证信息 + */ + LoginVo login(String body, SysClientVo client); + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/service/RiskControlService.java b/ruoyi-server/src/main/java/org/dromara/web/service/RiskControlService.java new file mode 100644 index 0000000..26a8911 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/service/RiskControlService.java @@ -0,0 +1,137 @@ +package org.dromara.web.service; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.useragent.UserAgent; +import cn.hutool.http.useragent.UserAgentUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ip.AddressUtils; +import org.dromara.system.domain.bo.SysLogininforBo; +import org.dromara.system.domain.vo.SysLogininforVo; +import org.dromara.system.service.SysLogininforService; +import org.dromara.web.config.properties.RiskControlProperties; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 风险控制服务 + * + * @author Pair-Programmer + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RiskControlService { + + private final SysLogininforService logininforService; + private final RiskControlProperties properties; + + /** + * 评估登录风险 + * + * @param username 用户名 + * @param currentIp 当前IP + * @param uaStr User-Agent字符串 + * @return 风险分数 (0-100) + */ + public int assessRisk(String username, String currentIp, String uaStr) { + if (!properties.getEnabled()) { + return 0; + } + + int score = 0; + String currentAddress = AddressUtils.getRealAddressByIP(currentIp); + UserAgent currentUserAgent = UserAgentUtil.parse(uaStr); + + // 1. 获取最近成功的登录记录 (取最近10条) + SysLogininforBo query = new SysLogininforBo(); + query.setUserName(username); + query.setStatus(Constants.SUCCESS); + // 这里假设 selectLogininforList 是按时间倒序的,且我们可以通过PageHelper或流截取 + // 由于是 Service 调用,我们先获取列表然后手动截取,或者依赖 Mapper 的分页(这里简化处理) + List historyList = logininforService.selectLogininforList(query); + + // 如果是首次登录或无历史记录,视为低风险 + if (CollUtil.isEmpty(historyList)) { + return 0; + } + + // 截取最近 10 条作为基线 + List baseline = historyList.stream().limit(10).collect(Collectors.toList()); + + // 2. 检查异地登录 (省份是否变化) + boolean isGeoAnomaly = true; + Set validLocations = baseline.stream() + .map(SysLogininforVo::getLoginLocation) + .filter(StrUtil::isNotBlank) + .map(this::extractProvince) + .collect(Collectors.toSet()); + + String currentProvince = extractProvince(currentAddress); + if (validLocations.contains(currentProvince)) { + isGeoAnomaly = false; + } else { + // 特殊处理:如果历史记录都是“未知”或“内网IP”,且当前是公网IP,可能误报,暂且认为是异地 + log.info("风险检测-异地登录: 用户={}, 历史={}, 当前={}", username, validLocations, currentProvince); + } + + if (isGeoAnomaly) { + score += properties.getGeoWeight(); + } + + // 3. 检查新设备 (Browser + OS) + boolean isNewDevice = true; + Set knownDevices = baseline.stream() + .map(log -> log.getBrowser() + " on " + log.getOs()) + .collect(Collectors.toSet()); + + String currentDevice = currentUserAgent.getBrowser().getName() + " on " + currentUserAgent.getOs().getName(); + if (knownDevices.contains(currentDevice)) { + isNewDevice = false; + } else { + log.info("风险检测-新设备: 用户={}, 历史={}, 当前={}", username, knownDevices, currentDevice); + } + + if (isNewDevice) { + score += properties.getDeviceWeight(); + } + + // 4. 检查非工作时间 (00:00 - 06:00) + int hour = DateUtil.hour(new Date(), true); + if (hour >= 0 && hour < 6) { + score += properties.getTimeWeight(); + } + + log.info("风险检测完成: 用户={}, 总分={}, 详情(Geo={}, Device={}, Time={})", + username, score, isGeoAnomaly, isNewDevice, (hour >= 0 && hour < 6)); + + return score; + } + + /** + * 提取省份 (简单截取) + */ + private String extractProvince(String location) { + if (StrUtil.isBlank(location)) { + return "Unknown"; + } + // 假设 location 格式为 "XX省 XX市" 或 "XX省" + // 简单处理:取前2-3个字符,或者根据空格分割 + if (location.contains("省")) { + return location.substring(0, location.indexOf("省") + 1); + } + // 直辖市 + if (location.startsWith("北京") || location.startsWith("上海") || location.startsWith("天津") || location.startsWith("重庆")) { + return location.substring(0, 2); + } + return location; // 无法识别则返回原值 + } +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/service/SysLoginService.java b/ruoyi-server/src/main/java/org/dromara/web/service/SysLoginService.java new file mode 100644 index 0000000..659106c --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/service/SysLoginService.java @@ -0,0 +1,205 @@ +package org.dromara.web.service; + +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Opt; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.lock.annotation.Lock4j; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthUser; +import org.dromara.common.core.constant.CacheConstants; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.domain.dto.PostDTO; +import org.dromara.common.core.domain.dto.RoleDTO; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.log.event.LogininforEvent; +import org.dromara.common.mybatis.helper.DataPermissionHelper; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.bo.SysSocialBo; +import org.dromara.system.domain.vo.*; +import org.dromara.system.service.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; +import java.util.function.Supplier; + +/** + * 登录校验方法 + * + * @author pengles + */ +@RequiredArgsConstructor +@Slf4j +@Service +public class SysLoginService { + + @Value("${user.password.maxRetryCount}") + private Integer maxRetryCount; + + @Value("${user.password.lockTime}") + private Integer lockTime; + + private final SysPermissionService permissionService; + private final SysSocialService sysSocialService; + private final SysRoleService roleService; + private final SysDeptService deptService; + private final SysPostService postService; + private final SysUserService userService; + + + /** + * 绑定第三方用户 + * + * @param authUserData 授权响应实体 + */ + @Lock4j + public void socialRegister(AuthUser authUserData) { + String authId = authUserData.getSource() + authUserData.getUuid(); + // 第三方用户信息 + SysSocialBo bo = BeanUtil.toBean(authUserData, SysSocialBo.class); + BeanUtil.copyProperties(authUserData.getToken(), bo); + Long userId = LoginHelper.getUserId(); + bo.setUserId(userId); + bo.setAuthId(authId); + bo.setOpenId(authUserData.getUuid()); + bo.setUserName(authUserData.getUsername()); + bo.setNickName(authUserData.getNickname()); + List checkList = sysSocialService.selectByAuthId(authId); + if (CollUtil.isNotEmpty(checkList)) { + throw new ServiceException("此三方账号已经被绑定!"); + } + // 查询是否已经绑定用户 + SysSocialBo params = new SysSocialBo(); + params.setUserId(userId); + params.setSource(bo.getSource()); + List list = sysSocialService.queryList(params); + if (CollUtil.isEmpty(list)) { + // 没有绑定用户, 新增用户信息 + sysSocialService.insertByBo(bo); + } else { + // 更新用户信息 + bo.setId(list.get(0).getId()); + sysSocialService.updateByBo(bo); + // 如果要绑定的平台账号已经被绑定过了 是否抛异常自行决断 + // throw new ServiceException("此平台账号已经被绑定!"); + } + } + + + /** + * 退出登录 + */ + public void logout() { + try { + LoginUser loginUser = LoginHelper.getLoginUser(); + if (ObjectUtil.isNull(loginUser)) { + return; + } + recordLogininfor(loginUser.getUsername(), Constants.LOGOUT, MessageUtils.message("user.logout.success")); + } catch (NotLoginException ignored) { + } finally { + try { + StpUtil.logout(); + } catch (NotLoginException ignored) { + } + } + } + + /** + * 记录登录信息 + * + * @param username 用户名 + * @param status 状态 + * @param message 消息内容 + */ + public void recordLogininfor(String username, String status, String message) { + LogininforEvent logininforEvent = new LogininforEvent(); + logininforEvent.setUsername(username); + logininforEvent.setStatus(status); + logininforEvent.setMessage(message); + logininforEvent.setRequest(ServletUtils.getRequest()); + SpringUtils.context().publishEvent(logininforEvent); + } + + /** + * 构建登录用户 + */ + public LoginUser buildLoginUser(SysUserVo user) { + LoginUser loginUser = new LoginUser(); + Long userId = user.getUserId(); + loginUser.setUserId(userId); + loginUser.setDeptId(user.getDeptId()); + loginUser.setUsername(user.getUserName()); + loginUser.setNickname(user.getNickName()); + loginUser.setUserType(user.getUserType()); + loginUser.setMenuPermission(permissionService.getMenuPermission(userId)); + loginUser.setRolePermission(permissionService.getRolePermission(userId)); + if (ObjectUtil.isNotNull(user.getDeptId())) { + Opt deptOpt = Opt.of(user.getDeptId()).map(deptService::selectDeptById); + loginUser.setDeptName(deptOpt.map(SysDeptVo::getDeptName).orElse(StringUtils.EMPTY)); + loginUser.setDeptCategory(deptOpt.map(SysDeptVo::getDeptCategory).orElse(StringUtils.EMPTY)); + } + List roles = roleService.selectRolesByUserId(userId); + List posts = postService.selectPostsByUserId(userId); + loginUser.setRoles(BeanUtil.copyToList(roles, RoleDTO.class)); + loginUser.setPosts(BeanUtil.copyToList(posts, PostDTO.class)); + return loginUser; + } + + /** + * 记录登录信息 + * + * @param userId 用户ID + */ + public void recordLoginInfo(Long userId, String ip) { + DataPermissionHelper.ignore(() -> userService.updateLoginInfo(userId, ip)); + } + + /** + * 登录校验 + */ + public void checkLogin(LoginType loginType, String username, Supplier supplier) { + String errorKey = CacheConstants.PWD_ERR_CNT_KEY + username; + String loginFail = Constants.LOGIN_FAIL; + + // 获取用户登录错误次数,默认为0 (可自定义限制策略 例如: key + username + ip) + int errorNumber = ObjectUtil.defaultIfNull(RedisUtils.getCacheObject(errorKey), 0); + // 锁定时间内登录 则踢出 + if (errorNumber >= maxRetryCount) { + recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime)); + throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime); + } + + if (supplier.get()) { + // 错误次数递增 + errorNumber++; + RedisUtils.setCacheObject(errorKey, errorNumber, Duration.ofMinutes(lockTime)); + // 达到规定错误次数 则锁定登录 + if (errorNumber >= maxRetryCount) { + recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitExceed(), maxRetryCount, lockTime)); + throw new UserException(loginType.getRetryLimitExceed(), maxRetryCount, lockTime); + } else { + // 未达到规定错误次数 + recordLogininfor(username, loginFail, MessageUtils.message(loginType.getRetryLimitCount(), errorNumber)); + throw new UserException(loginType.getRetryLimitCount(), errorNumber); + } + } + + // 登录成功 清空错误次数 + RedisUtils.deleteObject(errorKey); + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/service/SysRegisterService.java b/ruoyi-server/src/main/java/org/dromara/web/service/SysRegisterService.java new file mode 100644 index 0000000..99a2971 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/service/SysRegisterService.java @@ -0,0 +1,108 @@ +package org.dromara.web.service; + +import cn.hutool.crypto.digest.BCrypt; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.domain.model.RegisterBody; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.core.exception.user.CaptchaException; +import org.dromara.common.core.exception.user.CaptchaExpireException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.ServletUtils; +import org.dromara.common.core.utils.SpringUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.log.event.LogininforEvent; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.web.config.properties.CaptchaProperties; +import org.dromara.system.domain.SysUser; +import org.dromara.system.domain.bo.SysUserBo; +import org.dromara.system.mapper.SysUserMapper; +import org.dromara.system.service.SysUserService; +import org.springframework.stereotype.Service; + +/** + * 注册校验方法 + * + * @author pengles + */ +@RequiredArgsConstructor +@Service +public class SysRegisterService { + + private final SysUserService userService; + private final SysUserMapper userMapper; + private final CaptchaProperties captchaProperties; + + /** + * 注册 + */ + public void register(RegisterBody registerBody) { + String username = registerBody.getUsername(); + String password = registerBody.getPassword(); + // 校验用户类型是否存在 + String userType = UserType.getUserType(registerBody.getUserType()).getUserType(); + + boolean captchaEnabled = captchaProperties.getEnable(); + // 验证码开关 + if (captchaEnabled) { + validateCaptcha(username, registerBody.getCode(), registerBody.getUuid()); + } + SysUserBo sysUser = new SysUserBo(); + sysUser.setUserName(username); + sysUser.setNickName(username); + sysUser.setPassword(BCrypt.hashpw(password)); + sysUser.setUserType(userType); + + boolean exist = !userService.checkUserNameUnique(sysUser); + if (exist) { + throw new UserException("user.register.save.error", username); + } + boolean regFlag = userService.registerUser(sysUser); + if (!regFlag) { + throw new UserException("user.register.error"); + } + recordLogininfor(username, Constants.REGISTER, MessageUtils.message("user.register.success")); + } + + /** + * 校验验证码 + * + * @param username 用户名 + * @param code 验证码 + * @param uuid 唯一标识 + */ + public void validateCaptcha(String username, String code, String uuid) { + String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + StringUtils.blankToDefault(uuid, ""); + String captcha = RedisUtils.getCacheObject(verifyKey); + RedisUtils.deleteObject(verifyKey); + if (captcha == null) { + recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); + throw new CaptchaExpireException(); + } + if (!code.equalsIgnoreCase(captcha)) { + recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")); + throw new CaptchaException(); + } + } + + /** + * 记录登录信息 + * + * @param username 用户名 + * @param status 状态 + * @param message 消息内容 + * @return + */ + private void recordLogininfor(String username, String status, String message) { + LogininforEvent logininforEvent = new LogininforEvent(); + logininforEvent.setUsername(username); + logininforEvent.setStatus(status); + logininforEvent.setMessage(message); + logininforEvent.setRequest(ServletUtils.getRequest()); + SpringUtils.context().publishEvent(logininforEvent); + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/service/impl/EmailAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/web/service/impl/EmailAuthStrategy.java new file mode 100644 index 0000000..f075861 --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/service/impl/EmailAuthStrategy.java @@ -0,0 +1,99 @@ +package org.dromara.web.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.model.EmailLoginBody; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.user.CaptchaExpireException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.service.SysUserService; +import org.dromara.web.domain.vo.LoginVo; +import org.dromara.web.service.IAuthStrategy; +import org.dromara.web.service.SysLoginService; +import org.springframework.stereotype.Service; + +/** + * 邮件认证策略 + * + * @author pengles + */ +@Slf4j +@Service("email" + IAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class EmailAuthStrategy implements IAuthStrategy { + + private final SysLoginService loginService; + private final SysUserService userService; + + @Override + public LoginVo login(String body, SysClientVo client) { + EmailLoginBody loginBody = JsonUtils.parseObject(body, EmailLoginBody.class); + ValidatorUtils.validate(loginBody); + String email = loginBody.getEmail(); + String emailCode = loginBody.getEmailCode(); + + SysUserVo user = loadUserByEmail(email); + loginService.checkLogin(LoginType.EMAIL, user.getUserName(), () -> !validateEmailCode(email, emailCode)); + // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 + LoginUser loginUser = loginService.buildLoginUser(user); + + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + return loginVo; + } + + /** + * 校验邮箱验证码 + */ + private boolean validateEmailCode(String email, String emailCode) { + String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + email); + if (StringUtils.isBlank(code)) { + loginService.recordLogininfor(email, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); + throw new CaptchaExpireException(); + } + return code.equals(emailCode); + } + + private SysUserVo loadUserByEmail(String email) { + SysUserVo user = userService.selectUserByEmail(email); + if (ObjectUtil.isNull(user)) { + log.info("登录用户:{} 不存在.", email); + throw new UserException("user.not.exists", email); + } else if (SystemConstants.DISABLE.equals(user.getStatus())) { + log.info("登录用户:{} 已被停用.", email); + throw new UserException("user.blocked", email); + } + return user; + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java new file mode 100644 index 0000000..db779ba --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java @@ -0,0 +1,120 @@ +package org.dromara.web.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.crypto.digest.BCrypt; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.domain.model.PasswordLoginBody; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.user.CaptchaException; +import org.dromara.common.core.exception.user.CaptchaExpireException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.web.config.properties.CaptchaProperties; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.service.SysUserService; +import org.dromara.web.domain.vo.LoginVo; +import org.dromara.web.service.IAuthStrategy; +import org.dromara.web.service.SysLoginService; +import org.springframework.stereotype.Service; + +/** + * 密码认证策略 + * + * @author pengles + */ +@Slf4j +@Service("password" + IAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class PasswordAuthStrategy implements IAuthStrategy { + + private final CaptchaProperties captchaProperties; + private final SysLoginService loginService; + private final SysUserService userService; + + @Override + public LoginVo login(String body, SysClientVo client) { + PasswordLoginBody loginBody = JsonUtils.parseObject(body, PasswordLoginBody.class); + ValidatorUtils.validate(loginBody); + String username = loginBody.getUsername(); + String password = loginBody.getPassword(); + String code = loginBody.getCode(); + String uuid = loginBody.getUuid(); + + boolean captchaEnabled = captchaProperties.getEnable(); + // 验证码开关 + if (captchaEnabled) { + validateCaptcha(username, code, uuid); + } + + SysUserVo user = loadUserByUsername(username); + loginService.checkLogin(LoginType.PASSWORD, username, () -> !BCrypt.checkpw(password, user.getPassword())); + + // 此处可根据登录用户的数据不同 自行创建 loginUser + LoginUser loginUser = loginService.buildLoginUser(user); + + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + return loginVo; + } + + /** + * 校验验证码 + * + * @param username 用户名 + * @param code 验证码 + * @param uuid 唯一标识 + */ + private void validateCaptcha(String username, String code, String uuid) { + String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + StringUtils.blankToDefault(uuid, ""); + String captcha = RedisUtils.getCacheObject(verifyKey); + RedisUtils.deleteObject(verifyKey); + if (captcha == null) { + loginService.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); + throw new CaptchaExpireException(); + } + if (!code.equalsIgnoreCase(captcha)) { + loginService.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")); + throw new CaptchaException(); + } + } + + private SysUserVo loadUserByUsername(String username) { + SysUserVo user = userService.selectUserByUserName(username); + if (ObjectUtil.isNull(user)) { + log.info("登录用户:{} 不存在.", username); + throw new UserException("user.not.exists", username); + } else if (SystemConstants.DISABLE.equals(user.getStatus())) { + log.info("登录用户:{} 已被停用.", username); + throw new UserException("user.blocked", username); + } + return user; + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java new file mode 100644 index 0000000..7d9cd0f --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java @@ -0,0 +1,99 @@ +package org.dromara.web.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.util.ObjectUtil; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.domain.model.SmsLoginBody; +import org.dromara.common.core.enums.LoginType; +import org.dromara.common.core.exception.user.CaptchaExpireException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.MessageUtils; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.service.SysUserService; +import org.dromara.web.domain.vo.LoginVo; +import org.dromara.web.service.IAuthStrategy; +import org.dromara.web.service.SysLoginService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 短信认证策略 + * + * @author pengles + */ +@Slf4j +@Service("sms" + IAuthStrategy.BASE_NAME) +public class SmsAuthStrategy implements IAuthStrategy { + + @Autowired + private SysLoginService loginService; + @Autowired + private SysUserService userService; + + @Override + public LoginVo login(String body, SysClientVo client) { + SmsLoginBody loginBody = JsonUtils.parseObject(body, SmsLoginBody.class); + ValidatorUtils.validate(loginBody); + String phonenumber = loginBody.getPhonenumber(); + String smsCode = loginBody.getSmsCode(); + + SysUserVo user = loadUserByPhonenumber(phonenumber); + loginService.checkLogin(LoginType.SMS, user.getUserName(), () -> !validateSmsCode(phonenumber, smsCode)); + // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 + LoginUser loginUser = loginService.buildLoginUser(user); + + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + return loginVo; + } + + /** + * 校验短信验证码 + */ + private boolean validateSmsCode(String phonenumber, String smsCode) { + String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + phonenumber); + if (StringUtils.isBlank(code)) { + loginService.recordLogininfor(phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")); + throw new CaptchaExpireException(); + } + return code.equals(smsCode); + } + + private SysUserVo loadUserByPhonenumber(String phonenumber) { + SysUserVo user = userService.selectUserByPhonenumber(phonenumber); + if (ObjectUtil.isNull(user)) { + log.info("登录用户:{} 不存在.", phonenumber); + throw new UserException("user.not.exists", phonenumber); + } else if (SystemConstants.DISABLE.equals(user.getStatus())) { + log.info("登录用户:{} 已被停用.", phonenumber); + throw new UserException("user.blocked", phonenumber); + } + return user; + } + +} diff --git a/ruoyi-server/src/main/java/org/dromara/web/service/impl/SocialAuthStrategy.java b/ruoyi-server/src/main/java/org/dromara/web/service/impl/SocialAuthStrategy.java new file mode 100644 index 0000000..78379be --- /dev/null +++ b/ruoyi-server/src/main/java/org/dromara/web/service/impl/SocialAuthStrategy.java @@ -0,0 +1,107 @@ +package org.dromara.web.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthUser; +import org.dromara.common.core.constant.SystemConstants; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.domain.model.SocialLoginBody; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.exception.user.UserException; +import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.social.config.properties.SocialProperties; +import org.dromara.common.social.utils.SocialUtils; +import org.dromara.system.domain.vo.SysClientVo; +import org.dromara.system.domain.vo.SysSocialVo; +import org.dromara.system.domain.vo.SysUserVo; +import org.dromara.system.service.SysUserService; +import org.dromara.system.service.SysSocialService; +import org.dromara.web.domain.vo.LoginVo; +import org.dromara.web.service.IAuthStrategy; +import org.dromara.web.service.SysLoginService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 第三方授权策略 + * + * @author pengles + */ +@Slf4j +@Service("social" + IAuthStrategy.BASE_NAME) +@RequiredArgsConstructor +public class SocialAuthStrategy implements IAuthStrategy { + + private final SocialProperties socialProperties; + private final SysSocialService sysSocialService; + private final SysUserService userService; + private final SysLoginService loginService; + + /** + * 登录-第三方授权登录 + * + * @param body 登录信息 + * @param client 客户端信息 + */ + @Override + public LoginVo login(String body, SysClientVo client) { + SocialLoginBody loginBody = JsonUtils.parseObject(body, SocialLoginBody.class); + ValidatorUtils.validate(loginBody); + AuthResponse response = SocialUtils.loginAuth( + loginBody.getSource(), loginBody.getSocialCode(), + loginBody.getSocialState(), socialProperties); + if (!response.ok()) { + throw new ServiceException(response.getMsg()); + } + AuthUser authUserData = response.getData(); + + List list = sysSocialService.selectByAuthId(authUserData.getSource() + authUserData.getUuid()); + if (CollUtil.isEmpty(list)) { + throw new ServiceException("你还没有绑定第三方账号,绑定后才可以登录!"); + } + SysSocialVo social = list.get(0); + + SysUserVo user = loadUser(social.getUserId()); + // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 + LoginUser loginUser = loginService.buildLoginUser(user); + + loginUser.setClientKey(client.getClientKey()); + loginUser.setDeviceType(client.getDeviceType()); + SaLoginParameter model = new SaLoginParameter(); + model.setDeviceType(client.getDeviceType()); + // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 + // 例如: 后台用户30分钟过期 app用户1天过期 + model.setTimeout(client.getTimeout()); + model.setActiveTimeout(client.getActiveTimeout()); + model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId()); + // 生成token + LoginHelper.login(loginUser, model); + + LoginVo loginVo = new LoginVo(); + loginVo.setAccessToken(StpUtil.getTokenValue()); + loginVo.setExpireIn(StpUtil.getTokenTimeout()); + loginVo.setClientId(client.getClientId()); + return loginVo; + } + + private SysUserVo loadUser(Long userId) { + SysUserVo user = userService.selectUserById(userId); + if (ObjectUtil.isNull(user)) { + log.info("登录用户:{} 不存在.", ""); + throw new UserException("user.not.exists", ""); + } else if (SystemConstants.DISABLE.equals(user.getStatus())) { + log.info("登录用户:{} 已被停用.", ""); + throw new UserException("user.blocked", ""); + } + return user; + } + +} diff --git a/ruoyi-server/src/main/resources/.DS_Store b/ruoyi-server/src/main/resources/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..730776961dd896509c1e344987df1a36cc968b87 GIT binary patch literal 8196 zcmeHMU2GIp6u#fIz>FPWS}7D{*_8z((AsS+SSaGsmY+&1$hP!{V%^;t+L7r@-I?9; zlWK|4_?P%^;uBHPN1{d}#z4>~6D3B|kVqmi`XIje#0whHbLY;MmY?`y48gg{-0z-q z&$+Yb%y;JYP8nlp&g*rIRWZh7x;ScO)Lp0Xd465fq(Dg|+jtit2Y7nl3!WF?01Hzs3vA`}F;J85H&JY|v5ZoET2?hS$Y5kaQ z&X5!s_F)FX3|yN5UOuHvXAa9Uck1~)Gh&&pmq?t2R8l%)<}7)ZTqf^L4LPGJFYV>s z=5%fkmpUEC$`r@x%x=>d9nj{rIJTEIZ6oJ9n7TnG{oSUmJEP4`-qu~;Hzg)yMOFs1 zs>6pH);3nh8rIc~RmTpmt548)ZNu1@B3H(1n|2NCw}x%!IS~Sa*8rP+RY=cIjmL5e zE#iAbNvasnxnexZZDs20NOwhWaS8wrB1$jhv#Cqq@^h4;vE|RSOs0u{aVDHdlO#tMueE z=3{x&JE+BFS==MCG_J_n$Nf6}$PP#6E9(ACB(n4_S>D6-EjfdFp;jZStH~kV=~9A% zpP8sr6wU42Pmf=?s%zHDil%p2>5O3os`X8z+TL9dkq64k`(>pk>kJQ5Fs{%=T3TgU z`|@Fa)pJF-qhsdbG>3|6obaT{i2c=j8lsJh(Ix>E%mp z(se_ppchpwtcau{U9$Ea`DmtHFp2K;EClQ2c#&`MxzOhBv z3f4$lXeZl8g?5C!z>czy*hzMZondF$d3J&Q#xAkn*&pmON}!?wi?Iwfs6_&GSdS)b zz(#CGC%Uj38SICJVL0$GhR5*)j^Js$h?np(Ud5Yu8}Hy4-otTxf)n@@U*IczjWhTL z=kW_J;8$G4pSX;_q;hGIv`ktqt&nP^gj6rxBW;nkN;{=qDI*O^Lm~pHSZaHxMUEL=~VLj ze4kb&KHq&#T+LO#3e_diE-DD)z?Cu-WmzmnZ5A(Fd4gNtsm7z)3@RBRu25G-wV6~d zLR=lKiE0v6sSp#%s`ld=@~$2YpS)GbT@kE@w0Y} zu1DzNjoXb2^j)a)kK=^?ah!1SABHrJktz2{1~@K|G}Qj{4*~!5r*^pihx@ )____ | | /_____/ \ /| | /\ ___/ /_____/ | | | |_| | /\___ \ + |____|_ /____/ \____// ______|__| \___/ |____/ \___ > |____| |____/____//____ > + \/ \/ \/ \/ diff --git a/ruoyi-server/src/main/resources/i18n/messages.properties b/ruoyi-server/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000..9876158 --- /dev/null +++ b/ruoyi-server/src/main/resources/i18n/messages.properties @@ -0,0 +1,56 @@ +#错误消息 +not.null=* 必须填写 +user.jcaptcha.error=验证码错误 +user.jcaptcha.expire=验证码已失效 +user.not.exists=对不起, 您的账号:{0} 不存在. +user.password.not.match=用户不存在/密码错误 +user.password.retry.limit.count=密码输入错误{0}次 +user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定{1}分钟 +user.password.delete=对不起,您的账号:{0} 已被删除 +user.blocked=对不起,您的账号:{0} 已禁用,请联系管理员 +role.blocked=角色已封禁,请联系管理员 +user.logout.success=退出成功 +length.not.valid=长度必须在{min}到{max}个字符之间 +user.username.not.blank=用户名不能为空 +user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头 +user.username.length.valid=账户长度必须在{min}到{max}个字符之间 +user.password.not.blank=用户密码不能为空 +user.password.length.valid=用户密码长度必须在{min}到{max}个字符之间 +user.password.not.valid=* 5-50个字符 +user.email.not.valid=邮箱格式错误 +user.email.not.blank=邮箱不能为空 +user.phonenumber.not.blank=用户手机号不能为空 +user.mobile.phone.number.not.valid=手机号格式错误 +user.login.success=登录成功 +user.register.success=注册成功 +user.register.save.error=保存用户 {0} 失败,注册账号已存在 +user.register.error=注册失败,请联系系统管理人员 +user.notfound=请重新登录 +user.forcelogout=管理员强制退出,请重新登录 +user.unknown.error=未知错误,请重新登录 +auth.grant.type.error=认证权限类型错误 +auth.grant.type.blocked=认证权限类型已禁用 +auth.grant.type.not.blank=认证权限类型不能为空 +auth.clientid.not.blank=认证客户端id不能为空 +##文件上传消息 +upload.exceed.maxSize=上传的文件大小超出限制的文件大小!
允许的文件最大大小是:{0}MB! +upload.filename.exceed.length=上传的文件名最长{0}个字符 +##权限 +no.permission=您没有数据的权限,请联系管理员添加权限 [{0}] +no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}] +no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}] +no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}] +no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}] +no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}] +repeat.submit.message=不允许重复提交,请稍候再试 +rate.limiter.message=访问过于频繁,请稍候再试 +sms.code.not.blank=短信验证码不能为空 +sms.code.retry.limit.count=短信验证码输入错误{0}次 +sms.code.retry.limit.exceed=短信验证码输入错误{0}次,帐户锁定{1}分钟 +email.code.not.blank=邮箱验证码不能为空 +email.code.retry.limit.count=邮箱验证码输入错误{0}次 +email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,帐户锁定{1}分钟 +xcx.code.not.blank=小程序[code]不能为空 +social.source.not.blank=第三方登录平台[source]不能为空 +social.code.not.blank=第三方登录平台[code]不能为空 +social.state.not.blank=第三方登录平台[state]不能为空 diff --git a/ruoyi-server/src/main/resources/i18n/messages_en_US.properties b/ruoyi-server/src/main/resources/i18n/messages_en_US.properties new file mode 100644 index 0000000..0dc99ab --- /dev/null +++ b/ruoyi-server/src/main/resources/i18n/messages_en_US.properties @@ -0,0 +1,56 @@ +#错误消息 +not.null=* Required fill in +user.jcaptcha.error=Captcha error +user.jcaptcha.expire=Captcha invalid +user.not.exists=Sorry, your account: {0} does not exist +user.password.not.match=User does not exist/Password error +user.password.retry.limit.count=Password input error {0} times +user.password.retry.limit.exceed=Password input error {0} times, account locked for {1} minutes +user.password.delete=Sorry, your account:{0} has been deleted +user.blocked=Sorry, your account: {0} has been disabled. Please contact the administrator +role.blocked=Role disabled,please contact administrators +user.logout.success=Exit successful +length.not.valid=The length must be between {min} and {max} characters +user.username.not.blank=Username cannot be blank +user.username.not.valid=* 2 to 20 chinese characters, letters, numbers or underscores, and must start with a non number +user.username.length.valid=Account length must be between {min} and {max} characters +user.password.not.blank=Password cannot be empty +user.password.length.valid=Password length must be between {min} and {max} characters +user.password.not.valid=* 5-50 characters +user.email.not.valid=Mailbox format error +user.email.not.blank=Mailbox cannot be blank +user.phonenumber.not.blank=Phone number cannot be blank +user.mobile.phone.number.not.valid=Phone number format error +user.login.success=Login successful +user.register.success=Register successful +user.register.save.error=Failed to save user {0}, The registered account already exists +user.register.error=Register failed, please contact system administrator +user.notfound=Please login again +user.forcelogout=The administrator is forced to exit,please login again +user.unknown.error=Unknown error, please login again +auth.grant.type.error=Auth grant type error +auth.grant.type.blocked=Auth grant type disabled +auth.grant.type.not.blank=Auth grant type cannot be blank +auth.clientid.not.blank=Auth clientid cannot be blank +##文件上传消息 +upload.exceed.maxSize=The uploaded file size exceeds the limit file size!
the maximum allowed file size is:{0}MB! +upload.filename.exceed.length=The maximum length of uploaded file name is {0} characters +##权限 +no.permission=You do not have permission to the data,please contact your administrator to add permissions [{0}] +no.create.permission=You do not have permission to create data,please contact your administrator to add permissions [{0}] +no.update.permission=You do not have permission to modify data,please contact your administrator to add permissions [{0}] +no.delete.permission=You do not have permission to delete data,please contact your administrator to add permissions [{0}] +no.export.permission=You do not have permission to export data,please contact your administrator to add permissions [{0}] +no.view.permission=You do not have permission to view data,please contact your administrator to add permissions [{0}] +repeat.submit.message=Repeat submit is not allowed, please try again later +rate.limiter.message=Visit too frequently, please try again later +sms.code.not.blank=Sms code cannot be blank +sms.code.retry.limit.count=Sms code input error {0} times +sms.code.retry.limit.exceed=Sms code input error {0} times, account locked for {1} minutes +email.code.not.blank=Email code cannot be blank +email.code.retry.limit.count=Email code input error {0} times +email.code.retry.limit.exceed=Email code input error {0} times, account locked for {1} minutes +xcx.code.not.blank=Mini program [code] cannot be blank +social.source.not.blank=Social login platform [source] cannot be blank +social.code.not.blank=Social login platform [code] cannot be blank +social.state.not.blank=Social login platform [state] cannot be blank diff --git a/ruoyi-server/src/main/resources/i18n/messages_zh_CN.properties b/ruoyi-server/src/main/resources/i18n/messages_zh_CN.properties new file mode 100644 index 0000000..9876158 --- /dev/null +++ b/ruoyi-server/src/main/resources/i18n/messages_zh_CN.properties @@ -0,0 +1,56 @@ +#错误消息 +not.null=* 必须填写 +user.jcaptcha.error=验证码错误 +user.jcaptcha.expire=验证码已失效 +user.not.exists=对不起, 您的账号:{0} 不存在. +user.password.not.match=用户不存在/密码错误 +user.password.retry.limit.count=密码输入错误{0}次 +user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定{1}分钟 +user.password.delete=对不起,您的账号:{0} 已被删除 +user.blocked=对不起,您的账号:{0} 已禁用,请联系管理员 +role.blocked=角色已封禁,请联系管理员 +user.logout.success=退出成功 +length.not.valid=长度必须在{min}到{max}个字符之间 +user.username.not.blank=用户名不能为空 +user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头 +user.username.length.valid=账户长度必须在{min}到{max}个字符之间 +user.password.not.blank=用户密码不能为空 +user.password.length.valid=用户密码长度必须在{min}到{max}个字符之间 +user.password.not.valid=* 5-50个字符 +user.email.not.valid=邮箱格式错误 +user.email.not.blank=邮箱不能为空 +user.phonenumber.not.blank=用户手机号不能为空 +user.mobile.phone.number.not.valid=手机号格式错误 +user.login.success=登录成功 +user.register.success=注册成功 +user.register.save.error=保存用户 {0} 失败,注册账号已存在 +user.register.error=注册失败,请联系系统管理人员 +user.notfound=请重新登录 +user.forcelogout=管理员强制退出,请重新登录 +user.unknown.error=未知错误,请重新登录 +auth.grant.type.error=认证权限类型错误 +auth.grant.type.blocked=认证权限类型已禁用 +auth.grant.type.not.blank=认证权限类型不能为空 +auth.clientid.not.blank=认证客户端id不能为空 +##文件上传消息 +upload.exceed.maxSize=上传的文件大小超出限制的文件大小!
允许的文件最大大小是:{0}MB! +upload.filename.exceed.length=上传的文件名最长{0}个字符 +##权限 +no.permission=您没有数据的权限,请联系管理员添加权限 [{0}] +no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}] +no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}] +no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}] +no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}] +no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}] +repeat.submit.message=不允许重复提交,请稍候再试 +rate.limiter.message=访问过于频繁,请稍候再试 +sms.code.not.blank=短信验证码不能为空 +sms.code.retry.limit.count=短信验证码输入错误{0}次 +sms.code.retry.limit.exceed=短信验证码输入错误{0}次,帐户锁定{1}分钟 +email.code.not.blank=邮箱验证码不能为空 +email.code.retry.limit.count=邮箱验证码输入错误{0}次 +email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,帐户锁定{1}分钟 +xcx.code.not.blank=小程序[code]不能为空 +social.source.not.blank=第三方登录平台[source]不能为空 +social.code.not.blank=第三方登录平台[code]不能为空 +social.state.not.blank=第三方登录平台[state]不能为空 diff --git a/ruoyi-server/src/main/resources/ip2region.xdb b/ruoyi-server/src/main/resources/ip2region.xdb new file mode 100644 index 0000000000000000000000000000000000000000..7052c057104ddd0c57f3ec9a8a457e4ec8f7139d GIT binary patch literal 11070083 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 U%~#O`%*AKW!NUD%-J%P4cn!1Y-hfTIY)wB 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

p)zI9 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%0Ay`w1q9G5x5Y&wI`KyKR8?K;D>0R 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

WD(q?yvk`3&|P?H?u~vjfA^% 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
  • 5Fp40^ExQe9I z9W2ECiNtioHgNBW>k_;dLheL|BINE-{_MVF8QC`$qJh zUC-M+9CA;TK8;0C@YhPcSrI`|4}^DFV}x$qT!L-;F{ zC!aTlP=wsfO5h&+74p<)T@S$_w=*Ph5A!K6!67%xACAjNp4x+BxCwFEqDdTbCH&l1 zlJ7>=gI7TjvR~W{o`lQEb@~elQ@Ff19lrvu@)h@qUl8KnB{5wc@5Uil>#K0c#jd8c z$i^syihmrJP=bno64#{!m66TJRVYGkY&USoeXC^?_h(X`_WYmYs=j?&xNXT(=im+w zxjU;5;jZ&F`zST5$Jd2Wgxqx>$02uD`3c-@K6X3TA^U5x5Q^^fvAb{wah|~;?p{Ae z7jco4u45E@H5ondtFikpxC>v6$0&hINlc#;rN7F4{Z-f65NbdCtBjaD;psrdOdKmg z737}&K#`9HW4Q6J#xpsKoAKp6o9mFx`4Ebbn=jA7JvWX8&%-r*Y#-N@()G+~;cgYD zzhZG4?n^%9os@uVMut#Zo&5ep2O1$O?-V-In9SXCC{m}tRB;#XKuXZnafs_mi*%%W zxNrFW{GT}F_K?59A=mSL9CCZl(N~i%LN?NuK@oC!eiMh>Z*G1I_Z=VmE-m`o__`2^ z20k{#MR99`pvyPvx|FW#=m9w7diR02|Jr55^j-1=9C9fWxTH8;jX%{*W{|DhF{pxU zt%Oj`$Li27vJ&>72C|j254Dly*@2>;j`IvsniOPUz6ehHxnxz+JdVh{5% zsB%dh3!wB-sr>D#k?h0|bqg;tA`qE3dS4&K1`D@4>*?F6V zipWYYK^1k9GK3;zDTCMIUhi{x18!OBqIVE0xUw&A6}RTwu#US*uG9C1SL13vWgWNc zTeQbWBRi}1q3AQxpZZI8pQUD>k7FSeHGFIz*X+i3RxMKA;!{Eqa=F}!yCaSTU%-7? zoUXqE9CG7#h`Z?9a38KK_0;pIheNLCzt-g&w9-rR$w3;5{>Hbck3;SxIl_HYuG2C4 z7Vg^;(^d5ya`|ptLK=z&lBa$jbBOz%ugmvw$G(IU+{m}#6c_oFK`$94dhtqqP>;XA z?YZ`a;vU?K=PndIB#s41VksYkBINodjeA&+TI-Cwq?f#atPKgM@rWL^mc3vfiZXF5 z7{fhAoc7>japOIDLZ4)0DGynHhERmueZk{!m&UQ+iM{06Q{xt$L-VH=xx4^(nNRs*+BZK-Bm(4WcNf<(9?YE>GC?V-Siny8EoO+Cw0;Lm-pk4yQ97ax9wv))C}34 zm4Kp;iqj{8AERWEj{78(Ll&EYy2$F%gQ8j-3+nXz$Kw?@292xx5qokLijdp0K0#}* zmy{9F7x}sbUrRPFe9iUJKGZ}u?k%XTF&Wzq)JL{% zkDvjvQ*;O&YfSdY6KJF{S^cNbnL63|&Y=joQAvC)IgM<^%|JzDqf&yFk&VL&RMwd6 z8mrKnIvKxpsDkWQFGFZUWAb^`CR9Z>TeqNXb+X&+Ks96|R)=jLS1An>Op;E>){9*AS-VO9jlWzoIoRF*PTLV8k5$ZLy?aK5Bgg2 z0&?pQnn0H4Bs8r~@|=P4$nKg8&@8f@eGZyOcHIJ0MwaI)w5CpaX&tH{yPFB24Rvx4 zwh2{{osC=2HnKMCKsAj?UFy)T#-s=LpoThmkG>Bzk@ZpwY9s5<4s?Jl&qJt-Y)CG_rn~f##9zd<)PbvNg5@6_J%tf@;W8)}dXE$qBRvHPp%evJW-YN$<9x zHnM){KnLpNdFvt6MQ-1P`pC*Vf(FRiFocfP$$CG5M#x6x6k2>poXZkaMAn}rXd*36 z#�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ⅆ3PK$`xCgr|6#)GFccG>vtgL< z*#C?PSNIDieC2RV_&do6Ot`|G*j-6(BsL1X3+~2*ue=8v9XWkE9D@nx?!|=1ejg_M zbd1G>*KZsqe0txH6@cp%V&fyHIh%k9S9ll`ejXmdgmXoh@RKtUn*?riGA4X7JcbF+ z+2fdSKZ`Np`=(&R^Z5iOT<=LtxXr1UaP?`}^vG$JO0YSR(@%fqV#2?({5&SSDlcI3 zBBy6_KDGcnXA7~?Bv*zB&-JUAa6ezegx7r$Cj93(7h_8zr`cVK36JOvY#Df7mSg4M z>J``u@Rchu;d<|4!ZYz6Cfu`CnD8^b8v7t}`dfr+FyWr9#nwfnIa`kjSNIs)2Clvx z+Xe2~Zfsvf`sTJD6Tb3iOn6m(!Gv=MFyRUZG2veS7dr&*^!s@Ry$XEx9EAzb?$KDSh;;7O#_E83Ru>aK-HyYA z>m82?w^UDQ43%m=mG2yZIz=UV_ z8caCX6BF)BFRc2+BYjDo#5#c6tc?k;<}sLXuaCuqcR?LYcpd6u!WE9ggmcGZ^}wy9 zVf7=Y=QbTX5!~iUnDCViFyWs%G{hQ#XQ?sPC30$|D<*sjUxsCX+swqeMNYq8)g2RF z>8r4-!S%8*;XR*?^@N%^!>)};pY^Z9^1xT-V*?|nz6`kqv@D%t^^bA=QEga?pbU`#EV#41FUc!XuWdSCfTZolLPJJoEgnRueCcK)jVZzlHVZy7q z7<)Z(>dQYd;b(IRCVbygOt_zKV8Ubn7be{1GEDfs<=9)`UYBFSxe849DSjJ!CnCMI zS70l_SH6p_N^+~QHQ=>fi&cWJtipufai3$Gz%#KK6YlkwnD9>i3KJgR7Hk{1!gfsf zzHcz${rN2>eBTaCxXqo|u88y}Np@qx{rnyiZu19hPvmrd?#1dgIC5ngRv+A#bnL{) z>F2a3VZxu!8eqb^pdr>Ma+=S^nD9)Tf;CBUO)=s1I}JM>ybGFP!e>@+QmX$FLjQ10MTcOn8UVe0ehW##b`pj1!6TUAU6Rv(DCOn3dFySj3V8Rs| zVvWER8e_sc@)S(?%2P4nHk)8=z-_k0glFkI{W|!*^D*I`wZlZs&n~bI;CDbrOn84@ zgbDAui?L1-Y1ehegxBE`Ot>$XV!}JC3nsiqU9l^{S9ZgMN7Nk?u6`9JT;Xc0cSM@` zKG+|@z3z(%*XxG~ukDSPaJ`$b0pNML8Ouv@`Pi-C(cXp$pCJP=;oKlhcs_5(gvW3P zCY&3L3D5Nq?EWP8046-X0!(<83bBVGr;~O(COn1-nD7pH7<&Y~?nRhz^@&(<!6!dJe63Ab5_313-;3HSO{ zY*FO2wu`ZH@MtTr72qpZVyhz3h*o35tNA{*27KRIY#sRBxE}i$JTISM!fkHAgiq*= zSY_nm_dc{gs=P! z6V6p*!n-_mGG{AzRcd2(zL`?X;lQ7|W z4KU$W8e)yWS2o6k*Y6ap3AjR2tXbsr9j`ei+{zi4a4TnG!mYHxgjcgACVb^t`t>B& z8fybyqqdmv{yYy8p271m;p*)$;eFN~6W#?EV8Z9%g)(p}9Wdd(bi{;b@FGmO`o);= zp6G;i0pHgZ%ZQxzU?wI!+AA>O`>w=<@9TyM|2d@YSQdCh*;voW>2IX=!i4AZS}Z3b zefQ19gnNBGCj8UF8!+K|y)ofk-Uk!T{Q(o6`9EU9`=u`?ybJna!uQ>X3Ey`Ub~AYF zw_roTR}RC5M^3Xe0=qNG-G$u?9{YWm@O@)3;oLY(c+T#}3L~fQe-C29_dSFO-!~o; z&P~9Iz^gJ56F!L^#U_EToQxHNubhHC32t*LCOr0OnDBj1VZw9$G$uTQ(=p+CC7AGB zKZ6Oc^t0G=k<;1wH%z#~EKK;%Ab&=Ees`Z#~KR?EV*Y6WdIJW_-0$2YW6Q02@FyVe~!i4YJ zjBSaWcKKFp8@QG2*p4K(6Z=n+`wkO6mv&*ov%4GH13tm_V*9{t?#F~j`!jYR$sNQF zMNYftF!n3>+58O?9z!)IYTi!2=Q{#;UXH><%hXE#Kk`Ro!rv)tVZv?J#)N0-7_1Js zpLMZ%k<+KOG^~C^ddH<>CxX}SBuscj4KU$W8e+mdI~fzM_j^n@*9a4Cr7_kdBApXW zu`|JKw!m6NPP5b+YZH;KY>Nr6?Ri)`@O|yE4w2IsI%2}Pi!kANxfm19b;5*Cl+M`Y zNiG8uo{3DXTSVGR-Lb4BmyHRJuLmallc;O3o)PH@?}g=n$B>H^fzOGF*d*|~V=^Y( z&&M#~`FtD`&J|Y-QiOq|g)^9%cVnkY%moVX07GT2X(n9Q&i1cZu6cf&sVT&Ttr~Ad&67YzYV#^|@ zRauS+_xeptcs}34gsYch!mU(bE0WwwY!$eb)z}*F8m+~ITlo zPBZZdCcGy$V8Y|uh;5FXcF335SKyv)!Gzn~ifxOW+T4!q0QYkzCcIn!gMA0?=Ppe6 zoY;*CSN|RpzVZibPvkV8d$AuQr|0h{Ot{`YOn9H|#||X9gV-T(n}@OLB$sN$DFr@1 zYh%J!9)k&2I2IGm)xm^!YhA2<Rp5;_;YpD-*Dy$Y~4{u}R>0nT$OaIsFviaZGq#iZS7tpMp&V z@0V%V(~;Bf08Gb(`%;3<0$({B6aK9DcTBiv|GSAQQ9Zu0|7IJX89 zo{6>CN8puShY5cRS&waqNHf0?s{}vcRhV$kKF7WQ*V}{%&(da0IQJzce1d(23Fo$8 z!gID2+ZIv0tFUju^>$#wpKy0#yCSFe`)=$9aGQHD;nD8J_C=&Q+m8uf`7o3$PC0mF|djib!kQ854eTF2RI*eJR!@ zays3*Vj19`Wn#i(xB|-p-Y%F+o$6>;|{C=zed|x3p9{l`Gz=V7D zF!l)eGhY!VyhA2plfWnDWbE;X^x38u6FzsRU{8YYn~DinpN0une+m=c$xma?Mx;Nt zHUksx*-Y&9$Z0kIi3#VHV8S!F6cb*RH?V(2PHis3-ib(0@d`}%Gto*+c%|RPglG3X zOn3~dFyY*4OnA4xkFATGPQLY+@Rc89!f)SCunpigH)5ZG_jM&EJfbS>^Cb5LCOo^F zFyZ?)W4pj@?#8}Paz9|gxjmTh&f1F!_wz?gxXquieMxRVb^u)cAXXhYjW%@(pF_ZV za5gpvJfCy1c}Z?QCS31DOnAj!!i1|Yz=U`5LTn3o3|lebXXR_`-{3jhh6(=}iS3wh zy>GA`k<;GYiR}XSdN;NQTzxP0V?^3@KVkd8E4?2RuKqJ7ocje6&K%EK# z&-E)B20J;i?Jo(y}J|>9{U@Z@Y?$Vo-YQJg{0$&%bwv7?2HwYnpW+X&b>Qmj zG2u2p#)RAa1QYJ{2241&5fk3!pJJbZ+pNTde|NkJ6Ta_rOn5%Oz=Y>|6ZRFjl`WX? zxw{qH20kOVW8Xwh&(XJ-aGN_Y;rGK%Y!|rtZcMn%?=j&kf53hOkM<`_xG(!K;nm!a z3GcI?G2wr;@E1(@z601HaIX(z!nt3u-@s$9#)RiH)r2zyyg%>8gnM=mCfv$sOnCjq zU}M4ejl+bWocl51`yRlAuPnfX$5)66f46uL6P~k&FyUU0$AsIQfNcV|xfv7AeTfP0 zPDDCG>SFc4t)yYX zbA1Aqp5#u%gj+cYYY>q>1vSKktDlSsKUKfSgy*^uCj1GhF(y3rQ!wEjc`7DcuL&lc zYl<}k{{*r*)*^Cxi?+mCMWmk^wZ_`%SK|Ne+8z^b^8&0xYc;P+p5 zEDQV`Wn;p*9$3$aRJ|9L6Om>&7wZkK-UsWO<1pb??#G1p?gLl>czlJJ@Rbi@kAT}O!X`ydA=9uDa4XMX!YBK)nDCyMfz67XehM%fn-h_qfVr6Ph@QvhgWG%&6Mo)b z!h~}RFyT2{h?Pd9H**;#eBY~>aD~?};oKr@F}Tgwu_aLRZp4;?cljIGGVqnlv2yUA zeyhNQXX$NB_TLHdrCH7wAw6j)W!e`HFOn48zj|tD`2bgef4favwG@t7*;Vai; z8zRz7Y{Z23#HW~WUp~Vs!B2PoMV0 zZoq_dy|F&vUjG5>o8+okxxWZpB;R?es;dk)}?9Pbvx!^8L_{x!(@MuS2!hN|L6VBa(jRw~ngN+6EY#b)M ze)nS!L{95hfC*n&hzY+R9>g93w>cgYt~UXDILSSN6-7>G!9;9QlADYPkLWQ>c*P#a zgmcB1@a#^(gzG(l319gnCfu{BnDEmv4Vw<$OC{J0@O?9}S&`Gv^=D%{BGR+66BBOp zKbUYozr%Kc*LF81JfGiVd%*YY#r8!`GqE2#kmL?x!Xx@GCfu|CVZviLgb7zaj0xv{ z#eR#N-lEl*@F|r#ooBPzkv20i;nlnX>lTsLy*ri_k=_B>m~g!wnD7ih-|} zMou$02ooOB?U?XP+<^(7b%QbCD~DhsBd5NM!i4*IHzqvVdobbLXiPXa1`}R~d$F;R zi|?t}{ovVs02A&@0VbR)#Dv%OK}>iI4`IT&@tE-2Z~`XW%EMR@xGxhi;rkxN{tBMm zNtp0SG#Q&7IlZ+@uo+2iCN?W_ItOQC!ae&tCOj|yz=U&iu(^@b>^_ePU-<$ieC0e$ zxS#Ve;Ss%v37>AtqemWlVUbU%`ZPrI_&hwhVhUavI-jm~dYfVZ!wm zV@o2ZCv_<%TBUmMP4NwE8F+S=W5Rp)O{_f0RbcN#PM<1QV8RtvV#57=7ZW~--ou1P zy9yJ&Z#5>oBj3l?fOo-KO!&$VG2!|A2wNAC&WZKd2Jn>|G2zb>pJJaSxk^m9XH}T+ zeV=2(WB&pZ&TYcBfM;ndwk^qR$AriC4JLf$x0vvVc3{G9i=EhiBB!(9J4|@@?83eW zSN{PM-V=K;;R<`PeUa0rp#9i^h%_$;u|wcba)+^0^CR~ig$eiNXiWIZTA1(**2aW; zb_^!mvtu#gzSO~l`&k#Om*mp0`Vr~GOvf4|xrSIH@H5?52CmQ=YXhF$wpcsxe746r zM5NJn#5#fR>x^{)_qr>V5s^;eOzet?^n_oD3GcyfnD9R9j$I8NUlu0ZW;WI{BAq?G zupIF2$;Em{q$jQq))(Bfewgqq-H7!ER~UfhMWi#!CR&V7h| z1n${7Ot|`bYy)@<8?i5v+$QWRa4TCd;ZLJmv2EZv+m0OqkM=Moyhgub!dL!=3Ab5| z3GeyT89cY(pLraGiJIpYI~sgnEljxAwK3t}$2$hA2kvJY)+lm%0vcmYBGR+j6l(#V z`IfTC#ZMiuHj&frX^XXsoOWw_?1G5&KDrPS?pX&+ctjnsPT;Y3#=1nLHR_6GfOljj z)-57c=#B|LV^?9qtz3;=2dm5-%jaXmsyQ&}7KO%ht8-V43pUr%1Fu2Vj z*wBddJ2Ats;ouRCz(z(+|7P$gtQb6lQ?RMvdegA!5otsv*bMNVn2F7bNd26R3HRmi znDGAm2PRy74klb-E++gb;dxAWPrQK51CMAvCY*Z_6Rz+QRtmnd3|j;q(PB)v-s{+s zh_siMV#^}ZURsU`xA`U}+~!+YIk6w;wwckt!U<>YRC`FLkkc5ot%J zVZuLmI{~W?u9uEA0QaRK)(AYJ##obx^e%3SHH%0mN^`6QxRsVz8}Rej7HgN}+G8ES zZFaRd9#(GAiS?Y!5fS-AFMBUME$V-;JysN@*=1ANXelQH3U&SRMHsyvPf=ZdkZk<+|P!-V_!6ec_q zPh-NZOvi-BP=d_?&%|78ev*3;6P~k|FyZ?aV8ZXXg_v-?m$A}_G!tdm>q+jP*fQ{n zEyv0u(o9rfZ%3q=e+OFyuD2Rn6FJS&T5MfJ`pmu_6K?ZkY$N!-Pch;B@);(atHgwN zT@|(|BK2i6wgr6UR!sQHuQB1=zcJxmuniMFL$+f(z_YXy+m+;YW5PZA9uuCyA28t+ z+k*+`_G0_ME4CjKzVc`67jVxGV8SQnLF^EChaASL!6QnwAOhc)YRQ`!yzWO~wIb5V zUK^_ezOpVRJig;F;gkJ%O!&%rm~gMtu=?OxO2-;Rq@COlYXp9GG{%}Fxu#gNh}4(n zSSN6W&R7@leOM*JHxD8!+Lq_r`?V?1S}?ZJi{W0O$9e~}EPV~@Tzv{QHFBEkY1s6Lv_>V^4Dg6%Vza>Ct7c7%ZW(OUoIy6bX<=Kw{inEB68}>otSX$ zE=+g~BQfFga}+ij{H%<@#zsz0)i~^aa4QdB!ec1Fgy*vm8y`8{HvuaGpF|U}Ns-ex zl*!m*;5Hw}iX+llFa;CNJ%LRH@4;!<^dwh;l}1kQ+cIoRlG}=H1CMq)Cfw#XnD7|B z#e~;=2PRx^CnkK~e=yfybAP^^Kg`?1v2pw>bhE89DWA6gD~{eZL)pjRlW=999sKp726U zIQJkX+~z}=@Y6URD~d?fCt|{Hr$;g2`~Hdv&+a5lxWZ(tI3iV_f=!J`pX8=t)4}yh zuo>VsXJWI!&(UmbPDI+DbFq0zZayYFXD?#HXZcH*aGMJ-;eIZ}O2HM%uqEItmtxBz z(#~3rm4ho(U@O4C+p-c{1%7TRjFr;5n;})q$Gb ziX9)3_E|klxYub|eeh`0u}0wg8e>f&(hg~gwTMWqw8UD8l-8j&)&|_qwphE!X~o)O z9gFcwQ>7H4$lE)?(|xE4CgJuJAD?+|N%i;Z`32S`YyIABCYLeYzw&hR!q41 z*Vwj*v_{*p9TBPePON_0BV$O%8h~4Ah&76w-c^mUQzEDH`gG?dQGv@lUy@Q z_)mj2$6A0Zw3J0I{!BiW5s^leiFE^?Zr!mgaJ_7-XXG?5y|A2!^#6Ox#d;^XK3HGy zF6f8d9FgYw7A!9!%|t#n5WGVMVS^*5D~Di1Bd7f`3>z6a&F3g=bVTa)7;G%~TpEWJ zL{6;~V&fyHUQfUtiJYnzVZ!5^h)s%|Don zd8{UQCK_W+z^ydJT18I%Y^`5Ua_3|1z-L{1Ot``Ym~bl>V!{CRX; z@Gj_%36J3_>}qf;S(tEzY)rV79#~Isga5BBv|&WA)n|8DBcqBqEKWDb@@; zXU#F;XY35D1=OsxejPl9)>s?;N=om)wphD}^!&BQgzvim6Q11*G2vVXO!)IzN9-bS z^@}m#D?4GL=Cr}OfJfUEyBz!kWMIPgWnx!=SMy3tc(mOx;VZjiS>O?6V?DtYdSN+{ z(=N!xdPk&n?}POPzh(Mi{UfKDAAk)6*BgWlj+|NFOA7aAi?njt#y>*!Iy06DJfP1zPtBjm>YZbN$ybCsC!dHHY zeHA%9(_64@;5N5o-zB+SnDBntjqQm@74~BL!0&+lm~bmUV+SIq&jkmuLy^;J9>%J{ zJxjIcBm(y=AG-}aF9R{*c^QP=5z#rH@-K2>!dDK#hDJ`GCx&4oBhooB3cCk<iN^-NYIp8^)iwReF9-9YVhxwTBXkWx$0>5t;V8VS_hzYm(GFBQn zJ)33NYv2ltFyS^AV@o2^+kPn~+{zo6@ZYHV7bbkVEyIN8b2(NXIh_*~*b4C9bXke5 z24DF;wk{&A?Rrc&_c12i&rh%o;C0xD3HR(%O!&&ruuAYuRAIZoSMJ94M5LX(7prr@ zk^Aam^}*+0I@Tb`HN+Z$*S0a%1l*UVSoMWR?n`yxN^oBqV@<%-n_|tttJxfD5s~&p zOIbwPFRigQQ1h)EYZtkA%U~VAed&mGik$B2jCF~e=Cdo75s_9i6BGV>>{npjBBzk0nNv;AD9>d$1@YvtMgh#sq6JEcS*s3JA8e0RtZ!NYCyzACuA4APbV;jIb zYa=H79{Us%?#pMG@O)Nc!dF&d!spWGm~e$JuzldZ?8gp(=kp-;--t9X|HFjuJA?^W zKa2^VD8FLAMNV^Fjioy3O0{wnb~JcL*209_tc}$Hw^PsK2FSvR?O!#xi zjo3}#zVye0D-6K$!1v{2!hN|F6Tb2`O!&%ym~fkeu%VIDCxKzuUn0_c4#$MA9D$9F zD84&kW55;e#e}cC4-?+4W3htB#rp^wAJO%krW3HDi1dHPn1~5K0gqynBBy$jG2vbC z7*-6fJ_VZ!-n-K<;oMW0@NRt?n-1=02{r?KoQ6KRtW+iZ)qi<~}{w8uI`r29HzoxpwR zj9rrCF2#h;`7W67%y-2yBBv2$VnyJ36R}C)^_z?d@7>3+$HDcAG2wqDcnT)`_uQVq zo{UJ(-&AZGxWZG|^oX=0ORyP9ZYDMhJoB@$Ig!(qb1~sop2vjGvlp;=;QQudr4ea9 z%dkb@UN6R$L{6V4mSW2yr@gcsD+fRC71&B}y?3!y;OAjAwk9H-A#1U95ycaSZ2*6g z+lW<0PN!5Awh27Do3SmC(`V?d*fwzW?bwdUsm-0(u81_ByRkjs&pmsw1K>6fVuvEq z4mpffN2J}8x|rt({B+dD>VW5^E>;iRN*Y!_a_V(Db~1P-evb*?*9a3e&kEKl$#urM zM5KMz70XC+nOIgtn)z(3C-~{_h2=z~^~=S2N2GU0AFOXgI;Hwy{lTj;02A)#&6w~L zehZctk>)cW8wj3>LD(HhZZI|^aym8sgbf8hIm58w;IWUuMn#& zu(9Crjl&ASJuAeDBBvcW5t|f|#y%M<29IwFHZ^j(avCQ5)4QiI;hsH>O^--@DZyrd zubhbqSAPzh6*=vf*_d!Ef5(Kc{0AmH6LT=((`_y$+|TE+7rZcm)&QOQo1_D`nWC$Z6ddW5OeP9TUFtpV$&`UzTFaBBwKCIabg~IqiZ%>_PAu z@(?E6%6Lrp$_ZEzcCDeP(RE|`u9|7@lN6Yljh zm~i!Hu^CXae%Q>&sls!Z@RfhV=76u9i_MEjPtJU75xBx)Y)Rze9K@DIPWxp!_EtpN z*X5XSg$isHctoqQZQ#}1j_m+f*op0moK|c%_7nKNeVFh{@5c^+D;&fQMWntQ#;U;; zQk^-Mz^$ZX8Q}Xev2GD*UbxJb+PP-r%6W*WKW5PYV0Ta&k#)Mnx zgY}I_KV9yJ^#}KJ0G0=yiG1v~h*WPNCfv#(Y%ut~A=psxh=yUqBhpNaz-A@6+1Q*U zHy4`+Ze>2UAabg=5Gw`OE5jB=PP4QaTbAUOW95<4zOKO5MWj~NV;dsU8)zd|34U^_ zFyRWHW1GM;xEb3Pk#tJ-}f6PywcT}@cN}LVU3_>jj*8+#Wx#l7`T-`W5dBc z8-b0CNOL_38y%6JsxjET;QQ{w#zM{e0ec{FIbnGN>n+-7G&yfwWCg3qN#hOK=oz)!60nctOCj7fC z*JHgS(oFQh`hs^sKdgV`bS@3R@*>h+%EtzR>kYz$dwn}LI3j)47=jH4&&vpGWaP9e zqp;Bt=@}b?jRnv3III9XONCfzyzBa*aq-w zZp11hicj#^rie85&Db{Z3~tAEfZN=O?TVcC{BCRyxMzE@eUZ}>upc`BUa^DNA#nA> zSgkIqpjK*Qbt2Nb*Tw2Zr1?z4gui#3fC<<89VT3%J|=u+I@Soh4vn!E5ovraG2z@< znD8z*8xuZFTVWj{rxA6;Iz>)TKxa&NmM+1Bdv+-%JlZap@ReOL;oWl?Cj6{ijtRGt zfeD`lnOL_Z*BukS?lc7Y$*83 zVc779;;oI11dm}9HX7XPF_`eG+=~h4?!$!l{8&u5!Z@q|JWGYx_=q&337Bvz4`W5( z`zB(Oz|X^EtT=M&=M+r%8_^S(@VnzlOgJ|c6Rt1~n+~o}g3U;BGqKs={qlEAxXpiH zb0X4nI~SW5kA7H}u)?mWDUW*CmKE&36E3C&hM5GEEu}bi&RAHOI z_ie_uL{9r`E4D4kZO3**q_cb{CVbz2FyX)B@Es<6hU~(G$G#gAUcc|Lec)F1V~3L5 zVXPY5N~$YAmjd6{9uvOr0_?(w)Uytl@Gj_xb&5zM>Wp;(uU}UzBO>jIOiXy@ufT+7 z{z^k-bV&fyHx6B0W5pb`IFyX#T#3q4zJsA^TqsK7eo%}c^ zoGZqJ+nj=}h@76ymDnn9h1J*^@Re(^b>K0q$2Nd_y%DR7Nc*J<+Z0i}fv_zR>Fu-? z6Ta_jO!%DtHzr(R8zx+RJGKKnXFDV$Kkv`Mz#r8#{zU;>i zfGZrt4uRi|hp}dt9mzGvT7Y}j5^E*m|K!&iYXcrpTTFNc&%=V0KI@-PB)ksou=dKQ z^r`s*O!)iCg;)ph&ni1&oxm&I8S4U`v#wYMcqTHjZjsZz?vCX|POFlO^#+fx57rml zmws4(@XulgV0lR{9~&4s&F3I&Fu2!4FyXQP3H!f@wC+PO;SmkPMn1NR?;C@S zjY#*6!wSGXE5sIo>n+BXL{87c^=1NR>2H(Ym zbMIlRz$?8PTL(2~B(@qh)7TPL980w&(!7oUIDoJ ziI{M`lQ7|3-T)K6uOZebBK4&))&zWIQ>+De3@x#kF>e59=Q} zt?d9TFLL^(pO4)dk;Z--Cfw#gOn5|tu)z_ed(BbtCcoa7$Cgx}glm~e%O z*rbRw^OLdSh*WP1HWhr|G)#D2p2D7voc7>!O!yN^2{r>Q@wG2tiR z7)UW|20a-Fd*k<;$!iV6RTkjt=)$f`huUo zepvs=X@3sDgxkCs%LA`UJ|^6=TQT8QZo`Cg12N%#4#EaUPV+JZ8w&2{Fl;#Z$`RO| z;2m-oCj3l~#DxFm^C)a|t?HWvH@jKc~dr(IBpjgLq>WCAu7TyGjS9X#3+YzDZ( zOiXxu&tbw9{)P$XW?^%{ZO+A>2cOU{V8WjZ=3&BD&c_yjN4pR!jY#WIh6!K!Dkl8) zeGLB1b!g;R~M@XKH1Z-oU$XiT&x=0>(r~<2R>2iV)eoEl8*HS*XxJ%2j4dU%S&?k z*g){A48leyxiMHlLy3Rax_Yzev@tOlt-nLvt(x{C~#C#Hx=`^b==!>e$M(f;vZX zg;+Jz%voKc`js_vmP)4;@Q5<80=O{s46PI@I`K%Z5NmeQkz8{u2Yg>HmIoefJ~lma zchO1-R-NQh4X6h-^{@i)=kh|Vx{-3aFV&c+Nz&`4M5iU=JDsRil51U=)(AXUKwRsrE#jG=@3s@>FM{E~=;VEY+1LL%9{4^QlatEZwJ1EU9dw{z)qXi1HNa zjgZPGsy;IIRH}*sMBq=AsZ)rWRHd~`t@x5$Q=-$8T(c_v|CmQ|6<9UY+}E54e4?bz zs7fnc zjpe}QsaC9QE>@l7QuB#kNqSvM1U2I;BU+>=_Vr?-B}w(A|IgEThF4W(4R|9cQEo^S zqzIZg7C_M;ibO$ktzZQ!SQE<(2q?^_4p{F23#ce4Dpispt;z<3YJltCKRO; zKuQpFfA89Ro%Q2=p7pZ!+5MDzZW1uibMt+yT5{p+$YY>?vv@W(M@-)Oxw#86jUisk z3yAzz*811jJX@prj2+^L5+c7k9g$mutxa=o9ad+xY-AV1VeTD1<8>nP5YK+M34@q( zo7rc7q`3t|9yGUQLFOtbcehi^)kH$M+nc{gdkvAdoZCvv5Lcy?s2#-i+7tB(Iq6Gp zqMYRtqLCpdvpb4tjOE4>jknwcqM~hS&x(mkK(A*+?c`s=U9>$>ndLeWRX|+7EKx3) z^d%u0195y~iM-|9b_{gR>NyT#3klI(5PwJIZX)lcr2KDy&MoA6wvhJ1*-?NsSx8BF z?r|BcXdxxT*;tGUYMyHl-9YC$b;SyuBV?6uM-7CB|=!uQlgyY5+Z+NnsYZ{ zptanL4NYrt!?2uK_)M8B%v9VhChL$T@?)f*;Vv^48^2ILEx8HU6V}3$*hDEAR&x>t zpEVMq$`{hHR}p!LXR&(`13m7gg_+wS=I$W!pzH852KxLm z83WBt!KO;j!b(rW-cHBo-of6rUcX1rrpqkJJv<@upd*^GFw+C#EcGPvGi~g%uxcq8 zu6+50nIec+;bNi^(DR+h&q;G`E;i5lG9UX+EZhUW$3T1i13mjOZ7(76pgsE^_H&wZ z3$Pl?)v{k8UZo0%{8E`0d7tH$VawB;TR{sDa|zLSh&yWn(L{)~Od^^bOh!9}Xez|x zrV&*`JT6Z(@pn6ps2XC)JW-wHHWGP=S4_8(zCfJMglJ?ixe|>css^ov$V0p?xm6su zKApjY$b;_r4Om^8a~rYE(n8qRe_#-M?Y3asEVrFHp^Iy_h&@5%L7xEYF)t;hxqK~+GAyl7+<*Zb;7zzN%(s_8LvdN*E_8RT8fzHHT7-;KvVRNBasIk`^Sqi4I$K^Nqt#;mSKGzZvcEh%5&4qE=`JY6 z+NH<2_Slh&8AI4FWf_{4&pVkJ+-dRU!3u`{Kltfo6_ac0cf zl9Q|cSR&uca=kIo_RholNUy`^+rHQ(i}fv>yL54;2s*mk_#9VE1Y!o5BiyT2rWPtcN|Y1#Y7%-zx*2m{XF)6*p1f0P1sPI-C?u< zo!zT)Wgzmov^|$#pmh$%po{ZMnWYgJ=uF&&ftI|RI`2tqNr?Q&v;{W`8)GHMV&klq zM;B)*AnwR4ksmL$g!P-i?s-yLm%mJ!C-N^@EiYq}t(Ga+R2g4*E|`YBZMk=_>5>a) z&J1j(wD;uCaz(+aB^Ulr@;nAQzS-D3Yi~aGgU#TN*#E>j$aVfF>}NY}0k%k5kbUhI zGcQZh;}RnOi{*aBYSNsm#a5;>=vHB?B^UPf8f?2*7(+dE{+0GDA@We}mdM+KzsW%t zcjH3VmdN|Gq;pGnbFw5oj~XrEUDp!olUZ$vL)%gzZ2F~jteVRzJ#CqU81{S5|OWv)fsK?g zgq=JJdq8>?cF1T9^xS+9gD&n%K5M&&urZb!i;b7#!akdTO|;x3>^U3J^BCy8;{^_k)najvsOHI}O- z@-wBq_*}3glba>A$o(N9@*he+!m87p%VVF3h5h+CHaBh0eSv)?xv=ZL#+KW0 zE3lPI(kI7N>@3ioMdVl8ack(y+BBCCdC)r7VRbf38?o(@3w^1_c3N&1E&MID$ezy= zd7sXNbH8MYAl|EriG0Ifs3qLT8ezMOg*)XQSmR%)CA=Fgz=|wajQL;E^WFW5{c2W& z)tJ>{wPs7PrDn@A-YrYtlr~tF$?dj`*5zB8gvd9vT%%=~-JpxRXhnf*NF>CPjfirV zONhMX-0m2}lsAV5OaAVZ#kF2 zik7A4dok85J#TLB=TjJ>qM@rj6trS36Tf=99PcxAnt-Z(d=OIzXX3wBy@Hce$W5t zM6~^QDJi*nBHt~|x$am+nsZsKhqM>26FsqW(iYsgST8v)UQMx!WR}8H?8VD6w?KSi zxs}NO%X0k~1H}7#LR1a$xIB>$xk>U~sy_z$DdakAu&v4vYJo29DtRM8&}Y7c_8@*UnGpHm%hJ2r2<$Fv z;cjfC)Dqs|j#`!(@j%*_yNEo*d2yq$F;Zvv24F1qupAfm%Oms?;`OYUCvBzQMAR7Kc~n5u1mbf+Mk0tMONlBhmnF)9 zezQe%55)V?y+k9S%-z7Mj3TPGT%Ks2<>nJDw%ih;nqcyMRV|SReMT8i>k#kESt36< z9lM)?y(;#M-0@z+UYFL7t;OEJKyy$%uB*3WM#3HqLo$b;_IdE`LP4k8bln~#Cc#COIA(i5_!-w^+0m%EJx%)bM3Knt!KS3(5Hmn9Cu+_G9mJy=lexipET$CVwKWQ z`8fesg*`7l3)i|AFerEPWS_l=O_q}333mzxdQQKB{a0ED-(J0nyGkXr478R{87;)!lMs2(F?@zWxf>{N(>}-MSZ*%I zf$m76YKYIDd7|wQYpEyl5KFo*Fwi&8UoOuK{wggwgsA%Kv}B&h&r5S|KK7l>;P=>% z(q8!9_kZ-|=QNiPdC=Sftj6ZE7F#Peo3qia!|KwpyNyH;Kckltl|ef*$(mAT2JJgrzPFK)ZQYkrIg6GOiQ{}*#2qG9k3#k1HD2M zd5CA6E5SgoD5Y3CDH-->d+bE(bp-}mGK-xex$yjXDt5Wmc?DK!xhm{B$%XS_05;G{ z4x(qEPcuXwbTzNX23vbWusfvAFxPitpl{)F7-%k`&U@115~6B|-?HV2{75Mo_QWXc z0jVXN#iOw?E7CJ(EcSxc`64#iN>0JvkX*RGPsKpzWg0eJ+S@EYLofsT#B!fvbF78A z*gR`tK2~Eb)M9I;mT<*fht*jv8!<1r@QKg;#?JaJwPLK&bX_or&yns}tgGe9u?lN1i-FF|Nmvgl8Ro1f zc7|9uSI)$GS*|x$X|+^gpu6WP47#{myN!)xfij?*0U+t zR4Eyr`KDphEjI(Jme#{N>OA&|Hd{+x@=vtG}~ezp-U_$|`_V#(cz zgm^z{NaWY1Ikyh`!;ag6ZU2oc^qz8suE)I0guEkj?su*>zo&EE2;1ZLG}jn|F0O&x zoeD6}b>9&kh%ei8#S=uw#9BcVID;BPw zt*{cYaITaxhBB#B&i91KLl?JGo^(2q10A1OXE{#RJt6X-JLG8Wm^A0QU|q$&l6T_e z*m0IS9_yCY>AGVT(t3C{&0;4>o#EN-WDN9 z5bKkU&-G;nAztgUM84AMtit-Gv+Md}11&cQ8!X3#d({wZsPrtn)f|T9Y<$V@nTZhB zXcEz6h&yWv(ez+a%M7Aw%jJo@<=k-A9dzA^+Ci+dJyF+?lXWjAs(`pgS)zWH>rXTo z;&DTWhFWeIQ7)K#dzBD*(C<@6V0WjzcK2WprnR_-Fwk>l47GrMA4fD5Vs0AIbcn6b zAgTua7K&&N#FBG~<^_{^nNL&$@wi$d58AV_*hD!lTu~-rlhd)gDcGyB4q;!vMtg6_ z8p(GPc_Kemj*HhbY`R$3XEU((ZJ&LBf$pW5Shcl~$7b6#BpWf%r~6IVR6NxQ)^a7Shc#-2wXs}Vti8h9d|s|!;b5Ton^VRv0gHU z@LbRv>m#*P*N|CQwUx|c(Af===a<>oC)WC>*c>Z4 z7yCv^hHu2a#X$QqZ)K(k;`oY*JZQ=J7{n(s_Z zes6-P4B`&yMAQ}HC+~8i%Ae8}s)$BHEIEp348)RSiN-_h%LF11`o!`x2HMXBSdEM! zeD_(4Ew_>@u$9t6_;!312D-MZG0=HggMrS(T5P@K!ZXnZtj)0KiWb6ND#prIrDs5=Rs4Ng zDJgYMBJxMCO6TPmtgDqQ$Bq{ZKeyJ6QV@4mDNzN)F=UCnbdg~9$Z=tf?p>A1L0rFt$d9z#C~^?* zw+WGdNa_sFpJT9bHikzr(C5#`Sg|M5S|$-qh4^N68qxHp(%cN9YKZroJdwAYdm5W; zBbtIuwRxF_O&1F*HUk6g+56ZBmYa!zo)5FIYRl!ZIo85l>lKK=d=1HI1wg#9eJ@C>~GTPbyhHClz$Nu8?} zP|HRPbk}WKl^G0izYHPDZA)8Ei2U|6=jt(U$GO#vkVtWZuWl>s7 z6_E#>`C<(8Uc5I3`oy^pJ!@$_Bl4iv@>W_d-j={R7IaiK#6AL@J`)YpsS4^H!@<+>1vexg$K;P-ygN>B-!mb;IfxZpB z4+CA5`x!07=hlSCL%eFZ2Ucf>J}5c4;|(LqLChsY9(3#vVPmY$vDkR)^#trm>-AF@ z=ufmhjZIyhzKxlNfnImt#OB#?^Rb_$&hRX}09$CSFTy~t?2GB=uTqQ5bwcDp`&om5 z?yOpDsgw+}w2b2*&R|01L37J7(Aiyqt+d>#)tSZ+TQ4B;pewx^TWhVa!|Eg#o**`2 zUM!p)ZVhMEn$%ih`-z2ThBjD)D~$k!#X3t_%YmZ6^%0g`=>}YtmhC zG}hH}kkXLu$Wi-GQm zhq1@R!q5IZj)BhLcnq}U1k76tZY{sbvo>9aj%)c}l-5#Gu008n2mOR{1Xh;jTqo@4 zwdsl-gMoh6+6C*nHf_BeJ3%b$f)lX{v9Lq3*eRAf6$5=bJPqq1xo}kXp?J(>v%iWEQlw26YC=9fg`>+SBz0nwGEe~Q3S;;XNXwSwn zqH$t!El7ygKs?{q5(zQ4j>uciJ&KK&(T3;23D{FoGMwR0V_qzt;p^!2y3|HtW5mL| zjKv;a$2$S}?F;wFI=%&7r+Xs-68V0(r-^AWt$JoQWpm(t8mYac9i-mh?9s{jqHa16&3r~4-u`i`$IFG)< z=2^HIS zx$1XpRa%Q%jjfknhpYYutWJ6z-X3hkwx&IE+pz6+Ts`LHIQeM`=hpL-w?3WgMp)zZ z>Dga^HCs=~u`uvrxz1srJ2JsQ$37eb{oFrdeWqq)nyV%9aH1O}?}A3r9_Sho z`C#E)*L~z5u2@3k$D}3QSZth)_E8Muxe>NeB++S=2&0mVqeG@!oBfJ>>IIg#(j&; zv)p{_XK5kqvjrIF{e2n zwdL|ezD1gIEiusj(h6&{fhVN++s#;swNQ$6w30_)pl8mJ80Z?6(QDAR;6xtu^Ft@B z!fMH4J;cIQswZ}qwRbkwYeTwwdSjL8Ot>nnpViVIgY=mX8z{$x-(w$yfxcb79=pME zgE7$KhG0XjQF6M_G4h=g#0NTud|=;yxQfl!Gqr!R7Rt$d6BR zZUXj%92f4zPhy~N)}O+jvD~xRL>YUy$4$bXvy#talf}ZdU<&q{%uBfTypBz^eon(c z-?+Sqy<_8h7n^P+XJ9j}pR+J1cb()N%ZJ!@%hh8$q`mMI^e5&wq)#)hj(cidI)eq+ zo^@$%FAVfr*96PdF~0ERQHT{;t{7`sr}ODr)nyt(nY)ttEFj7SYs^oCBt+E^uaS8o z-#WA|z1|N4eJW{#m82zIDRz*xa4>ebSa?&~5i66vg!^A7?5Mi*y?19?huB_1b&&K-+&m0pK)x*Y2!eF;xi-LVR>@a`~+^|1DOGNLonTJl65bk5GAb%=A85cyt~ z>y2G(b@stPuh)Gs(EV}=c9~fCKId|*(n?lg{iM#Ym-^RbhTfF+Wf+kkDka02GYq>e z?Tfn|18w0B>`rSThk@o24D`A*9J|ZfyBixRCBt_;qcG5`)P2|kX{%h7~o|Kds zOo%+_Xy;=;r#ZI(TWGZ`!a$!j7Gu9!t_JhgPq&fXvoXB`Wih{zRSC~^ZWFf2Y%^~J zH>b7_2KrXI8RjkL{=oi7OS&zY9O%;%k#Deto^6)z(RafdZqf0%M%eCKs55-`vd5N8 z3B>DXDNzOJeVQl-`qV|_gN3udF*%4o;g%5jTeqaU;5H2OT)7kv3t_i6Cw|K1-q9V=)H=_gZ6VIIf!?| zgvf)oK8hT~@g+naw4e8F$yDE;mdq379!TpabvMZ(~|Bn4D_D!I0oAK zclbN3de)oBgRar9Sgp0b6ayXYG7Pl6<=Agl=kM6cv`)7Q10BO^ zY^{tT{I=CP40NBZ$3R=yfNiuoH!+{P(vk_02c5yc$bp{kL>{zff0OfR$%M#*{1d@fj+zKhJjwe8g9+xK%XFpJm@FrMqByoR&tzN6|+PhbTxO!K<|coPz%I$ zNQgY>7#d@s&({Um!BS`V-K#?|&{;YZ13j)C20GgI*kNg3+~Kqbu`dac2fgxj#6YjR zM_`~ce3W#@$D~W`dt0WR)u8PQ8 z&K-?`&crboXnS2S(0Mr)1Kqn_G0_dOPQpMxcbtrYKC_>KfzIx!7-+9g!$8;JbPRN#^}s+^v**@KHN-1eo~Q=mHLI4$ zgYK*|Fwp0TGcnNi&cZ;)el`X=XXjv`PlxAXpzGI*Re@Mb713abpDl(EdCT$VgZRBq ztMfbzv}fltgAi*;h&<@?(ghgk8eNEiKK)*Vfu2VfW1w~R!9d@e_QgO)dkF?QpO<2w zYjha~dX8O=f$oD$ud5+WZgyk)$dYXQWoewN6C?t(io&=&5*5-Ay; zF^6NIe-~~920Eg}(7iMY1MSOw80eXEKL%Ro z0~l!QqcPC6eGmi9J%oYI^%x8^Hx>h}^I;6M+kB8;^mGZvqCo2cN(|uk24^pe;Owf$rp|G0@yI80e}zi-G1QVxVW-Bn)(aK8Hd2 zi4S{TEWDq10RuhmMeH@J^K}fgmNziaS(>^v(-mUt~GY0xu?GFre3|p|R*4{R3 zyR}!3f&PT>j;)!Q5ZjwYR1I+_=ZQS%uKN=M9qmpGw7p#z=otROK+lK2G0^80h$#Va=`Nz8L7)*a8Fn9hH_CXq~Mv&>3t^ z>!3eZLgYceSKAK*Js;X&px5%Y7-*gQW1zeI0O|z&8z@8`v}6eeI@hHb=u8}lf!+pnLaVauC}~h&-g9kFZ0nXYDZ1C;0Xl=vmbP z10BO*)CqdN6M4|~4#z-ew<89+Do1R~lt5hRQlcEhIujyqId>!mT1y!vA+AG0X?yP2ZTwHUY0t7m9`uuGR}A#pT8@EUCyv{enFz7uB%&IK zWv;grp&1#7}==qSPPSANF@}Ofs z2|LAFKNSPryQg8Gt9d#G+Rq*s=pO8ef!=}6z(7l$N$a4yoXCULc@_rx9C9`Wx|7er zK-czM476vxFwp(d8w0KLJPh<&cRmK%!UgmN;-`s($b%ktAqG0T7h#}h+{GB^O83D) zcXD5>(nec_f%fbw40KOije*X~H5lj`U5kOX@J|f1XaB-Ld)5yFZM{Esos}HG_#n<; zLgXQS|K|o`pz}Eh1HF%3kAe2=1`PDsZ7>FUO&)@Q_Uzvn=>GgaT8EfRh&<@u4ZRT? zCM|?_&9`8nW4ILqou%6_(7xP`f%f_i?i~$9}@*oC! zK0Jhh_G}CW`g2xeG0+uznD!vfbwcDp?@o_kk6F(i$3W+0JO;WB6EM*Ed;)t)N`~)f zpT?e%7Q%Ps&tenp9xw?5y%s!&y$co_p7?PLseCZ=GZcbQkP|JoQ{ z#a@$=;iorV$3RE)1_nC2Q?Y3_qBk+nGwv-6^c;Je8HD&-k_nLqy@tF)?p-n2$q7+) zFnK1*6M4|z#Ci_{?aOp*hV}Y=4D|Z>0S4NanX~}<*C>cQ=)BCrK#%(n16`GmFwmV< zje$M|ytFc@y2DaETlj1OYTgvf)o@Fxa(72b(~ zj&BzR`n2{J26{gHje%Y@JO(;Gx1IO%+tU^rV4(fn4FfIN5Cd(m5e9k|@4h`#0r4uF zCCWjZ-Gs@G^CkA@lUKnW4nqZ*UtPBQvb`)ZuJF+SL zERs6qC&02qxnT0SBO&sjYg>$g&ivjO=-Tdsf%dE!20AazG0-!0U#x{#xMH@%K-aw$ z*4mES4+9-v8w~Urvn>YN!u}X&Uk<=ZtkTTm}|$_A&xd7@}O^6 z+hd?-M+Xdab`Qfq&#}WX&^haf9bx@E5(B+nmof7Y>&y~)(7Sdg40O(p!a!%YGX~ni z(HQ6%a0~`IFI_OupT<5G13j)Q2D&H8G0+x{!$4~}9s~VerW*#je%&$9k|$uGEu4sf zUh68btXO!`ISB(Tc`^oC@)Qg-cPa+j-f0+Ud#7WdSH&I}=zi&mfu0X%V4%-+XJThr z3uj}XyXPDXw7qk&URJU<2D%H*!$8;QdoCyYuNr`XuJk|*bWaSzK>Kn%cC*#;9}IK`hhm_| z4Z}cJZZr+CFYz7kybcYPbK+mHQ%;3H0h!P?X`hIC7Hp*(b4+EWv`!Ue5 zKR_)I&*_B7gZ6VY209ZDVxW8XAq=#ZF&OBG#$uqkhcVDy_XsvF?X`Op1FiEh478t* zW1zckJO(40IQ~je*wr z4)(5;40o>gm=}n1(Eai$ z20GWDVW4N#=NRZp&%r=va4rVA?q6V_bM_?$Ixk;gpey|~wpwZlKeN6D18rd~2HL_p z478T@80d&LV4&x89R~Unm>XFKi0=opL^+7>4ih2|+RsfG=pA-52HM^q7--2Y)B^FT zD-qk8u0ulPK}&AKK-XwH2D)48G0-*If&D4DaP{7af%at=2AcZ|1D(OYG0^q%80h|V z^*sI7lM8RN8(^TbyBpT9J{?~p476vv*YmtvudA6Q%0bK}L_S!ka}NykyX3|g==o59 z?P;y=g@N|E2?pAi3|44;X^MgNx(EY3t{4No((X;qKzl~yL04=a4D@|LGYk%M9ptw} znq#1&-4_G>dEpiq=*+joK+lI(7-$QvG0^9b{pbtC;}RkddOozlKzrS`K2rj*^->}a z+Ryzl&@=7;4D?=Hf`PVPih=${z=0U(i2i|r?w5nGL&U<09g4NHp0&q7>+FCXCKi5o z=Wq-(*AY9SKHXVIVxTKshJm)%2?Oo*Q5fhM-WfYuEc`ynF<2LC;aIGz^|KrU-66+e z-K=DH4D?ga2^eS#Ct{#yKm`Um_ACZEzLPM}ex8h-CMCl=meVoN`Rsv#&O}cPbT!Yw zAblFe&Xim@i_gM9_wL!)IabTL80hDRUKr>YdSej(_X2kw2HN`h80ZuH1sLe~F2q20 z>qQvoaTjBt-vjl*K#%K-f!28m26|_{6a&4sUWS3L`{mdbQZn3quf(ph_O7naR6<<8 zDxw_3zYCBMRR@!w(8v>c(3QRhyH;umzvJ*vte@4{9|OH3T!(?q*#Hc5<_BV+Z^Z^- zpfhnj26`>H0lVMod;kNTm(kc58{b$AbZ0$`fwuPu20Aa}>NAZY{uXEfQ4Y%8Bl4RI zL>_dsk7A%ZzwM@f6cfp(3+hXB&72d%>XZKwUH1{3`Iup||(4Nh}K%XGq$3RPdfPuC* z69di7!alTGKEgm-uf{-I$YZmmmhk-fF$VfA#wQqPou6W$$9;x@uGr_;94k2&1FiE5 z>`Tjig@L|t`5FWLiI;Df>u=LsLgYcmH;)_~=9=?2a}pvC+Ozo>=(>MbpXmniH(k0D zRRjya@kit>=f1~&kmJHt<3|kiTK+!_^c?#M1Koo^W1y`sz(7a45CdJ?MHpyqF$Owk zOEA!#{0jzJ=dT#(zOKPQ=d2b3UCpH!=yA(1&^y9%Y=xEl4FmnDq~9^n)m({z*13wg zUY*`a5+V=U!WwLY9ao2e<~CxWcc4ueXq}re(7FBt16{E#tkJgg%AO_4LF`#VH_4?%jPc(6P6`_DlQX z+E6Fxrz|25`cpJ*G0<~*e++cR4!}yp!gp?^7--1@G0?sH4-9n04#GhDaxkq!{KS_K zdC<`wf`Ojlhhm_$w8KDWw><_r+71}#RpT)1aH%ESWjbP@V>n_*rU+u4#Y81=hIdcELce zipOGSTb<`%pfh+b2D%QtFwo{!){jvW@$?%jg6a&o- z!$4=^77Vn7TQShL4!2>T&%(E35dYeuy8{EQ^G^0I#830ZL?saaKiX2F3W#T8mZ%cC zIQc(8s)%yI_evGv@76aWc4`ZO$f=4jWo{hsm@Ai*k zpe;Oxfgblb26|;5kAc=Q0ejNc?}}l&hyv{R`Nv*^t-c{u$QeblQGb_o`Qj{<}28%c22)Wd!W}kA`kk%QoW9W zp8aoNpuL`ofzIbN40PS!q)v!qPl!C|ac^PoNG`ndco&;tx%V;9+y~SG@wkM@&y-x4 z!C4q+ogZSL*O!kl&~vkzT0qA~{D_O|K4Fj z-I_-k-c@|5Iy8h&<@MV>&sAyC)&?pieV1Fwpbw zeGGKwKfplyGLu>$o+}BF2mQ_GS>&p%bs`VidL9E^mDw2RPX3tVAigh1h&<@o_z4DD z@>2|Sc0a@BNG|Nbxftj*`3ns64*MmxQjQC6!B=6RyJs~9I`eBV(A-)K^ykmkVW9hS zJqB8G1GdR(*^Ghq`VS2BO0)$7ZEq{K%WC-x1I_)-yg(dZBIeWaB}5*yq}$2wmG8vD z|3*gSgN1o%fPt>&ZW!n_vLOb#?v1Dg;)oI=zq`~K&eT0H(D`hPfsVZZ16{E_G0-*I zi#kEC`a~Y|xF#6rEM+jz)hxt7=d39PdR!3(dZjJKKu5GU?LpiH36XDRbvDO9XJTIr z^z%v!40L=gcV>zpzAq>y%7I>ch&*UNTVbGMXpMp9_QOE;OB)Py3~e#cUhj{A9(Mo+ zTCxNKorzKm^jYIT47BxsV4$@egn_Q#!5C=n5Jm+0`GLrT<_^W$S?ld72{D%t`3|z0 z^8b&y!>}W5KFcuBUU$MkuP;Yope=O9K<{ctW5?KWT`t?ld$3W-h1PpZDPsBj`Qh|Z)-7E&$vy(8;zMPDKUSCeZK<}xiVxU*((=gB$PRBsc zu^t%chnr&`z8AoPaaobpu7A^476k= zR%LZwg@KOfY79EM68TFN*I@sY*P;A`d!CV=&OOcq|6mvxhkj;tolOJm_3Mf`N{G z90oc|k7A(n`4|RT@^K6_H=Y(iujND@G&cbQ?aLDw==wd0fzIbs80g*NX$GO5!n4!%L5o_uSa8`bv}rJuHQo#=q?{auR&*!$b
    3w@(2c6avTPFMR^nh?aO1><5qG!2HLX;80dUHfq|Y!Phy~Be+mQL zSx;k-zF)$gkzDw8;92Z>Ywrc@MLX^#MhkjxB=Vqrc^Lz(WikdjXHziHv*Q&E^v?WW z40I-5#X!%v*D%oB>lo8d8KEkT4y*vh5ayACKLq295K+iZL z4_(|i`ODRxkOMuBh&*TupOS+(6A6(A?dNA0=omi7K<8}Eu1w9`bPTmb9^zlibYEbg z=fjs6=sJA0D^u}xT1%EF7cBf=8$|wFD>)AXtz|w2djISCk%9aKVzW#a{g06a($qG7Pljat!ngSb?pUmi z3R}2eWuLh{u|3W9!uB$2f;A~@?Vg_{B{PM-CX-q%<}-z@T<0exSBMpwHN~2m6=6kY z#aMA+Q+HZVId1PlfBW94-GSxyrsOr}lS?pfIkykCPujX`hBY&5jx|qfar;UWl zvl6Vtjw>znMWuxYxV?VkxMHlNw6LvfFII}RD=qBkP8Dm99WJ>cVjVH);6{rbfkByj zSL{d(I=B^LWf&ans&B+PVO>iL4|0XSW98WKR%bU14tCGpOs+e2f|WcGgAV*>o8&4m z(4J+nQ%Va<-JLg)I~D6u%J?pkl0C6r*0bJNAF1V5$@Rr9vpO%wKzn@!1|{wVDS0IZ z+Il4h9o)x~tHMA>bQK02+*-+9jSVO*Jk&K`i4DX+OAf-WxAtzpK*v591D)$3*#B8A zH)5b;xCw(6?l2kq&Dbqg@>UGmxZ|bu+c3~NZ^v>rF9|kW>I}0x0t0Q~E({KE7fCI5 zW1w~3gTbNhM#g9>74yJ{kk91QVQ05iY%{jia@(-&R%bm1 zdfW~SI=XQ(FMnb?Ew>BvcAPt~&_fHi|0+s0z~CTvne3(Au!aXx=h2dDgn`cP?igsv zJ+LNX;hfH3g;Fw{V@)y8ToDEbyUV4{VyvZDxH7cD+Q@O?s?in$ZEt@Jw7ml`(Bn!l z(A`stwUgr#X`wxKxReZ6l#bXDcHEH|=)Nw)AiYXqT@Osp{&K9Fji@`;$ByfZ^|Ryp zW1#)K4jU-Ph3mv1Y^3B~k)Dmh9y~C;N`=3ISUa=!SbMV$SO>Gi zu*1v_#|}5^h;=kO0z1O&NbE?nGOWz36V}P>DC{V+&RA!&qp_pSj=_%MTscP8!F9p9 zq@%d(mWjFP^bgq=j4RiW1>bLToawFIj>nRDe%$!)~^$z1iv-7m+TLdjFOy1gj5 z1oNk`2R{h4oJ#IgYWXGPFn=nw>@DU_=gH%Ao(BhtWwG4p z%*0)xBvvgpRVP3*zBa+ug8tXgtsisdmcxglb% zC$;oUd!5B{J=0z%Sar{Ie0i*CWJac}Ch^DOPbtI@ejO@{F{WDlB(K zI-&%tJ|mr%JT^~qVeIp`a@bvuN+EwATsd z&q`ZxXJcoltLe_6*XN`?%VN26($*6UbWaS&K=;H5tlDyUtmd3_c55+jwYYPs^W1cn z63mOmb?AllvR?PbdZ+W^&cn_#J0CkgT_bk^c0oGV?n3NBn~96Ci_9*j^^4PWNHBk~ z9oL7k_bJ@pO_TY|V!1xd**9Vd=KEO5zKphSYI$sSU*_yLDfuxrr*Aq-bFq1S(_YWV zYWk+@P>Xpf8Fr+*1iK{dnY$Fb)W&xic3IkU@G9X>e3|2TGUCpLgk>!f9 zy$5iO+I`R9b8O!M>3p`pT1pGys@MuUWI%d$9E!D*T`g^t(}(wA_pI}$6C7Qz**6Lyr1p)+>0)Dq5)W3aB$UO2;$oJ4LK337?AF)EOyF(bfr(l&ayF_jh$n;bFp4BFTJJLy|MGGz4NgPtY;Tu7uhUb zjPr`84LO1vijh4@~Dei&YNfx$aGw-74&F%XtjayUZZ= z%OKtfe03ou8)3T-;$D26jA4&Kg+*|nJN*Lw_a35>U|V0~XS0dQARgC=r~(dlxAA@= zOLU{{*`3Th29Rd=N)lOw=rxjG;M^5Oezy39*(IL_&NU(~?MN=I+0w zz_lW32eH@fiGJXSX|%5KEpwQ~_WDKqVD@k*%6swSVnb3e-7W0;K*JIafEzaG5-9XPiqIKs6vnK{)t)7xSfi)gn zc!aBwTmiPH>s*3O zx7-X2TD#18a_?g^t>i51Lo4|aR&6Eo*c{8v#dcWkPYiT?J2B8P?85%Gk{$ys>4soK zxN3w?pYGq-zs>#+`#-ZAu^Y{9!fs-150Npro3WeC{)7F;Y$!I=Y#27o>=x`6vsWpjtw^(fsHV`3%kqgZtQNe zd$4=V?#1pk8;OlH8-~XX4*m$!E*aWjDuqVu(#GW*J3VX`zY3ymUXRv3? zp2eOun}|&`n}kg=dk%Zf?0M{Yvlp-z%wEJ^G<%6F%1f!0Vihm3v%1ON&0^ki?q%%d zw4|GiO*Wf?O|c#M3igWGf3g3Xy^6hR_8Rt@+3VQrW^Z6`m`%l|noYx|nZ1d@(~$v(K^5&E{Zp%;sWqxhBVV{$F5Un0<+T zY4#QNmD$(W*Jj^f-IKVd(a z{fzx=wg6jTwh&urwg_8fwisJ%wgg*Z_6zol*{|5IW;IxiSuJN(El*_O{dFnUzLqE4 z@W!(PRxTFaM;?b&)bd;q-Zf{jp0zyT?mvw4?+mPOZThzG60ENvf==F8jsHN#W?mle7Qts&Ceccn-jHT&l-^b=kEwg2` zUtkNYg@xEEspVstv(;Fg^>ZUuZ)4bjc`12UxT7y)w98W46Kl1MF@!gzt+DpY(lKc3gjKu+=#P%PmW1H^Ih8$*^0;V&j%^9sF8){U|nGdiJc$=LBq`^Lu&a9nA&R=2>n&R%5wZtj=oLi2Wh$h4W|&wtaaz^YxgwoLhmdNK3lk zu;0vn$9_*|!mY$snytcCrM-5mvDId4ur+3Dv9)IFuyyH=K6X0Zm<+96ARz*b;2qvm&JOCg`M0Rs}u|8dli-w z3*XKq*ci)=#is5^*I^n~ExGvJ1U64{cgSexV>LUN`S5*sE#`MHzDZ#w{>1*YmEMW% zG~0#k!s5>?{Du8x_BZyonWqJxS}n$pFz{0mZ_8Y}roORjdXT$WK1CK_O%F;V7KWF>?rJLD|rmo#cDYg>*|{B?P{gYatxZf1Eep< zVaMBX-7wHxcMP=k6EN7@)ttw1Ct`4*>nOEUV4x+lSk5(VP1w)|O}n@`QgRq}TZ5)0uFXE!?O3h>^Ri2F z305t2?za)kW1q-~juiV8`&{fyu{jvDb^j8Zi+yh;f54!v8zH$LF)#M4nA;88t?3c& zgJ2D5y&)?W-jyWS)Q0q0-dVY6*z|@?Tf3D~at2o2kQsEFuspV2tcBPHY@;2w34<1{ zqvSSY(88T8_6G(nTwk#*7_@LVh;7B7g}Ym98wM@hlVaO3XyK-b)nm}YeJ-{GgBEUy z*q<2a7SYxvS&W-}+vtTo|WU)%I1I4PaoY?VV2{uve0v0BXAaW2F7GUR@f zTo&sk_P1DXtWvDy7OV;zEY?YE2sTvg46$KYPV8#21RIm#e7H?)EH++plCwoOXrF>g7yH*32$xzMvLRw)+htit{+7LNNrEGO1_JGCU( zaIw(O5ttVXBXaw2$J-|@S%ASouDcw!Ck6+(O0m7LBC&8>F;=oq(++Ngn&wo{2P& z76YBZ{W0J=nlzZJ;Xu_J+ZUI!uZa{dWnTPdt(=f{VwfYi1iT*E%e1M z5!-VIxl6Ij#KN3ij#Z0=(dMxlu~26%=EdS%x4>FpaaCGkEzMdnXRT5zZPhe4SWJ4I z5c!-~*aZpZt)y#>wN8(7`(gW;wV`KiQp;mrEcDE^WtQ4vVee+KoLD#?63mN*Idl8d zv;DEq>r$*jES!H?tcO^rvnK|69-V=mB^JhhHr7il%u;VGCl=~VFfSI{JD{mQj? zi)5?R6_tC<3bfv9Za}f;q8yaNaV@pCQDfBDbv!!W)dV$hHAPKbC!iBtC!!Nw%}_H} zbJW~*5<1CsGCJAS0=00pL@iyXpi^9@qElVDDA$#T@?5P@D_3jO+SLZNakWKlU8kYb zT&JVcUF}diS9{dnbp|@abtXE~)d6*IbwnLqXQ8uPXQQ)S`6%C2fC^mapmSX3qH|rH zP$ySs)Y)|&I?r`JI^Wd=b#Zm&UfwmWQWSUP^>(b@g+v#i3tShX3tin%H&=Jm-E|SV z$aOKg*j0!MT|H0_*Cpr@*QMxES5MT_)eH4?z&J{V-X^bj7CY<6lD|>NiIbxCPMC7G@ytl zvWcpB9U?;RdNfG6QsoAtAzpJRBAP`jl^ceLaNKY-!gC`L5k__sB0`%t7iHuVN$&-e ziixDn6s7nUZ}V0}gjU8NB8+-08t1w3hzPkm5D`XpCn`?&5T($QMZClOUUPVg-ifeE zl*)<5QPw~F3%`_N&qZ?(5soWGL{c+)8WAD)40=iZwW5`1F(R5peWWt_d(Hc!6cb@R zm!ai8>eq`h`V+~T6;UcSX~$BOV$VfyAR@G~0uf;)R-!l4{Y7t~a?e$uRYe(jQL#q0 znq46pM2k@YrQ&tz8Ko%2M40UdXni^tZ9qixXq{U57!hH|K0!oSmyL)>dWbe5BJ}<# z+UzyAAR_elc~Qm)B57qLrD7sEE=4K!T=WGZLJwauBO<9;Kq)4|SiV9;80XiB2>pG7 zD!pbEBEtTCi-^$5cZdl6eb2~#D9SiK+RaL&D8)oL3w}hgw-Q~$=PTEQHghP&L}>H> zuF1$Jl5@9!QZbRVlA;uQE;X5 zrBv>@3QDo(qLv&-B&`%sicNO)pMr=m_fyfe=^mm19CuwhS3oHy!W^zgL>T8lG$_3* z(O`5#`nc#uG}Mn9hKjvr3QhEy_n=9>5|de_`_#kH`#E0Gp#nnZGz7f~uE!WBR%Cc;W=K}5KFe~ySS_b<>->E5HQjD<*!E1)!I zd%9*RrAi_hXBDN`bI}fZCz4!>QsuAdm9L@{6G<-m4ej%}|BZ-nU-}0TVPyLe5%&E6 zXU~7>Rw^j*yWe!p=vv-uUz@((8zCahHX9M)+|5BmXtVLP8O20+m7x?9A$L3?LMu(K z&B!Ohs|=->2xn{4YctA;Bv(Nx{?1$Z9uY|{`hix6q|E|K{fQ)3M5&la#+jlN|C!E3 ze<31_WiR^Id-x9#VJtB{5J@Wql*)-PGD`e9Iz7K=0IvuG(kp)$BEl=>;i%>S&T@M? ztc8y7ni+@)H8T+r_Ad+Fq~q*~^JYYZ+$cnZ=aRlF_9fTt(s%eZC>+sbdUFUFUJu{4=GA95!#%Dh%krAXolxza@^zTR#KE= zA{_SwdNMth=qXg{tvrp0Fu!NeTpedCI1fGRxdn&_Z7xJ_dMj_Cw>|d`dN)0m=zoli zNRCTUiit3XHHZkkuSG=Y;XRHc!b(tziI97LK*pSp(#Mrjinn;~bM&RJ^;f7eT{Ege zTfOEs^s|ri7y2WTv7{))JH6&Ew0l6t3H&oc?B5;~58!P5QHriZ*Ri)R)se2}x#xOv z_9^ip=rGAX_dXmEdA9m&ZOOiysDtWB_HD$mh~TsP1GI7+sxR3$ zBMneP$$DsnvL*YTBnP#S?3;>~=v2wRC&@*vCHt|2HF&>+daRTzwjFuxlR5$1j)8Y0=Q3`N5{HyjZm zHv$o1WFrv~Mtu_+bv@_x1sd6CG)6hwv9V~pWOKg*5uuekQL(p@LX#vL%Vbm{*&aahzK=&za~+$-b$03eA!1Tq;FG=>2I#gxoWT2)Vh42=kkV=KK7fMb9Z` zSI6^cv1I#RhKMlEmk|-h`3hR(k#6?L$O3d;UiMT+er4*Xvx`kBE>vfQXR$7yakC7!m1j zNeA*iZXmB}PlXYJaVAwGa`;as(p6STYb1zWvWcL^v)B5ywO}mLpMJA4@%S zoagEzqG?p3{u-c$o@<1%rKe9LmxBntc~#BEhzR{1kDB<%nj#|1?*vwfNXC+)6q~Hg z6H&8tE^3YlzVTBJCm|xN;K_*Kd{M3iBEqO!A|kBPDToNGbn3v2Y$6#;4yAk|8A}1B z_&o37eAGqj`JZ~|iiogd7ocw5W_Lt{abARoW1IrNow;QwK5q^9msRm#rg%< zG(?2o)I5MnJU1OZq;;`h-#m_$XVpMU(K0D@C|ZtI3{3wDY9%Vy$bQvv6{u2e#xJ8Pv{U`ni4Nlz1t|98qCsd- z#);AK%0+|GV0!3kx&htbx)I&z8iIznhN7XaVQ83ZI2!I6fkwDSqLJ*Ted8D1#1(!M zvhP!SpmNE+aj!tJWZ%k0H=~O0QXkV#&T^kH*p8xOAIYC|k1m<)D1e6`*3tW}8CgQu1v)iX~gYXgtS_N7h3D z>Mz+Z5sFZ;l(d58NcJm?QdBP4FCQvUr8HRAQ5A}%WYl*s&O6dIb5Ooyy%(TD$$s6? z1NE0|1&dIz=Tc~~WWO&cL*-tx0>zU3Dk8d*{_aF}w&tLG$yTWV6?-m)%B5ruDE69B zF)Ai!V~J8cyQR{<=c|UQr!tO{R+L zm4n))()Y9WhzP%nJp&Qp_k(Amv%O|MBEqjv3lI^0uXqk3j)~T(_j6IPMwa}p63s~Q znq%L}&P1_t_DkRBE}j7IO8-hW6XoAU?-^%uH!VQ1=c2pO-K@)H_TBCTG{H3yO{Dj4 z)q8Xgt=yAV0g9!)mb;gC<@a(|e5CN+g@Z zbTq?{n~COlt`x2J<2InZUUMIcm9yDKlQ?@OAzP&^RC`i-wslZl<&rf+*^x{a1t}80^<9eVmo*RqC`Elb>vDZwYNnUd@ znjzU7W}?S^WKW=SuUUasc`K{Y8b59=S})n$H=s(dS%tPrwi4UWUaz?i#a=U-j3)E! zmZx(mx{vj|Fa7)L9F%`w`r0f&=ibMYhy5136B=97{cosxcP&O=1wXrgMi zLT$X2wulJtxlThJz2;d{GIELJ9Z?>od?I-gFQ8ONB+s-xDD@{ANAu~Sh*CL`jI)B$ zdZIz}KXMx=ZS>qGN{VC-pHiyyTot9AMAG{%O0nmnvk?*A733o#%&!2QH-$Uc_SUTR z`BO4ljY*HCH6=yu=sU*JhEl%g3Mds5;k^u{*mKcXM1&9lSrOhvna*4>o|KC zISvuwd3QWDiDV_RD2*VJ|NmhmrJFo=GbKgR!zfCz=b}3h!QWBQ{@sa)rcw4msF*z> zk{-l*4xGYNLMABac zrAp6LQHnhmJ%NbO<}CE2xA_#B#rn?@yy=bne=xF>?_Sa`Vvh>E5Fk&|+_; z3@!2FmZIgJd!4l=j)@l2LyA&Ngf)8ut?(XJA|j0XO++NeMQ@>U&sCteHKTVm>UR(k z-eImnMCf5PA{s?MtLD3`Cy{&>ltn53<8;jeN`*wyN)Jl?iDRPb2Xlo}T1+HumQgAv zl1~;YD6Ka&q~-=nF%f$Ig#L)6%`8gUM3T#)luIOI$)l7{Bv)err9#W8_a2n`oAfzY z5v6h>X{CbFde3d3RB8H?npKoyB8+7tZ4$|-dr*pru!5WDfoK+;uGvx=K^zlZg+@}U z^jsCC*mKdR%#TQV$fA@@B(u$-luIQ2Cpj1dCJ@lXyf0k|~+Dx0oG0}t6 z%%YS{gf*j-YdL)ult(Gwq;)BvR7iyVqtu^Bjw_-xf(SFBRBW=(6)D9;a$K~9yD*U) zw~JCtgwGB?XDmb*3#D8l`CKZGQlaO1P#Qrbt2>fXF_HA1qO{ngy)C0u_(i(S9+XB9 zNt+`n&GB3*rEbV_rGWDcd278A+b%P3WP zu8LAjBv(N66}v(ttz=Ou{5suA4@&)sq`x9cw-d>6<0vgAlK#plm0M0{Rt2T?p4&jF zxH8>Jic(A@{Y6#iTiqw@75zIzG>#U~O7uPY$#Yv7Cy~5PXHm*0lHLm_6%vi2wH()j zQhy?;Swv|Bk-Ykjq%@jHj=P1DB6&r>mC{bn?V=PDNt@9&dMA>ni!4gnCS8p=lyZsi zbU~>Vk@V1-k|Oy8vJIvFo-3j>f=GHFNvYU#DM}?oGKcAu7JIIYQaO>dSwU&NNuTd- zpj1gD$5l}p@pF1CBPo>-p-oD2JXcC-z2)@j@&-yfiR4qiU6f)Xe3thMBEsj%+Yyn> zA=-h6@HyYFhzNW88zRDIjyqXTB3Y#@O4&p*qZ~@>J-2~UrRDUQe-)*jM6xTpD8)q5 zO0)|R;dA}pnLClx97(B?NcOLaQtY|t4@87kc2CK;W>5MYyp~c-gxUUyi12CXUx;WN z{iL&XFWTo%a(^QtwD}ME&sQQwM94)``EFur`j;IUD06E1mk(K}_SE#RDe9oQUb7y` zp30rweqob?8cWryF_z;|v#C5o+pj^Jqg>_emz#Mg-;XOm=XihTqE3EX=cyUR=cRKg zN-+`IJRfyQ=c2Bt&_~t-5uv|J5D|Xkaw+OLmGecvCyIJa&B!E@UkzkYiZAz?z0njO z=TtPqb2HH_Z)G-`zEttwvp8bAeAzJKhmZ2q6+1pdKW=okZ5q?iZDJH`2 z3tlBhgdC-Kndg?H73o~G5|w-J6=;>`R-^Tv+km$FSazVDKF(cex7XZ*h%kpg5fRq& zFSOTd?n8fj?jJ;ib7DUt!tWpspxAqlrt!_)v~&*#p@XNT_pcf{WLo;TLs9i<>E3Ig zBURJR!J|-ZZ>0_*!Z?pcL|E%%P~BU*0FP$RFIJuRb{NY23&rAi|C zwNDkLmY#7M{?0%|nBSSG zz>hlzb@Jmnqb}YM_bdm4-#i-D0_CQ3q zYh1!g5Xl@;lwu;}E=5F`Ur$7YTrWg~`CW#HF!#$*Zy#qLbcMHaB_cx2zK95|^g~4G z;VMQ(gppB-iLmllBO=VbKO(|7ix3f3;ud3xfH$X zHD5!^d}Pbf>pr755D|J`fmWvH7rn_iiKM?2rI-ji{T3?s<0{bGK9+Y7adOl}uN|w< zYR|ok)_5yx(YkaG(Ff>5ulW&L?=#wfHhRrX=u@A=XNU-Ewi$itYxWf)!dd<`A{s0#h!}}pxFD19^elBKzb~B zsMQ0ki@gT4Mr|bf9cNo~+5_pc{B+drf%LkxM?@Is8Hfn8JrfaO)Ey8JavjlGYQB6`zxaq6Jea)QK8Sh2kPnl z^+K2VN?eY5`%3gdSNL&PqQ2gHKSYGtUWJIz-_@wU=Zetv-rqnp!h0BrMtKjT(HQSx zEV^CC*%>(w5n;{7qhfC}g(gV0W)l$+Ms^P(!pQDLMA(%{hzM;?M)!Hm`wy`3hR%=ipNGsvq|nBEprk z3=!cKb~$?8dw&D1@HST>BCN!ltS*tPQi@Vcgxp)G`~kj=u>b$B0_yDc_WeL4}TycoRPZ`5zeJOXs`Fa5AFBd0Tg>HQ3+4tCFwJ(MhXA-Q%N|lQSqw`95y`834wewMzlJvd2E4si(b|LEKBkPU|z0Drz z67TO))Z24?P+uQeKh~27*E^;7YG1+rhzRRaga&z=gV7Mr4MoFz4#Uwn?{7RR_FM{0 z@>V9JDPD6bD)HG)M-Tc~9zqX$e~+L?OVY1gkD({Mm8Z~LU!{3yvG-SomiSnfqGjI0 za#XIGwwo1bmG`$Ay%GklsM6<9g}(Pzen3BYD_ao}TG@uSd(9mv z_L|Xj&X?(YLu|joC_u%M{az!5Vk!CM(Sztgl>8dzA@q>zVf3);5zgI5ko`s?59LeA zuQyN^&viwGlKn!X2kPy)KB&KBzj!G^V>~w&6-)LTkQ6GBY!1^=rRSM?qF4B09bpkm2>5t2gX zlKs}G0>x4?vKeSb`Z)ginK*kUvNm&2zLfkf2o*}!Lk~1avNi{!8zkGY8_^KSdLN32 zu%5#Z5q5ewDwgcmOeqvg$xc6x9!JTqTApAmPo!0ViY4nkg<{V|v(T(`&1g27ovs-@ ziJnBs+@E5$Pa!*Ba!|QsD_?+dyWiYxyvK71$jgV~LN1{=Z?cZp0i)1@}D;gtNe`C>Yl8y6rR4myFrqErIwRtz1 zAXzID(LH|Ly=c97uQ?S>lWgt}pc2XUXgYdOvXMQ69+vDpdjvfy z**G6VGdwpFJuca(pFp!DTfy1rNw4`7nj_haO3~Aj_4f>#>$!PozGSm~7QO8Adj%~~ z&USMtdR4NKy@r-aHkRe+b;)+@4YWeCYkDPm(~o-#l}ol`73girR_PtIO0xb|qj$aL z|IixAuBx@@JwGGgN9&Zc*?xf5OV;}a^s!{C^a{|H??e*jKq5YDL`T+V*vf0LnXdG?UH61;Jo=IQR(Of=tpG$6^atEP`vPj zohI3APe(6E*25z7qBLJ^zJwM_|C7ql%aXPE3R)uBSeBw!CF|iev`pHnJ@*D$ z;m55+<&w2of!>yEM(?0cCEKIV&}Khw3;JBDaX3AEfxedPwc{J~t#p!d-=QD#ds5W<@-zDq)542mdvtSSUQ?lLs3+*i6Ywq$prI*16*1xF(yydE8ch;XIWMMSt#>!D*M>-{)Xe_r~vqXBBDoZXii zp=`HZ0;#^ zmt=Fl8@(Xej25BAlFgwEE%BO5(K4UIa?%c73gircI+LrO0p-;)#yXX z*6bs+Ub6fA2DDMKRyLu{e%u!HrDSb>g}#<-b-zKClC4=4`c|?ze22c5Y<@qWt&+8} z4gD$^SazYk zlJ&O_#gff0dX_uiv&deHtD)-BUiDrBWqK|P)t2lvrw*$7Z2Gmo9?DkEUQ=^Wu4J!V zd8mVAcm0kiU$WPn0@OvaSGBIF&~rTy!C%O##aJ#uMC0fT=~C2Nve$qdGDT>F zWaAu(MoBi7(P)exHx?C3_F9!flO%g3nT$##dnK8U9+qq@kDx~-d#!p5&5&#?Gtn&R zN{w?inj_gOWhr`E(i2Pc3?jlx%tb_aUpx=Z_nOb5WnObRDwoEohYGY_vbk?S8zo!M zO=z=ZuXtM!5k~eo`r7;Z232~l3T>5akG7$mQu4}-c1y`?G1@EHD(yr2C0nHfD3+}E zXaQ%|0%X@w4OCOIbFdc5lnIPkk?i_wi%yfQ_tQ~3$y#ZTI{0xN5fOSn3lZV^I~(Oo zcKsEgE}rX(3ME^Y9;mn1?1TDCHrpaJNV1U)Mk6GKlAXyfp~aH*UWS%PHnOFN2v_Q>=rymo3=yH`a`d`n&wOtnBJ{8Vt(5GE^G!sA zIlQ$XqnJo?DN1qqg7no`fz~N!uQ?x}4U)ZzevF9lB=8B^DB0`nCbU_yXT>e(OUa%f zzCvG1_S*Oj`hG$Bx$XzFRXKa@--dpc?DhQ@wB2(%&`!x-eRrYXC2R8!wA*X$L4SJg zFSJ+sM%U3k^tbo^58AJsy@nq^v1Fr;7V;i%Vfwg((7}@RUJV^0*;B%ysJdj&U^UQT z3)Aa)II5|fJxA6;nUbwi7CKV0*YKlIZOMACgX&5)zj~;?WKWI_5D|`Rh={OejZn59 zmxCI6?s(KhvavKpM0jR60TJQ3?nKl~vR!G8S}aWOSW9$@a(0HCigG0zbslQtHQS=o zl(XGD9d(dw%{rod$oO0`muwEtq6Lzj z1q;y&lJ&3%y(rmOUP6mKSB74eY+YVKOC;-kDOx7k^|u_oE?IwXpcQ`HN>nb{DpjCW zl8tjUS|{1aK0qHzc1C`L)=Spr2DDMKv1~$LdhRP!DOnFyXs=}VmwjlzWTQTSV#)5% z(R1jz^e4#C^W1HoPv@#3B0PBb1y;zJa-)$B-#81BO<&`-++cl_KDI^G(xieMxs%E z+-Nk$j~k1|N!I&#RP1Y(LKBp;{hNsHk?bAAy=anTpR`RzC6evmbo8)fb9e+jDp?PY zp_!8H`{RfRGkO9MAvX&VVU=d1C%xuVXs%>WGxN{_$;Pq}5uugm5D`Z9JR-s#y?}^t z+#>X%WaE4ZEtYKm%Fq(Y=C>3rlWcy=(F#9qC3;h`v*#^TF4>M%pjDE+msyS8mFzk4 ze`t-@T#MF8HkJ?2hm!5)M`*odJG}vIl;+ zL>T9{hzKkA9U{VU-yLA%lbVT`*jjRBjBiWtkT-3#n>x#NbHkR(_A|Lg|s8BgOKYO5_lJ(vT zT_#z7m!sa2jk6ExE7=P6L;d}@A~Zm<6}%1&l5D33qal)gb}_Y<}a>9g?+jCn}bbPb<&_$>u&0-6PrOFZZHJ zlCAY*R3cfM)6v6{ectm3njzUL%|wq&cE@}I&GO@BqbDV6^C>h(vUMp%b0r(+JhVWv z&ruel=OkO*=g|w2?d>A;qGW6R5?UC0nI^hzP6nHzLBd`48GJS(^vYzml!Q ze<+r0$D&2tQ5GS4TC0W*k?fi8P*h#AXYU%QreyCeYN532O@=t$*~?>kU!$({%6 zprbu^45};HGhaQFE!i_|4r=CYHb*U#vnS4$C|9y);XKqzvQf82Z6teUZ;Lue)@Da^ zmSm$o8|6zjzXH_BYj#FmBzuDIiZ1ZuE=1kDW_MI5St~uzC0_GV)KjwW1A3v$Ja;+j zE!oKWph1$&XfPTg*=&cRVSe0jG(xi3jzl*}*2B$clw@NWjmAi}V`I@cKW;p_L$Z~) z6BTK1Df@J$Q5#1x%yPkW|B+pGoQzYBXsc4$#9zZ3M&2~C^Sh7(+f@XNl znP`?|pFzw ztkf~mF%qh z4t+1#sDD6PC41kv4Q=v(;USUX^UNub~x^ zt?o*+O0wNtjn?>aYtcH-eSp?WcAjlO8zo!$O=z?Cz6E`*oUP{<=u64Y-LKHslJ))# zs+4S%s?hh6wfO_uD%mP+LqGd*zaS#~T5>xg!Y6w>(66d#`|umu={0vDBJ}V(B0>*; zpxu(qZx8xYvK9Ob?e*jKp}!@&hx~)~OLiR{K>vDw|Do7hiC*HGeuY;4OzC+1DxsrW@lZR$V_C3i=G)J=UY)a7r$=Y0qmPz&<%5qdLS(_DTjb#0;Me8MN zWdqtQ*|#)XP^DzwG*zLklJ&3+?UZa}yU!710d&WEl)s^g-wjLrv@5dq{jO;j6U$RyjpoU(v5h6k^8xf(09MsBltx+4v z-buAZr%CoX*y*U9WY5X%(HW9Gd!LEUl5AbhMnqWad_;ua3(z@|JvE<;h_DZxP-n?L zw>b|HVgJrYM0oGi1rcFgx*{UfyZ{m5oyLWz+v4=K*&SV?oUPKOsHbFe=!Gtm>`))lI?U6x>mCHz5@^uTDcAp z;XUQ`hzRHBKtzPz2cf}|&F=<8gjQ}uLnM2rI1~+&?CKbfh%loOhzKh&65S-(D&34m zNp?n#Mz?s)TM-d@AA^Xn4`b17lFj{gM1=mvAtD?%9uc93JJ6ky?Mg8s!rW76v1Ip< zGPF#xyT)>~LbChBN>nb{9jpSa@#EH_^^)DQHlU53+k`etc7NG|DkZxURiW=C>){8q zRkB}%Y(v{6yVvbNJ0<%C$}Y59vNrdiy}qILV%E#-n1- zrO;iHJL0O-A=g_5^W1nj%>bQ_(cZo{1hn4@>q;^az?! zmVOGFi5~ZwPoPQ z5n3$SvuPQ6S+f3KK}#f?-%|9dWY2@Ip=FY-#B%hyWc|H?R!FvFGlLnJ$|4@K2qN!P4_4pYv~^21S0 z$iTi@P<_eH)&?kBvU@=eYT`AUq7$U#ibKsLyTY5J zlO#LKPev^yyH;AFQzYx*R78Z=xLibpD>VmgdgtIU$L zcA;3ZC+cV^uNF&@oi7KWgC*O)YUmKjKCwO&RhMkXYM`2ujinYk!jH>9nUeLMg@~{& zM2v;6l&hSLIuGSb_6k&h&XJODIM8{Ly&j#9 zx_B#H(FMxcxpX1wCfV839bF{Z`EoHTl&rrVhzM(a2_nKcFGW2iyT*E<>m}Rwfrtoa z)*v)kvis)^hzQ5sh=zD>C>kc&IEN!5^fv+#p_P${2>soJh_DYgqfwI0VKll$va9h{ zM1-?$3?jmPa4fn_ve)9<(KyL=WjrFn6@CXI!nJZIDwgaFNuj$WyQ=O+L>SowM1=L6 zh={Ob_n=9Vt9zd~V&u7uATr004oBKiNV9A~*sv#n*#36_XJ9a3lF4!JFRT@MXVwq)mZ4r(mf)p0x` z!l;{|6C@k;iKv-mPjb!CNs^uAC!-dU_16*+p_Nk*5qdurwesUyqc)QD-WIi!Y-H`x z8IoPoXQB?0-BCKCvm|@wI~(OoHnIZLNwU@LjJilRvaaX?$=3QpR4Ccqtq1BYS(|;( z6<+g7bd_Ygay7a}vg_embiHKP=0G$^vN;S!!z3H^a5O@)>u)5wO|o<8c0`2FiN>K~ zfPv5$!7E!+U%`tL7z+ZtAQ`jmy(V1D^%&nRiW?wxE~M^M)o5j!pMF? zTO~USwxORTdwuu?{VLhWenUGYTZvtW2>bUt+AZ1K_nQooBWs}} zB%6B%%9QMk%tA*>Hus}YZOL}J4mw)0l{f~~mF)VfhmMu3_v27~$#%K{%9iXr%R!AL zoBQ#oiDct!idsmvo-NTSlJ$Nn%9X5#Jk(0E8MQ`47+D)cggLZDr%Be|>8PD#=UIEy zL9+9@BkCmCx^zb8Nw&J@qb`y?k#$AGBUZ%4&m zGllN*ns*~2jB^4a!U|4AM3~<_hzMuMz34vWY%KSqDUz+(R5VSp`@{pN#9Nt;9+hmB z9z$~_duEu2=KFEaqGgh;(sH!Mk6Vl0lWgDLN9!b8!4J@flFj`iv|h4nWdr(HvR(NE zeJa_gKSQ5Oc7}X`zLacaU!kuhyB@wlKT3A@{t0cB>`dN4@viE-tP(#VSsc3|dqz1KwUF#xc}tYLEd5S654BOw-fy-=?Ie3|-yU_4Y(^c?S(1I9 zb2iGC?7e6KI>(PY7j=^C`=idNi)8OpyP|HAjjTJmNJ_rhLWPpG*#q^I>|Js%)LXI@ z?1QfGnpdK}lC{|n^_T2huOc+SXL}vGUO8LOfrtpb4?;wk?O=3+WMjDz4UudGhoV~~ zJ6~=^V~?H6`1zS}1dQ`aH`*wUx8p>!7-l&8Qx#FWGDxEYB!zBnV;POcC}(>+7LAjVGZGa`c3!8@1j%MJ5lxb81t+5^e%w@4 zBH12IM-NMOPCSBUNH*%3XqIHx%4{@8vXv-Bb0wSmJhVWvb7>)ZPO_0bk6!Q@EkcVu zSB92IHov84nPls-9IcRSEGyBQlJ));Dwk~EE6^&*R(>^Fy)!Oe1O(VwhtT7 zr;^R#Gem?|HX|a8WefUJvi`n8m6EM>71}P@nY9D$lx)H%Z2$J5{gUm` z0TfGi2a8_kN_`#Kd(j#wQ?lQ5l>?!tibcSRzIumt}Yz`gK zS(44~Y?LqAyPg7cj%00~i#ka*zs`sVPyXj2B8>Wc)J3w@?TRjt?3wmLbdhB511?6F zNH)JqQBTR9%X^{AJa;+jE!mTQA9RIe^ScuDm28~-&{dMn?`qUvvL1@iHIluPx)u$P z?CiM?T`$?$GY}1utiQqN2Fd1rBN`&vZVpAmB-@qYXoRoJNOY5OHnN-1D9LtuG`dBy z{ks*7k?b5Ci*A$b%(@+oldQk-=nlz7b|)&9Y_=(Mmt^nz??w|O`!stZBEpW{gC;3w zyD}NwC)rtWKbj)hx=cmWBs+T^KqZo0N7K=RlASLPp@${gl}FH{lC8^QXoh6_FcUp4 z*{GjDvm{%I+2~2hR_Q4;$7`0Nr@iJgXujv3MbG(h&!ZQ-<|4FMvi&PVFH5$9ub?H8 z&2}l;=ruQ?PbE7iK0})&Tcs`NbIJDh3-qOA^ZN>YE!kQA4XX5975ZMXR(?P~O14Tr zp{NbO$PyY~@qv zF3CQdxf@N8Y+WXzdnB9Ry=anTSI1;DMY3~uDk8!PPD4a!o$W2ENN_O=> zgdUb`tsg;;`f-n;#gfga3@wvvw#(59$>y*Uy(!tK-$La+vI<0m-rq(<81*~oUFB>> z|3mLdcICW})=4(95738_-St00>m@s{H=vItyAyqaHcGbdo6x6Ha*d&{CA)`wgDNGP zQ5E{$kNW}r=((TJ&wku5=vT?sW}wWs z(miCMBmKCe(9x3hcMPg4+4WEl9V^*N9Ea*lb`~^14W;Dy1syNhJ~Tl@*rTTC1j(+r z6VWMt+^Hy6vU4I2weoScMnq_{4I;ug+oIE?25SZvXz*KCV6f$x=*re<$g59b5qeY$!7ZiDv|8$ znT{TmY&{=Bk4o12V`zqC_xYJl8t&1dP%YsT#Se?vNH6tWGnazS|Zs#EJZ6MYjY)fQ?il0g~}y6 z&ngfRj(ZysVXfamt0dda)o6`mGg^x_N;b|-XtQMF+=4!rY%E`(FD2{!D^w}j%2y#G z1T3)PkEU2;8?E!jKT9Mnv*&mfwk zT*=-S=b?6zwbCBtOV&dHDwOOUdk@rGvNrpm{*t}(E<%GOd#67bjgah9fRSj7WWA3? z#ge`APNBOb`$Xw(G(oaYcqXDrl6@958BHlqf8se6l_+PQ%}hrROV8>%hDXp0Dfv_k z&64c1pxJ1Slzb|N=1cZ@&$DQOWW6s$FG%+3)*`f6vR2B_63ITRT#A-S_KDzfw8F=^ z5|t}wW2r!^B24LYpO9r7h@7$!7Ew zs+4S%s?hh6eWLpV+Uhm8q3u%gsTkVnxm_rhY@AUA&kPkA4Wn59qD?hay@G$2zv0pR zUA7viW<~mMwbw#NRPe9L+utG1K$#WnUxOn!E(;wg+26)J3K7ZQ>5Xb5BK!s6I;gJq zP!H9w$Y>E2sFenYI5rw0HAKY8(L|{c%GPl&NjWIDg1_TufAKvJwefMbMeTIlIQ}YQ z)E;%H;NPvczfIi{90Sq-KgzY_gi4pxA3hZ=<(a!L7UKFM5YxpuEG}?XS&bA|m_?{#hvho%G*$ zC_v|U&2v$2uh|D(>AAk|WE2z0Upz`tiiyx>KSYH7u0llk7yPg0I3gTJDJH_-f9Ow+ zNLooziivPs5hB807P$sp>pcuW#hy!{yV9*hccc5f=KY8W9vklqtzKF zMqPB)MeowXyXg1`rBI19Ou6ak;di+L9+nDoR`tP1= zN~w+K+EVKFX!^MBlx7jhakD8Yk~W{Dq)5i{6s6d6(PL=NyL_VimBvzv7OUnVnW*gD zjNL?|s0G?XsqV|^HtSJ}i6j@j@@~d@BI$1fB}J0^n35vNeL^W-lFmg-(Ta2~T8UPv z_Y2hfYG%7Gooh>}1Ch+HBc*8{r*jWbQY5(&O7YHgF52~O#zdk~G=etop`=L0axbL^ ziR8G4D6J-vn(tClBsKp>X^ZDRr=&=7Ur^fSxt}R1lH4zp>i(W?vmT}TM3QSjNs;u| zkdh+FHPU}4l96RoD%F3nIXq2Ck<@&K(mc=2r=&=7&r(|CxfdxZl3jU;Qkmyorld%Y zdxg>-&;3bBk>viOq)0}#m(o5W8QI^I6iLm0C@GSf`zdAqk*=9VDVs=Y=BVVkrj%L` z$#E?y&GpMR-kol<52e0Da$G-3S9$JgN{ZyT{*+GGlRoZ5N)~p-HA8ux zYeh+swAq?cKhIr7Ns+X9HKoy>yM>Y>$=yonLC-xzNx|RK;Ea5jQmN;jrld%Ydxp|d z&%H`Xk>p;Zq)0}#jM8!<*@xFDDUzCRP*Nl{S5P`YBsKq~q)2lAQHnhm{rPUjcp_=# z4oZp>qcTQ)C#6ZAn@nkn<#f-QN@=U-wo%&Yxm}bLNt?e@Qk)olMw@?7iai(o^=`%{ zA{pnWloZKvpHYfE7wzRN*q6@rq12a1X4H>T-oNQwD@uyw3TRC!j?=j)`XBlqxxD}5 z-V?1sYg}v5+Kk-j0OO3J_t1N;_j&DqKdo~SksGzhqGl&Vu)3~_FM|R z>bcht5sq7ih%oBqD3)xt(K`OB-@1&`qGOJv_cKt3b?F{DA|lj03lX7}vk?)-l8=bc zUjZuiTnbJ0MTZ9rdp?i)me<0=smR;daRA@?oX@5dcL|4Md-{D+7zvKSFz4$%i0 zjXHn8)iH>b$fk5QQ9rsLxCVjxwF<)yo8O`xrDOxP8Q?3k^d#(bll73Te zHCiJbc@$cU-t*l1Xq_MT0s80zp1s?u=6XaVp`FUj zkanTH(i_q~6no9+L*9XX$Z=mQR|7SZtd-^{_rvsYc_?4Ge^s*pjquz^G)A)i#-d4* zt;=LI$9pJ6i@lXHRPM)Bpi1Q$)~1Il6iYUi=p*zIf4%&NT6(9kp7$2(d4F`4YG$Fj z>)F36rF!c#@`)r@Kq+pZ+%3vAT%S=)G>WF96s0)Zb2+G)^pa{eNBQYiqJs4q!eUpvrF$#!}d+AUe{d(d9V=DrWb zo{P4iE$N!k=je0S7w8Mum*`8^SLiF(*XV24H|QHzC8|Wp8B&F+xB~2Nj78s~Z(ZM^ z?_A%b?_EEjA6!48A6-A8pIlqfR@XMP&Gj?-+4T$h#kC!6ckMtsT)(1UUB98#ZtXlx=S5~4s#uj4tLc=HC?q(E!Pp~2v-KmaAl%QR~E{09f^)~9fgi^)kd{l zbx!kHF9O6Y*!A-aWzJb zUB{#2T}@CES5wr~bpkrUbs{>^)eJRrHAl@|C!v#EC!>>HEl>+rOVrYJ3OdDgDmvAb zi*j9gnQ?wzS_P3u)6i+I)6wa!cBq}JJ!5H~ zRskw*pLuLlrRyk#V$Vfqpfl1nqchQ&t`4Y!t0U^@It!iUIvbts%18OG0#uOhJvs-S z^cvf=QgtJlx_Y5ruFKG6uFKKouHLA(s}Jhqx&mF{x)NRK>Wlii`k{WVtI$=htI^f2 z{;0pJ2o<@mLD#shMc29ppaHJy&~>is(en3!Q>t=MbYZMyg8jVJ~Zb7%WZbi4c#-K5-v1qL8Hguco zc67UI92(~ukH)+1KzF$AM0dK1QL!t9Qm(tuU9P*)-L45}f@>n0=(-2pzafn zxhA8@uKUn^uKUsbt|@4WYbu)Rnueyi9zYMcN>GVwI-2f!5IyL62tDL_7(MKI1U=$< z6g}#C3_a$Wfo8a7qM5G8(c`Ws&=am%XqIa>n(cZLJ?VN1J>{B%=D12xsq1Ozaq=W#&Y8-lC_V`DniDS@f*eT!0q17NUi&=g@Pm=h5@77tjl?MQD-hMf9TU zCG?VOFN#DzJP+nPPgXp-Uc@1cV^2^dS3s7O1-@Q?P&lRB&YUO5qgE|rw zmt{7Ket$x*9jK%%^GIGr(R391anZ|}arKuoPl`5gmora{PS?nCP&4HkjwRO|<-VNRBs$_Jl!w~A%vk!3LhVuh%bB@RE45Pa|2*A$ ze3#=N$MLK4xox$!b=*4WoJ~qmY!szpqbSFXP!x;$DTk!q}+-B#y9#QnHQV|s# zLmHxjW4{4W!Fjn6QNi&IMO1Kn!w}Wgd|}7D3Ega0mSQy=Q88wZUFQfy1?Te?LJAN-NPyX%$)} zeTF`hK1ZKRtI=v{4O$ak2eX!YX>HhxMDc6Gbta%h^^#Dpwc-8Q8zrv|@7)wMKwAz( zscU%!G`W#uPea44+`E|($kUc)9a^WeRD_D8VpJ@BfxeKwL|=wun)PVCv;l39zCvGV zzpv5P(nho~Ja)4QZ3^!t^9}ljo_m*PzD3_^%*|-C_S=HCNL$fX={xkDRDw#RQdBB^ zkG_|Fi14cY5T4!YDC>vt%x5F-2d(g}1zI*Cq7r_d?sG&(JvL1(11=&W=Oos-U^^OA==$wcz$Ph@y}RZtbFDyk|~ zL)E0}sJc`G)sSkUno=!POR9}(OLb5ksV=H3)kF29`l!Ct05y;rqJ~lgijX2vq!fjs zq(-Qb)EG6EqEWQe1T~RjP>d9dVx>3~CpAS)rDmv^)EqUJTA&tEOVm68h&oE0P$#J~>MV6ZU8Jt4tJDp3lP*LT zN{J{@N|8 zE4_$blwLwFNpsK~X)c;8y^LO#UO}%&^UyqLKAJDRie8moL$65-&;n^8S}47aUYFiL zZ%BD4Ps&I6(wpc_=`Hk@^fr21dI!BDy^G$J-b3$6i_juzF`bzp5eJyQ78>LNX zlk^SxM*0?gD{V%br7dWSv=wcYzC+(hC8$IyMWxdB=zHl0^nJsG*?SkB$H4IJe64lkbVdtBI^3=;mUYDrG=3BRxuBfZjEy_#p7AgZ}b&Kj` zezjX>qnX{J5>3q*Gz;Zu%Um?4TU1Na)Ar_~dFstadFth(Mcr8Ejdsh$=)-PNt;~3< zk5ECksP^VXt3p&{dn>JqQH8dwM4o0a7orQ<@<+R+No3WDp|X*e7}eYyb-ko0FFq+e zz69hYh3Cv%gf5c0qwdnh=whh{>LK++J*7*~B~mZcOX`h!OP8WcrOVJ|QXkYu>WlhH zm!r$2E6^2EGD?QWykaty7jOpOE?8h1G$631ED!X4) z*2;cSaV8CAlZvROW;7}$dFq)}h-z-0vip68D1SfmIiljsLfc!7*7S?&XjWLQMMc`O z7_GOn@3y@S=!DjC5}nbOXOX9#>5uw{`og4!)zMVgF(VM=AA^ZR@mEK+ zFtKs;5)jqWTx``EQHdtastux=n=Grgh>9_a4)u1TgX$eZ6?VQ7J7y*Fw57QQU8AENfCfkdnSEe*HB(Ufz;N~qlx6j|9Wxsh z4GiysV&rK*b1moP+Hk)FY>CzjB?H$J4znJ`HX4G3@IpmPO}ZW3F5Q9dknTixO6e#)Jl7@z zWk`3SyQI6(-O@-jQo0A-Bi)PcmF`3LNu$sxX*3!w-H+~<9zYLBW6&6BEE+34h#r(4 zLJvuqC{r4T#z_yOhowi*Bhq*@UYdX=NdH6slO9EnN)ypUX%dm8PL-(lh88=~?uwG#yQsW}q3;bLctg zdGx$A6U~%np;^)k=mlvunl0s^94Qy&N-v@prI*l4(i}8Lnv3R2FQb>GSI{fcJTy<5 zkLF9SqF1HY&}-5Hv_M*j7D}(9*QGbm8&V$1lk!o%^d@>!dJDZJy^Y?M-a+q3@1l35 z_t1ONBD6?aj227pqxYo`&)-=#m$AJQJQN7{?_O8d}0=}+{hv>)x44xj_lU+6FCZ}hix5FL~b zp+nL?=pX4YIxJP73aJuRO8=sNr6cHwbQB$xj-g}Hadcez5B(>dKqsV==%jQCosv$Y z)6yAqMmo#;$g|;lw@4I!mUoNQ6hDDx~cw6z+CvaI@BWuu(4;k&h5^rGGGUfX*K<(=jI;1gE)sN!t+?x_-aXZiFX z*Y?c0D6hsj*15*2CaQIgcgFjyYNI+<^;)31sGikDR`pQ>EBD=eLlkk2V|SkrM53s3 z%rM+;*$6d07oMeP)Wq&L)%Idg?7676=6$O;)YSI2ST#es-UWluVB70& zk9G*U?p%2HT#r)E>Fz-{Xe~FQq36QuJ`4@FW4b$J1iD4D--_m$=zg7v2hf;v+;v%Y%dzM|JIYH|51~w}53I(aht+!ojkjZdX?qjU|Lj`I ztR6*F@ZHNqLo z|9(N9dgeSjAKtsh6_{}@t8=~OGTm>DuiqtqKs%5*)umT@Fo{(cXW zZ29{=Lh{rzmFQnP-`DJCj-aC+*Wq`oV_sA(%74ArCPh&GCwq~kRQ1wGIqKz-JoU_R zFUpqx3a>)4)yhqnwJ+Xi}!rFK8TTBIS=ViDb)P*<&PI{`GsDl%-xaX}Z&KMwvm%q5Lg#NqMek zpJ4f`qybbba|0Sk zN~PMHOq53QT+g0QbAtUS|Fx1pN~HWTlSs)_d-F2;rI6Ao|Fho=QWn+RtfrSu%Ax#G za!GlVzhyqDi1NoQCV7;Tm9bPiF$bMQQ(g4a7gVBqH~ z;ny*~mhkhHMorC6tc;(ppej;TR8^{ms!7#Rb*TobA=N}RrCO+#R2$Wn>YzGOT~t@9 zhw4f7QGKZaY9KX44W$SaAw{A{DGEhNjZh=0F={MDqiCrKY9hs;7%3LTN^vMoYKodl z%}_I`IchGoKrN(}sHM~jwUXjdyp(_v8nrgd?C%_y)~L1A2DOpeqP9{y)K0npT_Ckb z?WGQ=gVYgqlschKQfJgz>VmpRT~Sx58|o%qh%S^8QKFQDlBA2!MN)UvUAh=uEcHM= zq@Jj!bP2jd>V`z) zq<*NM)F1Viu0~f&*Pv^p0ce0U5Dk>BMb}D$&>(3r8Y~S#L!|4_b<*|ddMOp9N@*xf zx&hrF-H2|KhN7X;Ff>fM3Ed>!jBb{Oqv6sBG(x%s-6GwJZk29Bw@J67+oe0u9nziX zPAMIwOBpCbx(nSU-Hq;+Mxv3@J?I|kUUaW?AG%K(g+@uE(P-&@biecfdO#Y3#zdO@0vW=lCJN6JOHje_5{GB2VRrI*l4 zI-)sfPNNvp+Wy9vnTzI1FQb>GSI{fcJTy<5kLF9SqF1HY&}-5Hv_M*j7D}(9*QGbm z8&V$1lk!o%^d@>!dJDZJy^Y?M-a+q3@1l2gCf-BuNsG`TX)#(Xy^r3PK0qHxOVAQ& zDOxIhh(44)LLW&5s6h8iAu4PX{3f0G7=0`)L(8P)Xt}fkt&l!JpGcpgPo1*`0v=METHla<8g5MN0-=J@#Z_&5XX0%z_g0^rb z+;4iCt!S(C9r{ixK_yZtDwV!R-%CHBAEa$)o3tHmmwrS)Nt&PyKh zB-5Dh1{=qi3je!7R7I+as!G*RHK{tPF4aIaq?)LvR14LTYNOgx9aKlEi|R`CP(7(W zsxLJ_4Wx#sp%j54q(~GgMWHCE5o#ngMvbLt6fHGDO{5qUBgLXvDGtR+O;J;+8EPgq zN6n=csD;!LwUk<+R#H5Qml9Bd)Ec#x+MqU4Thvx+huTROpbMn-sJ+wyb&xuuj#4Mo zN$QL`OI=VGsVnL#bwk~x3(*6MuVjxXoz$jx=y+tT`#4gR4EOmNjIPyq#MzV(oi&1 z8is~RH=&!Po6*hEa5P*RfksHTpj)I{(XG;L=r-wgbh~s1xs(s(ppnt&!q|3m+i9z~Bz6VXIz5}G7Eh8~k1M~_RB(PU`~nj$@c zo{*kIPfA%ROUg#s(o^Uu>1p(|G!;#irlD!lGw2!VS@f(l9Zi>Jpc&G0=sD?m^t?0^ z&6H-LS<(yW1!*>#E#;sbDHr8RFQONvm(WYn95hFoi{?r%qnD*u&@0kBG*6n3=1Z@l zSEbj`YtjO=Kw5|vO0T2Wr8m$UQXa~a@=?C@CVEqP3%w=1joy~tLGMWKqIaeD(0kG% zv`AWv7EAA=_oWZe2htL>L|TfLN*|&RrH{}@QUNNE3Q?i-G5T0qhL%ao(Q;`8S|NRc zK9N2}pGqsyN@*2ZC4GiIlRihEORLdpX$@KnMR4jdgzL35|UrOuI zdT9gNAbo|tlDNfX%pHceS^M{zD3_ko6%-z3)&)WMO&rs(05V^Dv?T2sq{Vi zUitz3AZAMKY8paarh=r8GS^tW^n9h45CL()I! zAL%eUELETisS;I6|Du1TBj|{96dje0p<~iLnJUTCV$dgPo-~UCoH&5EXS!JrADpFNcRjP)nN!3wxsRpVc)kHO=TBw#( z8`YNTpgK}rR9C8p>PhuceW?LzAT>k{r3e%uMWRS43PnkcP$Q`^YAi*gXsHQmBE_H> zDHg>_aVSn|ikeEzP&27HYA&@vEu@yHrPK1z3Q>{OuA5LX z^3*e}QS0c&X21-4rQ4u3Qd`tkYKPiM7oZEI_Ncwo0dAc2jnYswR2qhcNjIUJq?^&r z(r`3f8i7Vgx1d|3ThXo3ZRj@Xc67UR2f9PL6WuALqjV_)Wk`3SyQI6(-O@-jQo0A- zBi)PcmF`3LNu$sxX*3!w-H+~<9zYLBW6&6BEE+34h#r(4LJvuqC{r4T#z_yOhowi* zBhq*@UYdX=NdH6slO9EnN)ypUX%d4WGhhTC^82Ejsusl#wWYT6AYqqc#7(O#(`s7TwgmwT7o~5=xyG z9cQ}RzciVKs9?VvP`cgH{iU=FL^U;uc9gqN-n4MOd{i(ky0yu%qZFbd+uQRIDn_2R zG|#ZIXXv@V`4@@epJB{V_Nzq#>hMf-Gjp^3O41P}YRe>)Y~{Ynq@YwQ_f;kh4Ym5z zjxr2++R{85?d3ch?w5=5o{es0wjXB8d{po(XKTX=eWw!EJ~N{*LE%L||9?ieTV&BPCN42Ii`>$5DQ9Y{+tNJKnI`1HwPDhcbv9^px zvD2et%oKY>aj5z9=tR@{Z?$Wt9YZe4v-|n46;v=Y zI>Ef;UMr|bTNWcvD>JiL^(^-DuRDsE72Xq(sPQbW`{d_XOEikNTe^E90VQh7B-Be= z_D0FtG6fCL3lq@`%7^db6C`Urg_6`%sC5EV)vqmQL!XqmJeEtgiH71Af@6X{d*sk9QU zlvZ)?uHt$2j(zf_psZEl>meI?tD-xZR_;5Z&(LSwt!M0(=5ta(Inl-$t*6>R0d=Mp|pyZHdG4AS{sg% zjf&QWD=S9cTE@(^W14mBw@$O8^mXC!WuUBe(Ope}T}w8if>+~Hh-z-qZ0~7A1xGs- zQNdGV8lr;p@(iMaqkR@p!B?5-hzh=v%s^Cv8E4n|9HN4+9nY_e&Z7JovPm8lj53oR z)!qzZzibl!YCYoPibLdh2U64)ksK(}c+nY;|YHaSZW0E{7nD1po1y|)2L|nZ<|-?(6pvRw- zqWtS;K0;J*{R$A}Uq4fbs7}URzmE|Wyo#40D!6{j5#?V$vjS1U_4@>UYI_sxm0pRc zI5XX96`~rOJgd(T6=RlJeU7MRW|P%ww8oy7KdjcSi%zEEOx3ph&H<^2>TQ~#V%uA1 z$Gp+@NLf@Tb1%v!c}45OcWcGS)BVC<_q+N^d)0{8J?GH7 zPP9|{8T~B%f_{;9pIk&;jW$^q2HE`dd1P4oZj6A?Y9Vk8~IvmMTz%REa93f6>3v5p+a4ijGRh z&@t&aIxhW({*z9i6Vgd^QaXiBNvF|i=?pp}okeG*bLgCO9-Wsw2jgW3Zw@A05Tcz93ZPM-NcIgguhjb^pQ%XnaQU=P9?m~A-ccZ(dk!YlJ z54uOX7u_q}hwhU`p;6LkG+MeJ-7h_W9+1YMG16ExR(cRUC_RK8k}^@IG!BiE9!3vK zkDy1S@o2m>0ZowphyEu$iXN3FqKVQZG)a04JtjSl9+xJg$Z=g4%Jd`KpqkQR2^rrL{dP{m6y)C_i-jUu#?@I5X_oPK=k+c{smflD2 zOCO*Qq$OyHv=l9sK13f%AEA$=0#qOsqC)9o^s%%IEt8g`<3j6O^aJ`q+J?4C z+tGIENA#og6Z%Qofp$nc(N5`S^t1E}`bFA>c1gR@Zs}L_tMnWCO)5iWQaLJ@en-Dc zf1p34J!p@#7wwhyp?%Vy=uc@s+Akew;uRfe(%l?h&-W|EsO&(K4yOM)RF1p@O%lx) z_LBwkR}(M(uW-Kv(upQs$%#;2$FTQR# zN&-r*8`I82wPTbNlwLR7F9YS(4QJ0sp7t~K*s@+YN(RcR7jBu2^3=;m1@&S&n4Wfq zLR3_b8E&^KMkU&^6qVKE7`|F=SB*UNOnp{eKb#=}CD#wfOhKvj!?mQL^!l8ab@tQx z43ww+@{y-4O#{>*+|M*b4W$SaAw{A{DGEhNjZh=0F={MDqiCrKY9hs;7%7%xj}29h zyx8z)O&n)1E>s~ZiwnljG!IpbyyoGSrUhyt zwL~qYR;ZN}kK&~SlpwW6t)(`ojno#kmD-_p(go-OsXb~hbwC}Yj;N#533ZY>qs~$n z)J5uwx=P(pH|aujp_GUcr6iOjU4$-@x})yW#pq(G2kIg9L_MWT&?QnY)Jy7(dP|q0 zOQp-uWl|s1N9v3EN|&R{r7O@CQZh=GQc#L?CAw0&3SA}jL;a-wsK0bIx>~vhT_X)Z z1EhgypmZ&|RvLr`NrTZ~X$Tr3U5Bodu1D8PsVG%SLut|t=mzOVbfYvB4V8wWVbV?L zCh2B$vostHmqx^Ri6g>iauP}(!E?#|KT#PdPrZCpWc7%9Dxxy0`tIqCJZ))iLAQis znp@GW(rxHA>2`FxbO%q&J3^(P^gFZ`ly^tC>U>mWw{)`?qcUw-jy#QG?nHNn`>Ed(pkpeds=E6dEOsMx&+s(f!f`=mBXA8Y7KGW2Fbt zgVICjAt@7OO5@PD@Of<>=Ih_Xp|X+pu(o`Jr|=`85|H-@UvKZXU!BZ&dgIkY$>YOb z3d$OUfhJA}N123jCWJHOqCEBTk*872|Iq)! zG0mgsQE4KYC{03>q{q->(&OlHX)>BDO+iznC(skpljun)3uQ^!F`Y+ zPgj&^Fn>^c;FldLBJ5%|tV$S!kB@0{7Vqp%PHm z3*pMLQIUGZ$a^7NiGSI{fcJTy<5kLF9SqF1HY&}-5Hv_M*j7D}(9*QGbm8&V$1lk!o% z^d@>!dJDZJy^Y?M-a+q3@1l35_t1ONBD6?aj227pqxYo`&)-=#m$AJQJQN7{?_O8d}0=}+{h zv>)x44xj_lU+6FCZ}hix5FL~bp+nL?=pX4YIxJP73aJuRO8=sNr6cHwbQB$xj-g}H zadcez5B(>dKqsV==%jQCosv$Y)6yAqMmmemO6Sly={!0wdB~GYte0$JJDG_7yeCRQ zsU|kYEVb`n(onkXed*rKpiC2+Xg;u8jzd}6G8^UCEf3l)b5WjE^aUs%QJu`?R&OGz zsrj%Uqr8Qv?&dz*dmB+bO~L?r?;tAC%(uOF5!K0Tw0aK}X}@B$UTfKaHrg%SsyCs{ zTJ;uGqLr1RZC38sx1$|)%-fTheJ9$b`F5i+D|dY5=y#3s2ijv|6HKLD*uL4o6 zO_Eh5qJlI3FFI;xIA~uT$IuC#rIYB4)^Zk|vtwr4qdkwP=4Sg9$U{^oGuw`0s>XU$ zdvk-GuL`1qXF*j|t!jA1s-s#}*>9rVvNoz?&})FGt|rOO*AP*` z3=xQmH~DtVNJKR^Q|xmo3Q@tBjZkB)B^ouU8onN4P^{h3UClVuv?}-DE_>HCL(Mfq z3ly(06A%@g&(?@aG(~nTZBSdgYWFH`hdOBXj;M={uPaK{m?@}VRnC0GHO$^04bXlA z(cr4#Juw8OYKAm4RL3w34YzWy#u4Z?ZFxIN*L)c$)1C?U3K)m7?AdkC&uo;V*>h2z z-OpXId{m%O3Q>_g^LcjF#i&HRQdFTaE0L$3smA?MEqvNkMN}(u*q*^^hzibbbyTAo zXSaSQdNon4YT=ovji}&g>mVw)?sX9rysGLUD%i3TR!Aqgeho_l7cpnlb2TbQO-*{g}D;H&7>hzg!y*B~m<++x=<08!n|46A{N z>S#WkR6C0M%AAIV+M{jQncgroTxW0uy2bA2USqeS z+iWkxK80^b>Dn>_-K{e?5>de`?jCfnX1EWH);jM;RC{xQy+a;ARIsu!hzfdRQKr^1 z4rS?{$VO9jcBi4~TG=UIDd3Hbd+BDUogsH(DpSWU6}Eky+yvk(>8G2gYl zVpL*#?zLHpJbQfZtA(k_Ijb4oXOSqiW;lBqO1E-fMKe%A&G7CiL`5~jb6t$Ssu|uB zUn44bC$JH1wpwS`vIUjcG2Q)HipuQl?)^kLs<3j;vr6RE4By?GTCB5HYzOm?9VHUQ z*J8}s_FN~Rwb%!Jqab-o?A-_O06A!bxK3&wZm6j2FkSi zxqEON%Ch^pyF44^X_S0aP&+(lg{a7`#eIiTjLNiSIjXR7-vd=5&u;17o0>WtL!Iyp zMxyvSZ0Ww^OF)Tr*wWpfNhsNF>8@r9O4XKWD7{X&mJF0>d+u%>hqCH~?~JoiPMz?4 z=Ayhh;r*G93hIQfu|iZ-Cp;6ysHBcwIjGF8%zecxM-_GycMnz~Pd!tYTVvf_tL+J3hvz_=%`(_ zdzXF;ov=qc#=aU)A}V;rokCP|Q)=H)pGIfuhS%XNqJnoi=MWXVM>&r?t9R_|rXFXo zUU*L=pu~FN@g4G?ZSCqdj1ifiml{GWS{;hjO%EF3QuG`KUmn z6rv)HQjFHyQQQ+{1KLt)y~xH$~ilT zsNg&HL+Brkau`+Uh$_+1df~lv44u%bPogt+Klj`{i#+vAeO@c|!+RnU#n%t7W&)yu zySz1`V*ICK8`PnGIA%vg1;@|{QJqX@dkmdXVtwwVp;k#KwLa&1f>jzyuOHsK87R}v z=ROk{hqCN^?mbvG%F&j&XpWu1y{hJ-m#y5L^$J>G*W%tUEkv)|o_o5zf%2@}naD>) z+Oimx*csftTZ+o+hj)27s<3+7uDTL=b`gA$OtlY7GidJgORj8;z_;s@wl{DZ^ zUT;@jineLL?P!PH&pp9*qMz-2?(>3Q5Eb0>yATz;^WTli>=C(7%*s)P9n+opO62Ki z`B&M!h=$>@N1}_Z%IsQtpk58x&z-^Es87RihQ27dA+x*Zd5c{&CY!RIy+;d!ZosNnmDs)!1{7p;b<;E1Xts;lW> zXQ+Xw;Oy2!RPd^*h3Z(jJF+gKf;+h$qJkr;kEmew28aq?RSgjp^db-yylaj`@e$$C zCLk)9uQj5AubXX9+X$XE?latWhzdTbxd2hY(Y8laaBVvvD%h_hqJk%TCqxD3r87#5 z;4bKDKQ~Q6$+qY2x)hYEG1E}GUA4RJ87R;0=icSzqk;(TPxqWCL{tki)Sl~)(K0*A zEA}(F<%kNt+OI$>?OAf)TGj{Pk1>~(if8xzTA za*^zpX^%Y)b%>0;$jrCuh`OlP6(vT-_AuYrUJ~k~E&HNmZJC1l*)7l6E&HQuB4aN! z7hQ-3AS#$|AWF5Pq}yH^8mZnrXq4^c+TLiCsopp=UTc|vCTcB{&}7X%1!ZZ!Y&2DC znTBR+zgZ|py<9ZMj`D?_eJ+}(QRbrsntdT!u2EK?mFlfRo3-B-v`bs=MrYJJi#*M4 zqWIh`icg}B+u3WQdQq`0P0K`7A4S;SWmb`BytbTxo>A{vv|9VEK}G5nqxI@-K$|tn z7F42MDcYm`_M-jj9YFkJfx$XWBh)B1+T3kdW*YO|Juk4|Xf#G!jzyW8VH}#MEhnMLR__0dn}VilziDW?_M3rnv}G=uquyLJ zPwSkI7T8|g0@kt+QLWAKrRa4;1!wRLl&4-kTBIXdjFxCEOHqO5D?}e#xm7PiR50dp zv{JoQXthRJgNkg=t*jWW*M1w&*S6=@xe;wvZwuP0-gk%!<|{!|uwN`T3Onduw~qe$8+I9n_YGP=$Jx=%{8thNxin!+WgIyn@9&K zf4@VdTJ^&%Ym@Tg!(KiqF(K?Fk$O@7e!WSllz$9qq~Vl*d?QE`DSyjJq#X5fN%Pd3 zPs*eGQSwQv)muX)Ex;Amyo-PgdhtPQU2M@CoOWaYgtTMuiggI4$7Z>CuzUy*)0!{Dky)3O43pFj*(8NcaqevPdG|{ z(oo7jOT$Q6>SdEkD1XdSQtTDsesQFD%HJ=6l%-xaX*%WaH-j{f^0%B%%1RDL$tF#u z{9~U+n(pMT1F3=vj)-(Zy^|zQJ(Ggc!%<8|TK9UvW4?+~fN z$sS)NY0Tts_OYZ)Dp(n5qI#1^Ih214xuiMj%_YsF{Qc&W^3=;G)p|1Q)h5MK{?W#f zlAY{2Q%E`L<&p+xh4T#|Wm5ir<46-Jf4)hi$*$+l5~;+=o~2UK4yvbliy3y3PN;X1 zOg87`c{}C~R7qMglUJ5GgO-xKys&5TQIS>Wi`cI?E_T;?yXEz0H>u3&2~oPIkYuB#-KD>UL+$4T$o`G+%Mfs6=B&i6f;u+5Iv|Bc1xP-#w(s z>P;bKsh3Tf>SRZmM#^!rqvVq2IE`VHxugR13Q0vyc7|e73FWV|l(fV3?3g=AIbVmX z&Lz#E{4M8_=BYQIl;>pk%O|}}bvCat!#ku!u4h-an6!lQM_Ec*sopBmY9~9&8d8zd z2D?tuQ71dfG13XjpW&p{H{m)@lFm3CWxune*l)vL9I3gJouLIO-pO9S1X5edAF~~) zm+RRrdy@*B?Cgc4l}>iQRir)Y?IrD}{Np=7s&GBKmP%6m*07gAYD@X|L_1OkCwo;o z+T>(sPa^elvZM4SB|F)*q>%bi{(SvO16g$&NXMG~CJVH-ePzWVg&9jdHRx zj3$k7vRjTNWjfiT9Y-3k-UQM_Cp*I=(h~KSk_sq)Wrd`Tu4m`lMCw%%9(!+6AIjgd zFKK}5xnm#=R&NMtxa+xNAf-FGV<3%kvTGSl8bkSGjwNMM{yyv6*S88=q*7sKSS&&VdPWdy;AT4)2yRsFel~gy==3?#;(rVYUGpr#MIoU0X zNoABjN;%2lat*tiFC1( z-LeO%my_MHHz`@Y6w+WPdru4@rBePGOd~CFJv+)`QtWoSGCN8fY0<9m7#5S_e+_#H zq_$3Ooum%xb+pOJ&X+{GSiK&kUQYIT)|-?}`R65t)Q|F4)}J((@~`a>QYz(dnMN9; z-dNIj^(K%ex}Lj}Nt2!II;W7bD1Ve}QjvPar1egA)f-3~o$M%^NM%lTlycG@${%Gf zX}{~)t9gJ_;bgb0Bpr3KqZ}g@{T3ckF=;*JkFtTZOTFEsGWE(y6|QGjR!NE|3rC40 z#X8v;;z-S%?3OJ^9h~f1I+D6j{(N2Se|J5*>LgMhC%a`|QnGp}q*Nz+Po$BCQ$0;8 zuZIz&bjrVC8KjYvf5q-0jZ$wkDbw}rDC0;|o$P$mNYmAuL7M4gk6{)mN4;FqJSY3Q zoljao`A4*nw8-`B42ww>>Q$2Xeb``Srkv;5@Am!6G>)N=w47>f3egJEO7&KecBr?L zR7UylZOTde)jL3{p!_i_N#4G2O!FtNeyX+ko*Al>5)XyFB$8JV?q@2wn#XMK2wTRH zlAYXpFwy|^29gF-olN5%yrxO%>Sd5dsW+Nbpk5(qxq2%|E7e;?+UR8G+eFGa8?Ggn z6o1aj_7X_x=fhqGDTngcl1rNBdUnhCq!RT?Nprk#zqzD&l)vA6Ql9JC{qjkRoa~uc zOj@np8qyi{&XV};=3o?Kn)2OzQ?5f_&P#1lJ*u4ubUnLe5-C-^G}0*7v$KyTWm2t8%bvUfNWH4sG3|c6 zNy$!j%M{XJ%Aa8fX`*_QNLfyHzid*Ddby;CYT)A80n6$*nZn>0HK>1@9l2*E&-EtMFNWEgxddeSj18F0bXs+PQZz7$b{Cnag$#b$Z zn2Wey`i3*qCk?0kEk}?>IoT~ollEU8_70E^I@#VKQm-q*UT@Mo%3t+-(h~KSl2)s? zhE(Kwc9de$MkjlGn@F1}|L)mBDser#Whtp%ayVaoQiPN3MUqn0OCzPLmq8k@-UQM_ z%3tRs(q#3fkh0XvCe2iD7AZ%)T#~1rNog8ejcRQklH>u2NHobDv z#PqN?i8R^C_NI`sD1Ve}QiS=0S!hWQJ z7wua5qC!#;)zxI6Vv?5^_Dnuklk&eF)h9(zUCk`Ej3hO7y;Ufh6zgQaa>bG2sdnaP zdI_Xn>h&fitCvFRr(S5O`3Np0T@*V2wOobtyUK}x5B zV<3&9S{r)?N0Y`l*)uqnlu7yXjU!D|ZxX41^2aPBEvNh~SCCe!w~ADxUNLFCdK*X^ zDSs`SNF|hi21`jhD1XeIqzd&aNk=Jvzhk5m>YXHcPWFh*Tbv2XAF~~40p*Xmkd&uh zKB<5Tj)=5Uy;YRq>Q$1CI@$S-krsRujE#T4?YqWn*W>XYJ~+V$eK zLh45)nt`Z4DOJ5RQo57&lWT+fbDPCDUaM>$FI)H5IR)S&#MjU+Xu{28K2v94!Fi6h0U zmq7AX+Wl^ zlz$8xNE_ALMA}UGYuQ5Dq25l?F3KNsH>r&B=PM`er~DZXkdCT%jMSkh>~$n1QvQBP zq+aUvCM7%B&ze(6{haLa^(Un|+4Gr3nojwn%pm2sp4~5(lt=l;kWYHs_3VD{kQO=F zEfr6A$jN`jzC_U30HK*r(K5bg?Q{Wc-^syp=EvKL& zt2(_=G5WFvKjn_MT92rpw*gT>?<+*LGnd;fzeXF?+k`f2hAn8TM)|HqYy{>1oHCM> zNcp2AkJieESd;?Dr=+ru~j1Dp==#hzhnm(IU1M<^LSEHYt|!w~Qls>Y08|@Fh-znkT*6(@3teV=?x)p# zP_nj6L8%%u4W(P%Wv^`pqFR|JtnNa0+x-?;jYRjhjO}FBSlx$4sW%!?iDsYe-H)hX z%LmXHZ8;V_*pjD4^rdY15X!V$US>58J!!Xe$DW0#;9pJ4M$>K29nlPwW6y;9sd_GY zQN5Q?o_hJH$ZDjWp%|65|7ig2<| zhDcHe^*WMzIoT&sZ_;G-rjW8I|7n^{nojvEn?agM`SZ;pg>Lu#vRo5O=~O2t)QUbZY!FZp{Uh|aEC2+1lMh`9^6qSsqYwW*kU8N z&|<-btWOlpSUzqnidr@fSBRqe;%?`T zsLVsS%@_Ilw%}Toqz`Sl&|<-8T8&(*abr=`&T+UR6qQ+wOIWM~SFI#v*5KML)`5Fn zN!HnJToy&8bmF=+CUbNMcUVbM9>H~^C}j`s4HWhEO0>B;^n;MA3Q)F6Jac(MXKKWs&Ox?f{C$>>#c;&!_Ce9Ys+M z$8g8Zg}fU8DP4K=t0 zN;0|&af>Xr7+0$#^H+ykVzH&TdK8t}fLm>`HMm9;)!u|#t1&5M9d2jL*S-tat|Ya0 z;Ihb#Ag)VelJ5{MRMOcj;M7M^%3NF_ih2~s6)8!+VqBTU%5fDaYS~QO<|}-@Ex4^p zl5ZQX)naY9?Ml+l9k`tq+l6aal6)PwJr>)G%UY}x*KM<rZ0H(1)wI(zj(ME~zB- zrErxNtHRAeQ6J{w=Ao#R`M3or8i|Ft)hJpMYj7`G>?PbL6qWJ{ZZnEX*@A0D(eT@^OV4`z^6Ju6v@-*MsXv*9Bv4;TaZp++rtiC(*P(@}0u9-r)1K;kKhhAfF2D zz;!2ltOwVRT$#9Gi;dty6ty!bVdT-3!A;b@5Essp-p+@DB>7Mj8^MLh#Zv6CTA7Du zNm++mf+h#wgqGr#TWkfc-eL{7)fQWW+iJ0GxX@xj9h86)F9bSTSys#a_W}w%8V2JBmu_z-3WU@PP{6IpPjj>>#eoVux_2QB>a<+@QsV zaBpi&YJUeeY_Sns*~fjpa$E(9>YIs6YE1H_aPySpiElox7Der>!!5DcQrvPBm9hf2 z(qgM{^=M*n8$D{kr7f1hZ9s|OAz~YGFDiWwdI`4~O%9|jTX3ytQqW1P4OiXf`%r^h zpd=|5;%Y5chg*)K+E?J3E!KkDtfV#IwxXyH+i=||>TM4$MDE%5YwR2pwJeTHD9I?5 z;L1=`N;$4lV^TvEF0CXfGq?@N^&Qu&F-h5i>-~nWy$?4p>uaBnTcD)9#Vxnk3fyWG zwR#OMZLtil8AUB?!EHiO?XTcAE6K=j!L_2Ols4QR6!m>CZXb$D*^kSjC}k(E3q@mn z2senLd_%amm88}0;D#+Wf;)?%GSA^civ^vWXDE8tH42xbByZDlaidZ6#%v5OebA@O z;5MKrwh`B_G08W88?@LEZdhaTHf;oVR!LHx!{z+Mmzj%8i1ZC)39igy<+wRYl5Z}q z+F~`hS`_80!!5Vi3S3%A@?~%vEVdEXY_S$xD~d{K!=-=fTb99XMNw=Uu3ckNW(RJM z#rEP3XiR#05Z9$7DG%WiUA~kOTsMl|PWIqVTI>|=w8o^AGq|^vq?C7X!zgOY2=1)K z&f!9f1&5%+z7N3>=;v4RRDPT_82o~HM^l3_Gxnl7eiBn2cZJocqRGNc>=BwMPJnifWjPtJavbx&}8-Nk(EmZh?~2 zun@NhMP)9=)uL!5>TpX?ROV9LY7~vx8eAiq8b~QkxU`bgm%()_Nqs%Iqv+Z|#`74i zUr9!O0CyZkeK>(TsU-PM;Z7?_nP+f=C@N(L7owYEUYEe{Q9c~GVW_l@Z zIf_Pp1#TsZ>RW}Y*O<&h18y~n@~y!&YD{|6gj##%nVJ%tJDvJKD`88a+s?d#f1{c=)SWs6OJ0C^&`lE1Ry~Ol$?FOh(Xn~Ai zQ(a=$x}!juF+ymxV08rhkMmxui^Hg z=!s}QuHRwo(6H72c|fGb2% z?QvYS#-tB5xJ63ZKU}TF>TpX?ROV7#y~ZSE1Flg?YHz}wf55kF6fO@%`SNjvDC%Dv zSA?QjDaIvGl(GbO_?JF*1lNzE*Z}UN#ZKWuC8<4lqmaKM_4&$i6-v^-nYbj1N=e}= zQS_u(g`0z-w;XeE3s97DA+Aei92kuBe*wElysx>AvR)ag+??>VsE<{nvU;rAFGWFSa2nwZkxjyhEOZ>0>IHRlIwR#?e-2c!% zA42Z`&5weR`@ib%g^>F$s0*MRiRu4U&xJ0GbIs9j0K5nqEv2lKZ;=@TU2J7u0wMR! zWqA;C-*k`X`{8g>+@5plYkV2Ku1HbUZ%zH;L_r_E;bGLddo3KOp43Bji6JOXYy5)StT`$g7U5+rLLEJs8C4r#i7!x zs83h&bm(S@=^DHRs<4zZp<5-TuH9J>a^G&8gpeyU1yu`aeKpX6tN2UqhovnGp+yqY zHNP0D71EKfgX*n@1}JT%WT5A-;uO|zk9`5!V6EN=q056k@;#TIhLCH^XP_5_hU7bQ zUV@sh^7GyTwO-}-LmTupN%=`>=hq?RN_iDR?ppO4wB1tffZBz0%sQZ~rR;>dr3U>D zn;xiNQbylU0u4&556kzL4?*EotQY-8lwcAgILT8DR6B`$`W;er&|{PQDt{cRpXBGS z0ZLEu-_??VnkApkLksk|N!0gU>EGv}O_LZ2{r;X;Ammo-7a-(%`$Y)3QZ_@#eKXA# zs8vY6t*;Ggmy~)6cR>532L1M#{ZQ6g-3fI`t930Mg1V(X{jRwl=#5F7pIy?^*w$Dn?T4M2m|&LJq2zMqo%g2_BZPNomKJKh5! zw>Qs&&Y$c@eiW25nO*#Uk}nq;E%~(XW1zgrzVG=^;bdBUb&`~EXp*GV9WWW1Dx^E$ zdMIHvlt87EY1s@}*VCagi!}NWT?wKa`c)^}Dz`p#wJZ2ca%0Mfda} z=&+S}1nRchd!T+PMZdpw06H$D-&=SB`lIC2Z_@k|gxpA+gph0JDQHm2)O)ER2)UGh zgU;Igor6LdYyCE+;A&RL)xL%}l)T!Pl7ea_rsqo?RDU&n-zsaO0a`6FU3F`q#;aMM zOJp9JAmr}Po`Ke0?MHAOl$IKF-)5j@NvYr4)B?3$?OWXjwMz~9O=2BT_G((KCv7Lx zeKm6%eX}goFSVbRmJL9IR>Ke!S}eGRb#@J-tKT141JzzbzKi5LBI}@K7JC>%?hIKD zA-8i@Kr4lGR#rie3F$~Y4%J`dTh;(QAu-)!PeRQ?(WwZvO6}U}Hs~8x-!~!T#$`K% z+*;ZJwO`}MvjghB#*b$Y)GsmpUiSfL&|*VScnv#1f2$!V;+!b*W1SBb7SR^{hUPdl zsmRwb8M>j!w>kkK*P{|BS>$U^L61pHXY6sPUP#BK0ZJFK$Ml=8GElR`bY-J+mE5F#X6yGtGx#rEb?{bZ5KForUYjqMr zZXQz5ZN+|cZ-?$E_GjyzP^IOog60(a9XA)MmVEj>f;CXB^g-_c>!2mlKRs=hLdfmL zWe{@TKmRbayqF!Q-{rglT3PJR$W>5%vELmHP@|1m6O=CYr*H;pF6R5QZXGKpG}J1k z=pAkw)NZA8K(9;R^$gh!?UVlLc>9d3mveO2ce%@$}R}GSvdq9 zwvAPM7-hq%S{{y6JL&>xyrk55p8yqJ>+6d{iEI75 zmq4|WPp`0bQ2n*Ooefah%FIB`Li$aiEl{hJso%lZ2EA%2UxSdFqi;Yvt)07|c8Tf9 z*8zRc`uBY(YyInlx}{8guh9d&A+6SX+BczoA-&=aK!Z|>p2SHZ-F*l=U3}15OOQ)3lMT^;)~Gc>--3Afwo@9e$cl#+n}#l znO}ukrFOl?Y=g4b`O)o!x+SJ>qI#fy%QpZG3h8VPL7|Y2L@{1|iq#Z$M~r@M*cee-qj+r1#f5 zpl?Yj`ljaF5OT4d5SkWzNAm50z9Tj0srX%}eTwf<2b8sZolyT2MoF*J1JJPbXaqtx z1pQLxyAX0~_g@gYI=DDR>@0-bIy(o2Q~b^ert*Gps=o`m077mbUvoLJ!UO?9IBt{Pp}3kJ=InZ z)M{;MgI<-9(0hj0pzYR{9T0Ngb^I*|xvR{#A>>-U6GE;>yCCG&{C6PaR{3`!6(Z`Q?B=`U@COI<+}kwZtW%@ zMhLl5nxJPSrOx}a5OVJl)ty}0Bw+%o)a6P7iHe{s{InwY^Ah7n=Iuk&}OM!e>H6j^d(FAWe8msJ!@}; zkZajCs8veQZ$WQ^b_(hFxeG#WSA7RUZe4#DYPS{H0ew&E)BW&$2)WsO9YSu7c0)f9 z(mVejLdf-Y4}@IGz0f|ZZ$Ff^l${W|Hu`4n|AGz(X_*J1E@{g=8P7w|Vd;b3Ssj78 zt;`_)u z6hdyzzYlu<4SqLX2E~N*9;E;pZ}m-pkeih&AmnPe5-JqZ)f$HqH?XJm4M7QXqve|h zA-C#EA#_=^BBw)1D>DUE3+emM8mLxMJ}t9Z2h~eV?;IPTwDeuaIs-M^c(y>u_4aen z=Ov%c*d_?M@q7jP!VP}jzX&1Mzs(SGJ75ch+)4B$2)UGBhLB6S6+&+0w?SXA*jFLs z+R_RkH@a=mtCCVz>ub<<>AOB%?|^nnOz(wvLEo{l{x0;NR?0pI@juXI%=SZBiAA4V zKnHH{Z^aHmhpjC~pkLUybVJC!x$J?EyZilr5OU}IFQGSV4ZaEWTFO4?sLk{-sNZTB zfKFJe{|F(sAN~ZLv@%aYp`_Fu9wb&_Be++_ z)@l0OghV{^dhsOStyu-^S>UgWfMGbv!SF#+Udpn*bG-utM|~LgEl|r|?7w zT^l5%l&hdgC4L?zL&(*BHFS;TD}s=V6+=@>{K}dNB`j6~C8c)#^}!VM;Sxq(-_(2r zS|Is!oh^hGmH2(T82XsC`T+>J(XEA$Tl00$gF^btybnQ3toEf4a%*=Pw8HxL2!veh zQK;T(Xn@kz>I~Fe!hO#a`IVX$Xp^=273d2V`yzzgxNL@yKa-)YGJ?_5Jk%<)sn_r} z=xZhZ-t_CxcFCu^e+Puz_5E8Aa_9B8A>{hL6GHBnVRk|7(iXjg?|`xv>x8fKFTOXP`kTMc-TwLCBT)H|TAv{T&Du1!<|_ z9}seXH}g+uL|U!q^}7&qWA-oTtkr%F3MHl^AKb{EzR~ZvQ4n&qzZXL8oVWlgywT5X z97^2Cc1IkK1JZrZP1=+{&{mR^dpP?7|Krb_sN}5x6tootb3q-X|;Yf zKL8i-`4gVlZh2FpSretc zEn}dIONr_4_+0|!m2!US?@#ALg{Ac2Qu)Lv4kavK36w15cQHO7ziF0&swJjxE^DCL zQonZVp!!l)fxhK$fYPP0;^19*OPqn4OMQ=8pw?1G_iY)oHYi(4kMxS(33ZpULZZJY zfZizeBlsrNTk7|FAJkv!SI+=6D5dBf(hw9%TlDv`g6aHaz;xg0_dv+icOH~8-LKtT zXtc!iJR1Wccd}m$T_Q1EFL@AhrQ}0}QiERc;?Sh&ey2``il#HV`bm8;l#qNnec;D^J%7YMcPxB8!$lX~jfgYLe&%s9_HbASV`{$fBP^0A2 zC;cX9t+Y%}?{!dmx_=Lyfj&OnuhvgM&s%R_fIcastJjB5L7$eC`pf2@fnJnZxlMZX z67;h4L4VKmvk-FaY=)22A4Wjlo2yMlOWhio6g)?#gu$G)7YD{<|1LmqqUq zE`g96-8=}ny^;?VN`3lCMI0)!b{0cZq-DA$rb0JZDG3O?*{25OeRM}Kz#wc1FuLEC5e zRj>ovX?@rQwa@UUVh5D9l$}tw%$UA=>Ve*nanWn|n^5lzR^6v$o%KOSt<}dMj$X%KHA>{fn0G+V0{v(9k9Q_GGF5gKAx!#_FPRr=(UwNH@250#D`5_3o z_Y;4Eh9#dq5sg5|t;lyF*Xd}FVLko`TE`mjlIe5#&J;W zCV$cvK;v)n_jMDXD{i9IZ^^h^2_d(33nAo2Fb*YdVg&UHR05S+4b!2roBV1mhbnIJ zGd&YZS}X-sOMUt~s5MaSO{_D$GS@-%H!-^U)~EqmE&22wWexO{q|`P4G}I_P`mkI@ zo1k?!`4M~$LT<*^L+J7#l>Vh5hbEP= z&h+!K$BxP_2}rzh_zpEs?h9uT?IEkh=yfgOHn}hoR+V{#0B6Ava@> zK*;ssQ3$yw)0Gf%>tz*$Trpd=T;E%uR+$IA9<@QQTB~1!zG1b0 z6GATEb_lug+yNoizi&avU1h!vA=ihU5OS^F1%20QZ-@FV-vD&n*4YW@q~z1SpMsD( zq5ljaw_g4NA-AVbL&&xI3^Zsp3_-}X(tDu`ES3Wyx9W1CJc;Q{=R;#9rq|7JP+_^> zM{y`wPT%#uJO$O3`}@y22)T3jK?u1%JOrW3gPyxNcb7mb%KcgJ2!z}{(W4M@qq`D9 zu4StrXpLT1c->pMhSKzUve8OAvB3ybOKTYG{U#JF{A#RvD$}Z_}W!NpJOU zv%d~)x0E}eZ%J=;7k?YtX=Uz$zGEZtT?o0I(+(k*uLFAB#&b8c$7knW>jLr0}d{cQIb)NgY;01aBcAt;nl`k0g%+{_*I&3?a+ zf^u&5XGktI`est{lk=%Z4m?)UqlMUqeNwiiRSH~ZCE2O+nQ9)ys)+CKy#*S{qYawqLl2)P}; z3_|Yoei%Zo_T>Cfsk9}k3z`xb|r+|j#~vGw;LaWkbAa&9I6+Je)Abx zZ8Nq8YP5N1g4WtRtb@`vo*AgsQno=^OW6taTgm|_wD}8e;SK98el{{i}fz z=!3WT+58ZM+*vRWLT+Wvhdz9Zf42V!v_SGbEvtMXgxpi)M^2HEx zGyO3Lxi$X)gxr|bLdeDHpn7X{1GLH7@(Q%s+Oh@OYV~b{S|#>%X=fXhwUnJu_bq;I zd!SxxSs!#%`mXQ%k3q<-kl#Vb&HGzWzqC_-!FvD-t@faTJze2z$cNrvK`DB=T?Qd{ zvd19gPUr$?e1$)4CP0N1oKpJkA`T@h7+t+%NkMm1_}y_QR4FlieyD`)aHE6q(qJM?I143@6ehWfwWqljk zX|?ZyzGEZ*T?o0_+acuEU1T5bv~?z{RsSE#Hs~ue{Vx70)GGP(y>J`!>P+9;*PtEJ z7JWnTEeN@@?%U8#t8W*C+}wT#LW$rPa!z~~YPT{wpsch-cTOkNJ=0&Ad!SIt)VEu~ zt-NWw)%Wi`5OU||dC>W{`W-h4%8``%%#{mWcq^sor%4w1CGjX=n)>vtjK*6zO` z)FpE_%3)24=avpU4Eapf*@f!spSKoUfdY_e+x?qxogLR5OT4HpyjjJ@A`iSRzQ!~ zj6Djilrr@kTm>Ol!($L~cRG(l^-_wSU=7eFEAtiT3$y&X{vx#5W^)U))oR!VAvf<| zfsnhq|0>jKt!{(bW&Y+(Wvn}(td-IUbz2QRP`||nAmn=cdkDE5_Xh~Mu|5tVH}5AP zCcxxK_`=bbWcIZ&C#DBfspIpc@T0Z z*!d80J9QM4BQY%{7aE=NvpEJrF5krva``TSklU$w5OV)pH6Kc(7$rTSOQ2HAHytWV z`SC1=Dx?&>YR`m{7E3|3Li%kDbx^&<8lbe~)76uKJ}$l0)8-S<^EMJMK%cac_!P9k zdbAPxbc%V`_rjln+9jqV(E(*seyls8e(S>kG${G>UVjJ*tu4WA?5f**%PxbEd!H49 zkQ>1Q2)XOS<uj?8|fROt%_lM9PiRoYW?S=MPZ}&sUor*t#kW2Yv2)X@`g^>S* z1L~IE>U-!Os9z}hKkiT{eb<#0+|FLP-P0&2=XO%+ddY=G-%fq{ZgdQkC#3DnhYD}^ zD>4oxEMEy!CS~gUl|xCP=n4zfT77lUa*M5i>Tmbgl?LbutM5r@_3eIE)*Fwncfa{?NBdI-6cH$cdxOhCxJ=`MkgyBoa`ns$dD>rx20)iWJJ z?p|>QgxpHL2|{k|mO;qXUJfC54Y(OXuFP8?ueT;+;>wX zA>{US3c5p5>J!nOP^HyZ1rV5|N7=WsQM1S=4+sOtFHk{3+Z(%12x+` zv_PAzhF733NJ>4Iz6fo$dDsFWw>Q58AvX_ShPGPEwn42HYlE^@N+;AUG3{Ru^fycS zcj#>)-Tm)C|BzAAbK;*6a`g>E$fX>C-WAd}bpL|>&sISQA=mfdPCik&lX=%~wj2kA zcd`RskU0wOVjk{dZuJ+=QqUcD`IL7;cirVv-VLp_*g7aJ`J&&Fgnl3~`7PJrhtR&e zsQoKa!+t1hu}Amn;` zK7^ugybeY|@4cH*TTzWUZb|ER{o4NCGp_2Z}*Zb~{jYrWpdQZTGW3AQW zp!fTH!DUcPNb?my<0YSb<6to1?pP^`=3zRn3|$lalzAw}g;)4ka3xeIq-!D$O_DNu zrIg7~k;LTtlY?UD+PgVlZjv#(4w@qUlV7S1ra~zC^~m6QC~>!+%@XKFYxOi}rnUN3 z2)S{Y1tl$(f>ul4_4^LjK#fv^{9zF9klzR&g{x4~-}=TSQS=MmDco9%t;402dW8`SnMEf^m&!E^CDtnaN+qrUoZ+n(W(pH3n8~6FMx6?{T$^&7h3Eh zXtcz1ZpT1*64SZOr)4Ob%?4a}X(cN}fAQgc(AY{p^5YmK5eOa zE|(hQ7j}aWRK}`N^t%`}xLPzdIKvvO!>zU0I$T;~1$QwnxU&{JhYOMWor>`ga^LAY zfiXkTJ{p6oP?G&H6PHBMeox`%Tq!9fHWycoqP zGbB#!C^{#`;KE6gQhxg)m<(Mli!^ z*?Jud3o+f3g;s}n+U3ZKJ;DBAr&3A2f!-vk+j3rqbR1=A~IOHj13 zmg3Sggk&}|xKK&Y!JFtiiuy1H7empvMHk@4TWkU@fujDE;6jT9We{@bOF8*aw2LR; z3Q^SeI4-nUa5F7KQ7j{Li_e$Ag%vVK@;jQrOeiTSwbdyoEu`P&oT=ouL?oYldu%Rl zG~yF6Xbf&Fiq4*KxJDGsW)m)rqLdk2XtChq5OQbMCm`hJ?|BHhQ|blila}&R%nFKH zHVT)6qVLVl#f2MuEZ7Kr+D?YgkP<~@=Hl{D^sT!2xUm|OE5bNjQc2Fp6mAejDTi?3 zi@ucLC8$|QuOuzdX6wTiC~GM@p#v5>2>rzJ{S@l5{vCo2OG@1xN1$#=sk@_xzN2Wy zM&ZIYEZ>_@uaNu>K+p&M#_IbmbW}*c-}@N!I~$j`AmmP>e(3i?x(faPA$KYshmczn zC!k@gZv;9kWy*OSoP$Dt&hwub*?(1x;GLXV!Bl8LmA@lf2t8j#nffWl3sAenqGx-k z-|`JW$0ersrYE33N=!eM{S$Pu%0EAxg8pnZ`~^BKr1yMhp#QDn&RCys{|XIO(dq_i z*$@<34Z& zP~km{r~cJW97V1A4=1coPceFcP{qgSpf`*N<5oO3tM% z`d5}IsCusNLk(0r*YA}&Xvti^LY6|yt&|neBbM(`sD7^h?5qK5wv;W<=Pc#tA>{rC z>waD}>zM zY=g4a>Q3l@)o>8%vX&i!4qID}KtG@Bcfc>8L91a13N2-DFC%}iuRRVW?)5z?fsz(W zLDl#AGHakk_xj#0hH52WtDJmwP+I6|`8L-K)Os&xkN$tWHYjT;JE8x&m({B8_5T|} zZUhfN$nD#M&`&JiPoXY}=^1$l`k#B5v7booKZ6cSDSBcafslKr^K%Hf9sUalx%0Xk zLhdB$f!?^6U8SGAz6t$GO40wz_G_ru#-$JXjl}fr$Zw&eR{Jri-}*NI9k)K5fKFO( zPeG@J)?G&*&Om>)l!Fj*D{=^WTS)&B;T>q$@{K@eZCuVlp^*Mxn&3W8%=`T4=0k<| z`H_!9$@~0xrl5LBsV8&;lorx6D+4`$pFcHTfHvIcPn3<&i}(4Ky#&2{pC6acLaq1t z)zb#`-pB6Hzscx>j!G%|hV>ZqJE>1U6?zNmx3&yGp_HP3O&CZBSP7={wv`D3qA4o}dP*;ncX|{jB8RgIudVNdMma z5R?n$eUP=JzXP5R#Xd+4KR!dO04fyH-%^f4MV7J{N=U3=D=AB$q~%LNl^>*k`m4)T zP_>m(1I@FP^PvS+=0d2}Vs+3?i|vBiE!F|;vDjWH`$6BoPN>UL9)b>A$|F#>#d@G# z8;L&XsKt&!{g!V43N03Vh!OmdAG2I2??bG?t@69-`B0(7;!x3t{8$%5i4XblEP+Zb z<#eb_VmhAXP=&>2LP?3~tfZhyi&a6@RznT6z-m|sHCoCh=(N>v1{$>35HxJ1j6mUs z{CEcQsC}NN6jV8n=fB^~r?*v5?L6P3I;h@KHb7}hnSnM)O6|i&sM%7sK$|VL1==&u zkM3S*pT+h=-IlKh>YW$6J~%EXW*>BP9=mb<8d`P?dTSp4pKFP%@_q;v1%LZ8GysKC zgZ>73FrT;C^I5HnCsWD{D4ZX=BseVn3qA~e*ytnBM~oIg3yc)JmBSB`ubM z?z^A9?|+#dRYMCTroRTi5L$FUeb2v}zAuJq?`I|7BcoIYA>Jf@2YL`flY>X(WOxW# zVr4Fc(B$CxUBs3_X-k=bn(yZdbdA)|0=3@HwdzAcZP51ndA9$!&V!UKiA@a2ET`0MT zeEO^IDX4mpAI}=7b`c}7OiHPPR!YkCLaQKjZLm$~F$hf!ej@ZZgji=n^$>D@Yt#U( z5qftVv8SM?t-eNRy~WZHa&w!3vO+o!ozO99*?^StJE&hsXKVl(wv;1KxQJPq`eEt& zVos^WzRY|mw%DK01!R!~JdQ_vk&`<)PSDer=+EH)dOv)HfsxlpymYM@%nR|nNgOk36fJ-L|wkN)q^ zQTu8LjSK!NZ*m{Z3C=H>@g3rm7D+8g)!E|Zo#~1Tkr;>6Zl);6_t%6TLs3>?w zQa(>gbY;*cDRCjq1pRuR`rMM;*}XHt)NkA4p1Fkw1j?2p!F^k=L6FVJa=oq_%?DaSy_?T3q@ON4Zt&0x2e=+hle2XqbdBXJg08dJ z6bQL|Q=x>VEP=|bhH|Ll0l!yfLP?3~{H35OD|0q&Im*beW}$L5QbvS*-<7((kI-y&jq~%LN z>4$u)Gf?;t{i~C<1WV|{60UexZiMQf^b+5)3=}T$eGis0E=zsO@}a_|zSVIkvDBAR z0wtI7WK$!3PeGMS{diVE)sj#9Py;Pk>T6gC)h_k5*Fnpd`W~%->V>qo4N&7!-=iid zEj4J5GEmlP?}WN6b_nXXwhTan)|MeCw6+Ax=tYS%URFztNU+6mP@>_@i_N#xlfr{9!r06xvMXOYemuOX~SjF<-xbv)1A0}r326aF7YX! zvILhzmj%a&rEs+<9{hpbScglaXjf%$t?2ULQto%#a9MOk(812>#C2P&2iI@00bGcp zGJ_4vV}+<7`1wSB#}=1FV}tvsFNF&?`W^+JCM6mlZ2C-Y5XU7^bZVqY0#F8z|vm%+87Yl2b4+HhHP zZE!Vx=)`qftOwU`u>o9&#s{}kU+^+*L9Q*hw33|H8C)y6BKRY-(uV6t(I^ez22nKf zL%0yR{(Tl|_GJbwj3u}*F9}8Zgb`;G~8!l|MSR2$XqogNv z2lPFkGWb69x}W!8H}nI`_e1)JqWUtpEQ(fgCoZ|i_aTJ~ky{~qp?xyCdb00_klUL- zqJJprdmNWUZbjl+Q9NjnIl_e&3w}&nP?Rr&Yems&ZNqh=sGU8yH&9gHo48&SwX6@< zZ?OSfh}=A6X&H*l=VVDf3-@GJTZ# z9`$9$p`u3_YkhZ73{82IlzLY=6)Jtyw`@98X0dW8Y57u6wZ!y(um)Q2sIPq?RBNTw zLCYnj-pQ?i8XxudSxr#-(b%QjiF38cK&@6v8>!7ry%s`v# zS@ZhDz6HukOh>5`>bH~w5V|BdD(@104-H$HBM@@=-i45xhkrrH?W400a_<1oL7~*H zJ1%J8nX7@n(|Pr0)G!LlX`p|<63T@xme?gnp-Z4ViCupL%7;Ap+%B%Crmr`+n$0*4rOLS&MZ--3|ONn71K4W+}^|q?MV1Dy_^aXtvNlWo6w1 z-TMT0+l8{0?t`i=Rs$`NQi}c`v4zm0Czyx#3_y#aUaPMU>bDvOppzCm1r16}cfb(z zwxxUr8kRoX$S4IPP$>D9NqxbSjM9^C%z|8K?31jQA&HHHVo#D!cTNFx`IEj!AAqiq zlsjequ7nCDHe1$w9GYZhPKJt}By?Jh2g6&TMJf$GFGL#idac%isp9EgvpKQOcm^$yhC#7F@y3slzQtQEyk^(iY3$ znh}4^)Rr5x;Oal<^EKcaQIxL(U|mTFRn{TYB+@JR?^YMRWA1}tHLc;l9VfO^%iTuHKM47CR|!$ zGAqgNI@4@w3YycNpENFmMOaJs5um)y!S9&<4FKwSS2*3toe^OFq5A?ts2$`MwXmE`67g2zEnz{CEa?p)TL*;1G0J zS|-FO%!a2cYp%rk=YKpernPB~)m!I5bH} zuN{-2A}KSJ`?F#wv6|=2E?M&>P*Q5wD_#n!6w%$tTQBvw%P}Azz@OnSGBe)RF2;}+@ zq#@+4G8t%t)UGqO5&HD%SZOfu8&ZA-dQnp9`g{p$_GJbw&}JL6Ezs80oRR;OQQ8Kz zTFN%4*ZS87^;@e4pp!y+LZ5;LC8e&UoC8kdrW1u{V>1mS> z#U!S?qW~H&F+JHQK!p<1-5-aFp7N`p7)m_lN2vrVlaxARzdbt<2!* z*!Byarasw6J8;``o_1G~9k^i>#YS+U#e!T2x!r%^)3F>BovpdJM&#~TaA}RnDV)KD z7x`E)8ba<&9s}iBEFT*Cv>(B7Q0!?pfK83}!|xf80id{t2O)Bbt02AU`B)U#(kv_R_9b7>(| zYpt$>mP<@qy#iV(wd*ck1=S159bnJ^t(KI!i`PJnQi|@lCMYd!(VdflHdqZCp%*2l zcPuYK&C;^Bj=Cwib>A)SXl=yx^`Z?Vo$w6bz> zc__O2=Hp_hJb331xj_Lg?Dr{y0ccQa*O4EBhOI3lP-rQG##rxr8{Itg;rf;2ZhHWC z5=FJ2!VPOo=5GWSqVnKExqG?*LhcSCr!kg;qPfk*B~VmK39d|IQc5{4sU)i)g{wqS z$|_v7#`K8@*QzA>+Hl*IB04*sFVTRNsY-nh*P*h6g}e&;f7I^as(G5 zw<2?)yhdg-x;KSljed>_pz)1rqTL8hk$k$6r$Pxysn?YfsLVM>@lCW^uKl^Vuux*USK`nliRpdaWT>c# z+OL_zy<0IfMPm96Vk%T>Wlo36n*2#y4kep>-&0Vfv_;RCDrk<7?%TOgwUnZ}u?Cta zq}QtX&;o1OLa5g2tAmzE4SGMg6k0Cr)GNsfs9s_^E)CFXA-$%qff^;HzE^C5)=FFS zBw7bOCow&x)azYFg1V)4T@yXfQ6b%%$Dn>`r(UNApyL)h0iBeXuB=l{v367*tYpV^;P#*y z!9r$pFYctpPT>YMCUZN43oRD>ne~EZ1gkmua&a*wNm+m^L(zUH$0bozUkX==W(3=R zof}l)5~qF25?m5Ru@tUaNuE<{a0`^AhK0BdD5_y2u32MprEJ0VTC5KjYE0@2&d_S) zuD7@{=@ay*XJUI$6x)l-qG)$?;zElB?|mkggQ8e2E<~=B3!aG`$nm8d z#C4%4b_f?*EXalOd?`Ubln~N&Rsxki!|HkGI3qY6Dtm^VqxVwfP*PIr8cab8q&|JJ zSqRlyDRs~iA>CC=q2*GF-b1f|>V@=lYk(T1KK+EF2}(;$uW=b@gYQwW5o#9FQ>q2p zY;D;BZMC*+gIcBUdX;H|UbQk`gSJ28pO$t&JFSLYP`i|==TZl>NAhXk_d@%g@pn4= zp{&){33XZD4?*45vL2}S8Nc8Apnhx10Ce1HI05}pNXO+*&`B8=J@rpPgF<>P4MD?J z`v??T?ZLCm^s|08bD_LvDO2wf@}Zc-^!iW$jTh41PJjv}rPdyYik@{VGAM=;l2Yfr z1S)&h_oy7Ic-FUUCX|$vI`1i{`dNQs)Yp;jxU4SH4T)2qd6&~_+WWvF0E9 zd@Z=mC>n_^xG?KuLFcov92Ct%E-nv6_Zs=Qu*=7SLr}N0OxJD?)GINal|JY<&-xwk zTj+O^Qg_^2P`||npp({zQ_!H4qSx>t=xvL=0}abq>yy+76k3_VTF$z){_W2wC}*vo zqg-h8TFTTlI0nj-nBEEGL$S5=PuF|_R46HR4aT7&q3C`IN?5D}Dzkj$P*P&r>J(JD z*7vpws+O3JOAR#7N|_HWkWzG=EnFMRK~Y%>*o`dAgN8b$XEHMr1XK^;^t zF>Q4N)VS8~qb4YAEz3a9TkS7E8?0p;p%-lgUxHp<>vzCsq2{&B!yI{5Yk@Yc_51G? zXtR{5cSu|4L%T1f6W5KRYg`Ym7rCn;E<|oubwF8bODEJNt=3cH5Y#O(oy{Jo*ZS87 z^;_QupyO7`3FxG>Oz)acL8m33K7pNq1|_B5gAGB$mU09-E4AzCb`A>H`uA?ZI@Z}b z-p~Zq*AmqxdARmg_l8dXn)yJxEc}XA3$MvEpUmvdDVgtC)VnGV3 zl$3hvS5X6sPRv}~1|@ku+K6jL(M-4CwrfnDk9Ob&EjEN3)|k8#7{P@~`f1W^+A>FK zkbDEUVI?`CM{vvMN=)iofeY{T^#%9QGUT5BaKkA2%xwf0YD~}hYDNM@rR3u(m88BZ zT(y#DYNfY&+SEbIrJZ^Xu7Fmq^Lu&~^q3!|;Bly4NXMlC zT5V;nff{{%K@+rAYS1g}Iw&nMUC9|}gOHxB8=>ZP{+((Iv`JRolkzt06=<`SxdqxP zr03^0s8wpv^STY%Ze{L(+O2;b(Cb#_ZfK9h^iF3ll$Ac{-(+;IizR>UXDo#adu1f_ zirxnuwRRqZ`lU?WjRVk0A-ztYf=*k@&On3K>LF;@+BpJ+zP=!Mjx*#rw;O|8C{IXh z$cJJUD}V~0V;plfd*|nhoHAD*rK%vzhtmlo{dN;RyxKK&w_5vu!$AVlaZ@uqr zK7`z-&X+>(my|lzmq9U$6|9e)kD?xp!kt9!tiuhW=!!ms8%9ye5nPDe++MyumV=^u z;apr2MX?mFQe*lwk8AvZk2T@aD0)MX!EHcMeH(H8<9*5j+({JGa0)l5BxMfa5)*vN z5?nKi+S!8JjG{8P;Ib%cStqUwMP(ksbz7_l*Q+s^={{V)#RhOEH72#6!j)a&%Phww zQS`Pqg$oOPEQmuzLOKt{P+~o+T>pNl1S(tacVjt}wAoBSl|s5AtDtHjJ*8@(1(HwK z=R#>HX3Q=rJK(b&o^!R(k`~C^hJr)x;b0%XrrXm6YSGavy`G&nHFfX#Pn3$0<}sX^z?3nzGma{ zb!fYg?)M!~yNqY_cX`n3l27kxcSHMZ9`-|7zb1lCsLSd*1Rb`P9f7)~2EEJcSs#n_ zN=)ul3veNFv)Q*k*7{rNgSeHFd zg+`~zr*kw0%1gU4gM26^r1MY!69r>A1Qb<=%3aXTR z`o^;gs+QXIjbIHl&q|pOElB%WSqRlyDRs~%r4${rPeB``6n%c!n2rs<=t~*Fg~*+z zFA@8!#c&~VPuI;5igt0(0&SA|^oijWXtU&tt|U;awX6+#RZ5Y&wcs^qyRRYG0kumh zdj0EwUYD4jt-GN;LV8Wz3uP@|Cv-r1q@Um%gu0{@orgnExAnaT>a{ZaprcmbF{ock z(cSp}c{&#`tH$;ZzZ6B$P|QwgCZ#hoIu0FXI~K)6QN9i{6h(dC3`G(4rYMRTiu!*J zGZaNJLs69WR#7_5bfBorP}HZGp{Nw=e?RYf->2*PUDtE>Jn#9i4tvd7RnUGRJJJWB zYAwA6I;!c8L4lNDXNZaOH-2%R7q)+mdkQou&R_8+CM!*WrYJoLJ*o5*^pw(6YCV7_(O;?%$%}{z4dRFN<=sBfAs8Fd0DpGnLdS2-T z=mn*j&`hOS&@81Fp%;~2f?iUZP5Wl^7aiMVj81`&`y1&~pX+AVxX#uoA>22t3rFWorlomn@l@>vZl-`BjReBG4PpK3tRVt&; z%2+wS+Hrt_GQV=n`_TJJA3z@{Eru2=Eg_dB{DsB2{bV$uV2PISA@rfrN6<$~OQEGo z%b;aSA44B2eL@MJFt+y13a3E9CtCV)Xt~k~Xob?J(5Ff(p_NJrD4~>OpHK4lK=!RX z{h{0>f8k``u9F9iNHRYM%NOQHLSw{d-;y&HLhcPu|AvtB{Rheyn%|Rj1<-v$_RT`~ zL*tU{b@odiNq{OsGp zilAbNytNhOl|ab7>F0F_xi_=D0nL@x+V|eggOGbO)SD1;Z&7;-nlJUV-!EPO1zI!n z8OP$!d|gtY)X#i*5va*$`~{bNuU}IrO~}4yC>?4ck@l@hEuq$*QR_A`d)h$l)z<++ zt__z!mwx8!nE@gH%>}5FMB2A#cZRxYd07y0U;Vip>aMl!0cA@%J1S8qSIf(TVp?7t zD*numQ3>?Arh5aLt9j0Y-W0NL!FvmuFQwb>_$`3m7P4Qrc?Vjk^;`rYm+oDtR7$Y( ztPCpG5-OlTBh4y$cNKZQbS=mCbSQfj{bIkI8HGly@;yHi8nw!|VKg*ml`ml|lrQP* z3Mhc?7oUAk)Hn#a@f!~zH$NYMCP<_m&56(?A^V-JN1#X5_ZWoS+>JrVz0*FviuaqY z;)o#c!HQ!A$d&gvgxp9!0U=l3WC*$Pra)6APy3DVY0%S>r+p9BGthLYwOt)Eph7L7 z2r89a!oG$UYfmhJmWt2r1*-8kx)t`WnTg6FewdAt&Dn@J# zv{u`<4nl4mzJ!qLiS^J1NoT+Qxe+Q~#ovK7mRu^JEz-Mo)@_9XO=ni~JHl$8OKqsm zYI5n&hjewJdO{oIjdk^*2CJ#3eG6p@bm3~>FU_EqLUyDthT03YlWzxgfG$zrr4Vxc zk^v#Nzhpw4R;RWx17*E;hPtnoaTn?VWlLRbzeJ(FnrA;KNAv6t4bc`2h3=3@yNd6G zkQ;{)5OQ@H3FWW$bFcs^TTA>?My-w<+d70H8UT}*I%xJko(@m)ev%DO1cI@F5R_I&gbldU1UAunHnt7;I0!o9}~hH|BJdxXt{Mtn}sUn6e|8wrilwvUF!NILsfoUu^8_-tPnK!qAv z1QiR}x|Ber+Gl0ZQt{cdgk{ijZP5xSA@#KHS4={yHP1EBdLdih256JkdNYLF-dYYJ z_pQYW=&+8;5$NdWetsTkZVIrsP$UE z;@Uv%*K!rP*w9NIAmqmH5(v37t4pCyl8ZgUc80pH^*zxIx?Iy;0U?Wng^=srDx3ShX$5QK+|&-8K3^*J}wkK*)_)UkJHf zxF2+r);b45Zl(5zZWf>2ZEk@Etn+*FKxmNUX}|t57`k2C_b+J3I{z)up%8Md9R?vc zuZKgqTI)P0U;50h{sJhbJs5{3ujBk>og6o(KvT86Y0z|Okv)r@0U`Gd!)GDn&g7nh zkn4#;2)W)Zg63;aEPzVaF$?UOco~#f=a1n@s9f5xQLddTplvdK_MNxep&i;UJ0X-} z&XIZj4TRh}`W8a2Cw4)|&6nL!rH*tJv{%yEk=_UWsF6QG2en0qAmnDjFA#D)|0{%A zn}4J()ev&;R<3~pZM*rBXWhRf7rXB^f{;sdMWI<5u^*834N8`z09_!3f}!VNyM2tsZgo`;aT zBk=-+x|npiMwkg9H@34N_+z*Z0=#O~gp$U?UJxiYmJ*2*ep+_a%=GNr-7!=#c{Crm^4o%+3sMu@D zDbSOe?kQ-hMA|RRPlKM`$V|3J`)45JMsqrZ-1i1&K+g)1xbpqbJy_8S(n zpg?=VY~rrUCf~bnLGw2;Bf~GZLklpxrcg0sgJ2Y}9gw7AobiRSm zMW(&1hi{=uiL^(HDhRn9^Lq%nN za^)R>kXsM`gOEG={tO{ECk{f$&7MP0wYI1R3dCpIZYubWScRu^p@tQH9W{bdEBq{o zK*;6U7(%Yr7eGxa{90)WA=icrA>@v}%^>8C9cd71YHpO?O^322oxT4Vg|4pfExHEE z*Afb#n3fQSCJWiMG6k9{WWR_o4Vo@wkCZbYD%Lzppn2McHzDL|_7;R(>GL7f)Z8QevH)5r>FoLZBB->& zuhcSVo6s(~f4v>rp}w8at_r_$c0+q~Y`=plEBwA&1qGVUY++Qk_<5ENwbFV#0^l6ly1)_`a?Hwp$FfR-<@uO21q*l%fUcs&=!A`84L|o-!KTd-FrBM zT%NfQYHXgAQOScwNC{ud(PAWYm)7iV2)Wfb3PSG4H5x+h-1Hs@x$~EMp)pc|-G#?O z`C79AD5iCZL&efB_D)*~RI0u*Xt9>Q1VV21die@cgxm@^4MMJ$PKS`o zvkrvZ9#R*ox0N2W-(#;2owb$r*>m=@A>>wn0|>difD8l)9C+epfspfcg^=@ggR-Pw>|MXhA>^*ZuYi#M+Aq{yT9hYuiF-hZSts@E z2_biHyBCDq?wO?JNPB)TG*lw(j2s3bSHf^8SL*qi+#%0{V$w?$ayL5;6-&DC4lHtGTT^j+x(oU51qBm*ZOP-xv^~kA$Nt60_94iogsP92#K`gJ`x(W z&9B?h5OR_CK*-IBd!aExcKpUd`P=+zEP#+pcOQgYUG9g*ZKLO3ll^);gxv1-0EFC` zTA@?P{X%KR~ z^fZLd56?KCfsmUM(;?(m)eH!^Iq@un++O}1RH!8sL8aoeyHpvJkUZ@?+AP7reA z*BL^tE?pqxW>!}SxzX$fWr@#z&GB*wx$(OKLhkygJA_;=Js{*(aZd=jSz z$Q|RNQ0{iW-t(Y4B+~97cS6V=8%IFMr5gz$*S@us`r(zS$zmKGb?1bISWE=ex40AkUZ^bOo6&+E?uE+Ur`skM`l6Ywe%hka!13S zP`0LvLc_k|s=T@EOT(euul$|$JSZl)*r%Z45OTRZ4k6bsPe7A3&nXac&l5cfA=gV! zLCDo}Dui4K)1arn^7Hc<2)T6AA>`)E45;uczk?M)i?#G6&{A#tG6=a6K8BF{9q1Ei zxwOdML0$nRqy&2mPePx4#hE}OxeKrgTC3%)gOF>(mk@IGTn}x~bQ_^f;$@ZvNW$?kx~m{gXKZUt>QZ%T*9cPRkn)P11gO1gg}2 zse%Hji#@9}J2=Yh@NGB+sJBoe)Ay?1E5OO2E1X`+Nv$AGh8A5i9 z&V=gi)Y%Hv-^n=GXI##LkZbMPP=lS^x%~TT_Q4dW(@tiHy#vx2>LPjCdw^Xbmir7P&}wb%8t6;O#h#_Fhst;QtCtF>T75N8u+#4h z<{QT78%nT!T^p+NjelOME>!Ouzf$W%4ZiVXn*w$E#@D(tgxqiJT_EJvQCFzjH}sPI zttSh*T+>|vb(cKtUB(_zwxqMi@Fd1wdvv-JLawhzK*;TNBO&C*_AUsy z$h#rrN*D#9=4PH8H%CLrjnO^Oz3LkSA-7|Wh4Q6ECuEEYpmE=@iW}Vojfapc?*Rz8 zU*H~uCTUMR0>!`ad)?y@a_OFcCTrvr=t=GQr=Y3d_Ype(=)e$R$mVV>c`!Z;`)@22h&~!;?wT|B!Xsxzy9faIo z{w0K5dFvtMR?Y@!qegCmkXwJ7p{-K7{cUg?gxspy4k6dRuOQ@R`PUF~^JNEwT+i=> zkSqNg2)TXtTL`)Jz6(O`EPXeGTpRX4l{$V^P@s|KTke>B>myr2Ip6xy`$Ndp`eq2V zG1tq?x&=b6o&zA{YBmrW^sQejgP|c3Y4^IJ5OUX|!yx3^HylE4_T)mywIL50p}vvO zU0UAV&?w2pp23fX?$w%&fsp%6V=R=f=?b71HS#5BuaMm%_d);rma*L<_pg734u0!* zjYCkiVeUe^jjZcVp_kn4#y zPb2%AL!hP_CAj2gM{8yGO>M$9M7l=@+GhCm`f@;mHtk<2MDG zy34PlY0%T+vv&lafu7yPTCso8@f=jBz9ML*^o0FAU=}o6dua~zvXDLcz5*e)QeTB$ z)6$C}fGSOC2(K6?)K4uo9fLI}BB z7D33>>|F@8H5oDo--AlE?PU;hxx5b{cc<+GXtCzG1X`-&w+uq=DejLULLp-Lg!+A1i}dYav=huywk(xL3#J~9gB?DjLc zKQu&qws(g@xx3kkK9Sjz2O-z?J0Rrti94YYyP2Q%dBu@Xp+wpqEP|fb@?L;uN@V!F z1~f~3FG6#6)7o>dqP+iukUOUS7kX8FuR+D?D}j((x35FUox8mOA$J#GE`;3fJr6=| zX1xh5)S4}VN_YDaD}&ybp0H;nA3%#WatX9_w?76fgO=~+%)|a_xdKXPWD;5}k@i@% z23o5vS_iGy7Hxo#tLH{&lk}3kV%Q9o3)wke0aa@*HBg{Fvxi@P_V~y~(0O}&znl-H ziqG!e5eT`xwK0TTYcGJ(_D~o5*Bd2*cs|3>FnQ~WkKB~(*Ca91M0cQ zx4jp1t=8;1sP`WJ;`S$*XMLc);%Hyaj019Spt17Wba6P z0UXAvXtWpkR+bD>2{kl)`tuF6Tm5f9HMIK>6QMYkMcL04n^B66~BPf{MTM zrI$dZ-}zoDgAzja@Bflex#Veg_6n#{BdeflDcwHbS_2`sZhwQ2Ytioza!>FbhLCIf z5$Nc5zJ14_6B231_9PUD&+gZzl4E$Kk4%BisiZvnTiv-3ax14HgxpzIBM7+_ejb!s z>3bpqrB(W4cshh!FExjd`@5x!pcYzsOQ^L(+IqHukek?Y^4r(txyEAlvklQ~m zfii^bZ+w|hC#k2srtA!LtMt8;1zoPWTmg00(tAM2ozwP&P;=8%bNwgC}ii-OHe{mlT3$OZ_x{@e(aS2DBgGprR*wT@;Dgxt>e8-(1r{|+JNI}9Or zx8ewd++W=O0U>u5^e1#w+i(mzA^l>1k2?tkS~F7xRdF=D@I3m(e9ub#-cvP%8k$Y= zg}NFj_@18sZl6T{z})@8`%<8^9~dM1clzm2iyx@9{rXu;sPzxD=+)Y!YXc$oZL+pd z#t;5|0+~>@<{5=@B+r$HNY@|A)yO<3{|D-FMi%>L0d(IFsi~%D7LoTu5B%W&D*8bP zxvyqVfYAA7XiL&fgkqX!9GWUV`-Q=2(9=@SuhWTq2AckZ@0S@+q14QN5w8d;)?7-U zQpsh6{AGR_v{KV0AmsWp39Zt|)lj*VSN|8h6J;h5!=h$oxF$=QlPZG%$}4S>X{Dp+Ur}C4dn{iZ<6OhvAw+E zqMJm(&V$O-_dbN&Jo^Ab?mZHVA>`66ffAZ732hRxeZ3h% z?yGm@5OTRxK$Q|{ztvs^1zMij$IRMCpWPte2#-RseZDU7eW?l5){LZmNvulL#5@aC zVFjp_Ss-nHd>>z?7upQPu@aV!NII-QeP+tO)CQ=Txu8Gahs6qJ`*daw^sFkO#KS4kFzBc>0E4JVJ>O&3p`!|L(f>PBNfvyy?bK)xKYV}t{jP2k8mB^F^VYgWOBF zRZ8d&4Ux9nBjr#i_n@vLXpHpI!xA|b%0I~1&J-$uCP)e0WoMrV#f0pUD-I>3ef9{O zgvzz_3aC=ctAh5c?*LRS?Xz#}sez7*&#v3Qpg<$dA;#|z{jyZ*k^-e2@_TtYlyQi< z*dux-lzoVuZ-YceA>>+nC4^d=-+JS_3cB_X?X%zTy$(Xo*Bi>wbp4^+L-b2kBhuwT zsD;^j9drkTT)I0U)XMxMrH_D62UC9lG!nX7BS%4_B{EBV_dv+yaxa83%q>#F7zlMR zpG&RBLdfO$ZwR$D!zGvhK**)bhaS*8AA}}Ii>6BCMCeh?^D!tUxy%(`9I8I#=R^$@ zXiu15cpmx}+P+aD)1XU!VZ^G1E`?AllQIy>fKV&bRVWie&UYDvE;K{M*AeQZxpan5 zEAx=}xgxgZR9`PBTT72ZLU+DR>Z-o{??`qB7gGx2B3|g#pSpp%~z7HYf+WryrsYb4Z z66#ArpJ}>P&}wbb8fdMiTL*oq>DEIVgzTtngpez56SP^=l|x6=_XqT+`i?@#^~5m< zxt=%NC|G zDXUr2UoB*pstYx!_Pb&V)UcX#_N=lIl&X;tC{29fnSXU^@8;sG>~n_cgB4^{`y*^7 z)JdqPjC5xxyV}2(DhlO_&t5U)K@+O|@qHo`tM+Y(L&epc-Cry3%fD+aIx+4iyR&%UsTtvDm2)VQS?;+%l0Y5;fxtT842zw#q&YJf@sHM40+VCTU z+#dN86bMCVJC{rRMqA^3XG0BYNOz9pnF3{~FB9rk!wzQ8#IvAm^+ll^N%ydn*B{Ci zdQB(~3N+IE2L0yCGruz`zxzDXq3qv%E>S2~eR)vgcb_f^1?n@0Nq5-i5{0S{`^XyT zu=rsNEsApr1>-THxwR=f5$qE!XxDo ztUpkAP5CEQaLS*w&mNCXg=$GUJ0ojDr})kGebx$UBV>>LZ6Va$)U=@Vb`WZA+DSdzL&%-G zb$~AUlQW1aspq9ohVM_430)@Xb`Bt2N2rtd?ivJjhEP*;=53_w0wLG)U7>D&vZn19 z?X#fE{fL<>pza#k146DvJ^xHS6*V+{xqh#Ob-JbXx79Uuu+Bv1m{QhlJuFdJm-<*| zp>Qspjnx2!?MuN*vp(sibSzQ0Qk%>FqqEIk&JHia5{14NSgl2-742(-B??PuizN#8 z!FE`p=H^Bs+hd8s(mP;Xg2F4)OR>76u)H2vqHwS4i6sh4=!GQ;OUT9&h5K0)OBAk$ zE3rhO??orxt1 z??KGM5{2nr#1e%){}R@0)Y444rLLKSRgA*cmSD|CVJ-`>N>SL_GOVR2%w-u?0!7RS za!F!|!kT@CB?@=9Ram0Xw;D?n?t^QvL`}@xLv_vPSZgiGowjvYqR{sxmMBcO9!nJZ zHeiXuT5rVKq`u8q<>+j49`&ri+JVBoXD5~@tmijaqOgQ-u|#3yE-X=a^xcgm3VnO9 zL}BUQVTr;%tHct8y<3H~ABC&q0G8+?)0p}4KP*wWPyCD}3diUm)-NcG{1rc7(kyk^=-3z(~>MiN)9iBc=UrpB!D%Nx*(Cf!I+x|{! z{RTAm7_;>;xu%>4A$QgKCWPG8;9JmqNq5R1A{RglkMVn4OQA&&YGG~{dKW_O3iCav zR3pov_oW1Twfg~tTrP_t)WJL=xh#QDQ?o$mLkPJNK7x?D5?%_SE@q8HE`v}j^M_nB ze+(g)?h|OaL_RLQ6%caQ+MhzGgE=m_tb`KU6G`avV{R`nUqC3FpJpxerTW%G8`ZZ7 z+M>R#&{6drgZ@_EKTx1PbDT54Z=RYJI;Ey_j~F?XB}rw zglBnB10j3nm;#}2KQre*=N|WSz9Dqpao-c?L&&u+6+-TcAp)goWIB{_+^>#I2(>mj zQqRjE>H36R$(_G~Jufd}%{RIm2B5 zB{VV#t6j?g z@1)Q3M(C!Ke%x~)QWaEvlJ(xft`6v!MA|uj976t1H*`YlauNz8oxKZS{-&@0CemIdN1>RI-Pz+% zv5?KP1gg}?Dk#uM^AEqv{NqPuFqHd`Z+jjz>K|Xv(a^mT`AjuEI0pK+`u+puYc2&4 zay7dTLN49?&^V1855+W>I21@a+XfTh3w$ndC=oDsn4v^1lIT0p(k*9W>@e8uQpVtHvM^KN2o58 zs=f%+TzwZoEyTA-(zS#x*2q>+YmICJolxINC=lu)C79D9L4(tLE-BE3Lbfi=pw{YZ z0|n|cr?ZYu_mO>|0YZ;TT?RtA>dS*h2-)&RLZeRi?Hdi{Yq|nxrly+(p?2oKQqLD5 zzB=tQ4#m=O)hpxdFhc=w)_nwVVaAu^H87S#Yy+|;z zo~MVPhwJ&sNzkL}dkl)HFAf#fW8H3)V|Wo%T#xo07Ak=f>PteO)uS#`WFK4wt*yrn zRwlF#+9b3^Xfw1$eOsYz>e~+O(p+{!d(`(GRH^Bzpzk&E2WYSQ_CY7qcM|$reg8m# z`b>THg8H<`9w}3xhV^NKJ&HDh&J&+K9-R-RYGedzQlBxJAxF`sP@4MEp-!5vGt^Ce zSx|TN^?+{F@@|51G_pT*yGH&C8lsUypi_X;#$(=C7k^_d2Y zLjzxz{!q}s*VCjxDYWl#*%{0^&^b!yLgy+qj0F1|dO83dY{*{loz&$J6sXTMf*KL| zyQ~g#9(114`5Z~k_cRj97qX=nKr!{jp{eKl5~e}>HSz#-SbayJKz$}P5_C!RyG>W9 zTdLn}vY_tj>jCvlrC;oB(+kR0UlhtorI+l^&>uo>*SHxPluE6uWTzbr<*F|aiV211 zM9|}@{wVVV^kgdIXOBluK~sh7F=rZ7sF6ibu|}3aZwuKY&^yp#^(}#xYPx064)NKe z%uZ;RkS$?1#6yU#^nK8NA)Ds`h=+unk4HoJff(8)X96|QQH?wXoe;8lav{hGyiaE$ z^k5|7?wmxS+z36G`Y&?HgC;~GjZH>#Xd*Nz5^=xrJp#ql7l#7zg-`l6CeOx@{k1v@ z#Tv8Ecb7clP;p~lUI~ zSD05o*+TY?QWU!O0-x?WsP_fF4Sk>+FYu+`1R>Yj90>XAVklSBxpi1%CSzgsNk_ei* z@{(8qa`z|9g&bioq_6EfONWXt^mC~MDpg+@l(>*|_scUGNhnaCX%-1;H=}gB6P*T~ z)r|dYlAJl74Yg2TOQ@6jIz!#mmj!iKUk@m%zAGW*^1KSVS$(%a1JpMVim5N&EK*w9 z%&mYjtN^*^L&~6~&1li*i@67}3`+P&lY}ap=}0$=R3B^R`lSZz1ZrxESUD%La*sE2 z>pc%^J_`4q1z3Ul%wNqSbxwL;U91=i_lY=G5O^OCyhVc3()TiUP$Z37 z+qv5qN>g7tlq)_vMtM*y&5vyynwsYKrD@P~iL}q_&wz^6R|37B=2n$?16r)PEP)X3 z*O$6{2qCxLKZ1~(OG_aX?k{E;l+ZkrP^H$o3aXZL_KD9LC=jxLb70aril+P4c7?j7 zN8GdZSrBqPad~?A>`Wk0fbzO7DJzEx|I-eW1E1Gi%ddWG?%T=HmOT{so8c2 zxpDsrLN49c5OU+N1KOD$NjFzYx^JLe+S=U^a=)+afsm{9cMxiB?vQkq5OTgM2)Q2o z9zw3Ke}MLC>HDDln(hEpt*xzrkjwKo2)R6ehyGOGQ3$!?^f3szbjP8;v<)ZHBb6u| zl`5=i6!uvSR$$4F`$-77T>gfTYr{VfY8oCb0tmS|VVXyRwC3a)?%q&Fb0Y22=9y4- zbMmyufGE^kBl|!(8ri>jBvI;pNvvHcT(`Tis?}G66{ydYLFGQ3sepE9c{`!K;&_xkp2H1)?7-U`5L(ZTByE7&|>v1ftIRo8MH-xTcK^*XWOB#H1ca`heqy% zc4<9#LwhxH9~5Y$X~F*4g3+ukPiXXjPo#p}sSr z`szE2Jdw*2t2JtD-dIuBw809HyOKJ)Ript5eJNNOC`^}$m5sv4C|05Rim-}Nn63n? zRDESwX(>KkI#vvYdB(AdkxPdaoZ}6C!N z$T(KeTzwZ&7ZkQ39jgTjBU@tC>fwF0v4WnOt`~Vmy)TZn|4Q#WfE8TtedY$}X74k% zKqxH141jVqGLODSVNaxB#gMBtR-is}2ZY=h-AN>JJ%|+`=Nkdt?R{nxRG_~5poi5r ziCj>aE{+vE;!8J=LMSZ3JVs2OjXJTceu!Ja94hkdtW92q)9cf}Nqz!pkG33tYv8JLh-88JkMc$Xhie2n| zajXD^xtLb1BXv-it}fQ%_CDPbtfeTt>#z)KISQ{rR$ygc>eEHBdZVx|eX#nXFtQ)k z0QC*T%0*#$c~~*^#j&QUZyHu13hP;fm672~$i(V~!d$YjvQb!G6f5ZBeWojf!kU?G zts@Okm@Wk?4TbGX$C|CaIatLgEWHFPfx=vpSb_RXR_jPL3Vk(LvCDnBI97nd^2`+w zay{Rj5>V(X!J3ak-vX?qD9m#iRsx0XOJW7Ryw7B}j?{|!Txw(OLSf`?tZMbuU>#N8 zF{~3PEa9Zkl|J1`tWj6_JV#@VLt)LvV+BjS&n#n%KK8j3Va-NiYIl(AB8nr zfK{r#GOVR2?1^Pq(cPWA=w`ZbP2-FIjJa2DD+N+UFn!Ldc!(-U=b-yA8Ts)BOuV zE^-Kjc&=IQ0t|(6HC-MQ6Q6x9C=R{XhGTJ->{+GI`)xQk9V+wzgq&|Nv{HQu2svL8 z+95Pe((QyQ)mH`Wlk)5t%a2gCkUg8Jfljocm+TeoNhoOJuMtdJj&W@{4_PF6rbF3n zSx1|MqR`cCxqp37=o+ZEM)rYnB(l*keEp%}>dS?YOP2?Y5TAX%XC##0mL9a{C-Zl)b|Y3C^l#oBTG*HOxgLyxy3-7P{-K+m)zPkXeO4k7oP&J5^T z^*sk6cR!>MLN1pg=!JIlu05-q3B9Q4UV@Mt&DjugCCq_d)?8kJkjv#&2)PnogNilJ z5(u4ds^z^0uS3Y4slEXr*P^-5Jk9e>s7yOhQ8HwyCCH1xf?<*au0-DFMS7nuaQ4MCp7XTgj`+zhLFqUpLUTL z3hxueu>wnWqyu~?9L+dZfLuD$J~H97_W0z!#zd?b3jgvejy2hm+*zN3HR^O9IU1`N z@eZ5o&oCue3H2qhR--VNHCTcAOr7?TS}63@#;SwbnbFM8x>%#sHySG+g*7X{8i&IB zisP|n*Y&s`OCc$tc56S(ITu;6t<`gE3LivrDMfV z=!;`bN8#TM&cG@}VQY)9dUx>Y`e5}%VH^5k6!qNw04MAb8hhpWTcIE}< zL>^W?ayz~`5i6#?IM!MewrCwz zISSiQfwfJ2+p%_7lDV`K>#+KcU>&uzf-yRV6{yc#LoZ$D>)8ma8w%^0g*Ej~@0*5I zi9%l$R<-(Su#Tc|;5+_EHK~s`bgNi(uudFAr-J3Ue8am5;*m3a}=q zZz5Iz?li3FC_D?Af%P&9YxW9OF$&X_VC_=hZmde{ zlU%B>_M))#eOUXgPg-;U>mUmAJcL!Pz8b96^So~j)>;&f<~pp{o8A}43Q$-V^A@9m z!aTFEvQb!i6l)g>*Vt~XN=x!L#8p^(QJBjc&XloM&?Tmdxs;C8359uf#_EQ`bXi!nmUv%ntkY5GtAo`*eJNPyps+6IVl_lz z`x;@TTA$3_2-W};mNyV(w~E|^3_*>Rj9rqteGe* zVHVbG6t;a1*2|V;M!teoY)R&I3D$fR)^h>YQuQsvT5d_sf>vN9P#BrST5CyG>N>1S z6qa6vRqqcUSs&}_zr61ntllV`J$;sg>{*TRj9rqteGgR%Pg!Q@acGvlY3Sj z{2htfP`wV!&+xCUpavbtQ|`r@6sWaEwt?EJuN~B0eI1~f<`ReIt8W2RslF;GP@lPk zJTLKeX$Q5x#FyRy%DKeXtUr{{$RxB@BiBLY8d(7a8fh-2MVIMMeZHC+i*s=hKPq3M!PpgxlUW%$xfCViI4^{sttR2(YKq&)k6 zjS>jCI|Q#o3&dxCM|c}5&14qXtMW1^p}8cXATx5Yd2J8lXD*}dmyyo?T}TR)dKu~L z@8}UIO?~N5_GOf3e{G3E*NV@UcOBICGGFU{(5;e-{l)J#Xs|}!4xw;{JRy)04-g=Y!1u>$1M7KY;MGtf)&IxG7e4G@@7ED)#X{}IrSAn z$oYz(Vj+8FS^~YHzPS)`_nziKZ>sMtXrcNRLCCf5UFbdal|smsR|YMS{``00zxkGQwX_x@GGH&rb|M|mGBvanwz_&cUM8km9QE@ z?mB!8gxp&pKZlU3^%oFw>DEH!T9*o_@-lyystO9EyztFz9ohLh`gFA+eCJb4iCXI&$o&#nHlKLft#kzLoMun;uZM`l3)weBu5A6>51!&`VODeQIYm^nc>B zca;7M6|1kLVO_S%OuHnwhE0tTL{*eO85)vCx;0iPg=Ll#qp$jlvd1v2xXyhcybdHV+Xw8Y>@#B@|%AP?#=`l~7+2 zEBjq3Pud>E%0*#h9#)_}^B#m;f0mMNsc%sfE60-j#chAAz>@t7`(+&?Q&E_18dfoK zy@XY&zA~%?a=nCAslF<#!1`>SA9sv2_{8U#f|ZTJJfm2-D9j}fE4IS>;#kEiy{`l- zE#Y%X#|luGi%CL3M}ODWbmDpaPW0}IXE~0Y4lJg6o=+Yq+Q|jpf`o=3V*9pB!a*^{P|#K2hIt=u=<1SqUZ7 zmxL-Mojrr7f_~}5sMvktSEyP@-uh)~pu?S5Dm0e%d?b+_p-!D?k$rBvGt@=P z>k4(#JhPzgo#}~vlCB5TQzLsp*;-x{igkAL#l)df@!5B8ltGEk9IJMJ$t+JotHo!> zVGXoaO0d6vtb@wcR{>S3uL`ObvhPK$fex$h2=s@v$gbi)p=08+?^-<${iS)HfRM}O zBowI6bfJA+@Yy3tFDSbUdD`bgqEJp3YHiOk`$M^1nAfM}(l2??9pbYsy0Z&+jk>rU zGmaIElw9omybBtok)xq|q&#~>xR-P&%q1=~ri+`qajamhM*bT@uDt&ciFhkHb%|pI z$ocXiynNY+^>=2pa*<;=0OO#bQ7SMM#iDSE`FqopxIKoynD*bfnJte z?7Qt=ff5>-gerxul0K_~sX}zcZ=K$-6v#!JG&o>NjiIW7>6bc+4VOC zLT*hz2_YBx6oi~_Dui6RY0z|yoB_?$$XU>f8u=15Tga~PInc{OcGq|XDi#W_q@Xu7 z-CNLnO}7AAC}j7gMNp}xD}xqGFWI-YErCANTt0#lnl1?iUG>P-jlS+iI{W;56pD58 zzBu$mH+t6|S0+PGcB4P-mG@InvHD7&L^ppOmxO|De!n(Zl#u0X7R`#J_0A#}c@{1m zs|5<5q-=?GLVYK(0`-|bS&>@(vuL}V0oKOKMPV*^SR+ta&yiU9>MOt+hr-|0#$(0Q z7sr~6!gN!x3J3T+i?EhklB3}Yta9~LVC_I*o;$II-0IT}#mYrto_Sc+gT1c?>nI9+ z$FPE2?=yK&eilcd+a|KN7C?_l33iu?LC76_;}CLhn0XvRZni!FA$JU)453R*CGYYw zQy_Gqxkt{#pM;Qm_WUUbxwrgHg^-(((;(!2v3(js?wIoogj_wRL&%-m%z)6vX0YrN z&qC;8^Qh2sP+?Z&BJ+k&5ro`&cpgG-t-JuuR^J?`I4g32StIF6pt<6+>tP-=KP%G4 zd?&sI(A%2s9cZDDUGIyaQfZOgZ#88Qay|Gygj}8QB!j zmle>b8o3fm2-)?XgeqlJ?7mb51yZ^_qMOUPb8@*~E3ZSPmy^yOfy$sjeddZtEfhX= zRU4}w3ZEURkJSK$Pm82rrK0d@kqB0M6h?Ny`l{6x?rijHtV$I6s<8H=u!MbB*{!`V ziggnT^UT4TtiCB&&#Lb^td~((mshZcwe`6S#}b9UT&#Q)=2?Js9}3Tt@5g$vy-)WN z)?5@u&ck|NeIH;A>gdxA#yZj2r#p!iRC=GOx*{?kg{@tHwG@SUF2f2?STplIwAV+P zeNdpenC?(_e?D(|Ks{Iw_StOHlPl<+o*F@^J^i!U5vZ~F>~q@}KuvnGR_uEgnnIm4 zU1z9E&xm`*x+~O8eOb`8n#*-iZ;k8&_0>H4L4!2iU?^WB3!ok9+X?Me-#+L^t@TgP zel70+bg-v?PW%uQXfCD~&)W9#tvwa0)yucGHgsAq-=fo@0phcLHV_)7k;9?fUcO)Q zpgL(yQ6q1HhH2z*XoNYE6~)E9@QYc4aO zLiH6vrJAk`TC9;vpye950{T>aE1`s@OG1?zSp@~^GgmSTuJmmm4UM_dw|y*Bd?jta zLE2sdRjRKF3N)R$ind?nbBRK^SNUGbgJxgldvFdEsLx!@+4j{wvIaVQwJ-e$6kN^G z{y$Q>xrVx26Uj6$+qyu(H9noWmj1lfQyfZM>m!p;pgwaQxm@QXOQ8AJ`SKP(iR(zW zK}two7fDoK=Sojv1%akBz1bOh`^ZzF+}=LVJSfrI*E$L9={|%A znY9Z-&bJ#vF5MoeQhilWpyio9{GQW?d3{3K&DL#9bstWp1%lir1FZ7PQqwWAyt)sVQ&sV)k5~%y9PR}Z8!o2eYv(6B=g$zy)TZHK<&)?L?*EU^_gk?BH1YPMX_Qi+==2?2^5x)#0rqR zMtGWZ$fd)IA(sv-fm}MQ05vzeq@K@^4u!rbRt$x?#Ib@x?=wY2qA<@WRt$x?#IX`6 z?4=}DfLzUp4A4~nTT4i)G4(o3L(`jSv(4sEb!T~*Ncn#&K+UX9!b z?ay(2W)47sMwpFO$SNqf&DYZmB9}qFygX>cAfIj|lt0Kv7C`r@?|x{UrW+3xiZ5&* z^!6ami3UhdyaO%N$VJe*8u=cyR(l{0!y~%|AGRI zG=mwx!K_q!e2+pgA$v@XLxK9t?Tq{F9J%Z>C+Se@+gXkF?5PcuDL(smD3?Lq)z<^+ zslHxN_U+Wne)}*A4Y@ty&Oe7jxwrfC&phZZ_1z5>sP8^#qWT_!V(N=Sg;Jh9J1l}q zZ})dA%Akb$l2C9vt+l^?n14b4@{wi;YjX%Yk-a}r8>%ye-OXOH*M-g);;*&Ogz62U z4fbt|^`X`p*#>H_ksY86jm(6`XyjNZUwsA8IQ5N(rfV)Upl8+h98@S|ukDMV=e5=^ zKr@H<>-SmEi<-+zP_gDx0?pNQ^Pu^f=K^Sq%-1Cj%^c?2Hw$`E(#hLk%uCShVg63r9H>}JFM+mbE?Xhw?q+X;kUNvx4(*UU z?Qau1p>KxKOZNGYZ=q_7Tn&&a-ghrl(0xiJ|XU-32Udxl4rY@8^obv2> zc?3#RUpkaM-1lG<8Zw+(+ox-XLb=1KwY}Gz2Ne(ZbD{)#L&%QpT+%0? zo4<1-O;GqdLQ|{&xi{MUlN)J)!Y902Vg56l_aSFwpx(P^O@pfHDQ$6m(Olt)QUvw4DPr zPEcDxL1||zsI3FFDHIsk=li_g*ZXt*@q9dV_S{Y9IppE$< z(kSbfAbr$oA0t8dKhS6rgzXzcf-v95N!eDrf&`)V$%;hU*tm8n7D0Ib**MbpxaKC1 zAguG#BnZdhDiVY-Cz6V+UomNx)n=1QtX4{bu$R6}g76u?uaF>Y-<*oXR?zL+hV_81 zvlol3=H`+htmUgD2s3<*1YyiF($}r!e~=*T!TF>G*78;ogsr`;A~6f}_|3*52>Yd+ z1Yw2>(j7L#LK1}9BGR2U-(93iYgtv1NP>=;!lJt+`FEAwLxQl*#iV;}%x{n&9KUMP zQfs-41Y!33Ne|h450h3~?Ge&TR(qKQq2G2Agy$hUNDykTkRa^&S4j|#W-|%GacCjE zZY$eK+GqWKNP;ltk4XPzE&sbBF$#4164*G1YOGnEDv0qCcZ0UP2U`p}<`OJ|u&?)%4#YFKpO7HziJx+ffsS%1mIq-i*lw%s!S;iW zc>wDK-M)j^A<(n#FxKU3a#!rej)9JO9DB=ZZ(}{6WA&54Nxz6!=DJ9~j9a>2k&f85A0dG_OqnO#&owQs)yYk9w}?EI?!h&_1G5BS05X& zM$l(LP1ttOmOHS45piu0y8v3dh(%U&BX3U(1%2j`j^%)knTt&Uoo^~uVzp8%>*Mj5 z+1Pr}ejBhl&{q-bu|}&kVa=dxX~Ej9)`1TWYmsSo6fVUklcJb=ol7uE$j zLpL_$nz)vR4F#>GV;P{cXJVs3M@fi*wj6_Hfws)X#)Gz;faQR;%*Dz&D))+S}MEtDVLM zK*t=!A_)IS_zY(jXe|vJ1-cChEX!)y*m%&{CtyW+alc}$8njk}C9Rgi)`5;$i`9W% zWA#`@eq77MMuCo!z_LKsnT_Ruj*^SL0lMlpv16e9j$?0u_In$v{A}E>3R?`?ZwXdy zwHhqzhIo{0Y&__E6R;esL+qWuPtRVQZ|m7F%bvT5P@5Hehw2 z>#WB%fo|VsYzyd^4Ok~=%Y)be=!&@m5UT~_PHj)2Z^6pKJxx<9i;5Vi=*0$p`B zRs=dOXv<=(40PM)VU-ZJ0b6XfC0I4+{;a{4f{wBbTM6246}A_2%zaq9)jF{KphxTg z)(JYwLF|y#4r4`si^nX+NgD+G%XSYJ=E0&{57~jf3&3o3LikQChH0 z2zv=TXSMU#kayyKY1mNEe(6|-)iSYBpvNzPWr6OOY-}OuK3jxUg3eckRa>nFOM>1D zQrH%&HDHaP{hF|5tF>UqKv#Ag>j7=qi}hKp9~%H&%OG|Rbe-q12(+Jjmvi@G+;SdP z4O*+gmV?f>0y_md=4ou}`*Cd>)(Bc_!nT8+6Faa@&`}OzJrGun4Ondui$F(l|Kg~C zj*^Cr1+9(4azMw-#qy%KmX8&H)(Wu_(D_QSIiRD=#a4p0T!pQ(S}nHTY8$XR2+!TH zKCAU(r$EO%jSX0B5IYCD4d<~4!k%|`@VBlz_$#>ow)VNCuinAy9sb+ezeZYUwMC># zt5uQyV6{JzBCEND{FQNGJW7I;w=kX|pY)Q|UM59WbBp-<=OX?;?Z5FXjdZCb{}UK0q2K$^W*%a8ic#%Os7kej`c$F3Epu+eb*FtYv~U+FFhwjkT8JNT0G9 zt|UR2eLQKR)h3aywo$Gjl~~JC(qgMEAyr$y8q!j!`K?_>x?hriUF`wV78|93^xPt@ z9RIoU=Sf?w-!{?<*78MCqt%*7%{E^Psa0xzf3}hKTFZT;b{nOG6fNS2`S-uLJNf(W zoy_iEfA}P6;+>4*Ux}GSy1{BUk|5LyND%rJl4e@%3#6N@Rz!l(ub2ek)yJDj5JveT zsYH_hbY3ZGuJ!vWsoGlBkRXitP0|XhJxGF3`xa@n&G!gtjkR1$dcs<+zcWz)x|TvL z`J^OSOA2fEN%m3)maL2WrLd?z?&qGS2B9Ao`Q-QRM$!v$KldW(PuB0xq`tVH>nB~b z+WRC3Tl6ndWHomepQO8s-+TGKyjnJyH1#fC>+#>bdL602YK5faUGbeSMXI|iY^|#& zZMlmX{A;ZZq{wQnl75xeuQD+#RT=JQ!?6s|`%L zTOHT(u>#OLSRqyf+Oilc0Uf0jTM0VfDs0Omamxm5JLv2?uvXB1ZP;GWe*5I#*Tl7v z*eK9i0vm0$F<1`hn7LTF)he(`(0*0ea;vSt>Oi-l9&2A4&)0$N2dy2zdLE5O>BWXV z7Pm~tGC*5qVxu6e6U&0Icd_xH>zsh)SS=UJ105wFE3jH2Rs=d`F;)UPN+~u6^r+0m z8rQ}1HDSj<$2^YpSgjZ910Ay;8?f3Sb`Erg^H^jxS4%n+_j89ype^0cNuXo8UsNV8 z0p0c?n4sUCl7=O%mcrUW*V%zZaD{8;U6!tky$d>KCKiE?>AG2G6xYsU5rnJ5Rq@JQ z6|YTPCBIdaP8wFlnEoAn!$}!Z)89fPjk1;rQkJ#MCXKJ+N_}!VqfDqu41rI(?_bBe zL9wBrzfqNrMLzi%K23t~drMc5AUwWLBtclqB*uivu7ytrrC~!s=S#!y@EZuzsuF3Sd-qbT0CXD)u_DlOuo#;K`s-)2u_S2A6xI&9&JHZHnw!oZ z1l`&+Y$)hj(y?K_Ccox59LoSb&oZ%TKFJw59V-CsSBT93Jws+ zCM@zbf34g=0zD4y#;Qaf=*sf30tn|BR_<%^n}QWsrBAZXDy-Tk`F+Y7Y^6^!!zwHZ zI!X#_^hv&LxCz@1`g@o=ux8L-z-+;Gg05v3)(W}}ZP;#~ntjyPBE%UJXpkpq; z%0aiU0;}{%)>4Hn27TAn6090@h8k=sOmcg;M=rybgI-lDuq0^96t>3KH>~z1*6wTayMZ0pe$b;nTt*F zN!B?P%Y$$gW7B-i-!-rstac+N=n*Tx1npOd&G1Ri>zUZCp!3~^3A)e9F+pn;*g~J= zk!ulF3A$gZu*JS6>s*3W`y?x?!IpyFKbK+4t+oPN2YL?HV%x2@18eq4ey_C!+X;H- z+l94)o&{~#ZqRk^!S;e4&3)J#KFQI16Ke;(D|TS}eUdBZ0M_Y~oJ$9>L!kTYFxCZn zes*I=K<7J(9Rr>3ICjD(neQal2fEIF>=fvpIE@W}u4NF*`+PiRJ~quK*`L#~0tm+s zo8fD+cV}Wnz9vVr7@Osj+{incAsQr9oT-*bMOGxX|;pc zA<$JH#*To_a1=WRy3XU+tQqlqv#}D;F-x&IzUKD?R_1H6Kj&fdeUfux0agzBxKe>F z1f6{mRtY+06}A}kOkRRj``XKL1z<}-w|yD5(%0lXTZJWkO`iRxur)r(%GP4*K=*Df zw%%$RusWaQJgdi^20fQHVuC*V-Gn`3wP!IwYg;fu`!!%&eUfe8hBf*m>ukcdgC5%* zShLkyu$@-hg|&k2r8aCg=w8}`?FGGK?!yj2xW=$9(0<+65zwP~6gvj`Ea*7)metiX3&VfEQp2seL9>0rN1mPF1zrel*ojna33VIyU zv0(7a&uXzc(D~}IO`yLSycyf#YjUkLU|W5Xt=)z-f*#E# zY`d??Ik5w420bHNu${gpM|v053VMHO!*=_cjJXHf3%bsI*c(3iM`rAh)ed7_p!=m8 zI|6!j9L0`-{!jQg_Li^7Iruhq!q?=QK8f}DnrvS`b_(>IIE@XxDc&#X*f5`Dl;Kzg z==1qZYy{}@vys>+Uz1fQu+currwqnmS)hA98yo9u{yf7bg8r{{5;n!xWR$5`9_ZHQ zW79y-$mv)C=sqjNW`LfbGqECHlTnJXS)kiD8!G{wuN0eOwYgZCPjWrX!{&pwT!58> z9`_1tA?V5$VU?iAp$c2NMq;BtucHJu z+SlYN9)o51BrUVCv7oCSCuX$?*hJ9ta}t*0lRTTr#ioEB>8V&A=yAx$rh)$Y{B*1U zbcRA~0CbB6u`{4sbQX(zl4rQCm~?Y|t++4pXaV6bSlCd|G1IXOUz1~$i7f`bj+S86 zpx12;wiI-}W!Q4iZCHUNL9ekCwixi9U(_JWSG z4|~JcWMyw+?Vu~`z`A^r*}JhLpx47u>=@`H`f=E^Qs3$YoX`+6oe3v`s(SPAG~5=*f;R-22J z`6T;g9yZ@<3$Sts{{zFStyY6A1>L*Lu;rjzy8>GYdX25ZlAvozVUe%NUD$nzYX!8H zh7ASX^Xb?y(8u@TSccUyu@P1qiB*GM4>j0Q2v-ia+}GrOy#iZlwN=c5r5`)v zYqHPIV&{C4GvqvW0rYw6MJ$4=+;AQ{+$|hU(EXBz4fRRxA?esK&?7w@%kW9Y%)~~3 zo=YRKQJ`mQ0vqj1zq(xY$E6kld!9OO`aKFg9&=1b1*@#zg%nz z=rj1KSe{R^>U?Y(=vgowE3jH2Hp3_ROVmuP$ZEydEYKNdV1zOKR+`ou7EeOcF?tSV4a{d9K`xS`}Jd|d`-qYjScuDM`aK@1KRQ|cFxzN<$3G^ z=<}?L*pL!wDd$TXHWc)poQ`GqBu6C^8{v~2hmlxxd%QAt2WervYPX1VXS|o(U0ea6 zf0-SQwS#U^2e#iQ+1CfKs5-8>8de5+b);cgp!bk$tiadgxmzKY1U)J#ECOxmzR4({ zdm;^sV6ywS8@O)oBZ1D~mNE*2QLqR?Z5ew4^r&QFBYcwk#7Ha&dgn`FbrAj{k8QHr zW^4=SS{kqmR=bErpm%+DKVw2z3)T)gLkAXtj^Z9*zbudUSvEEev^E_}S}lb&g3i!{ zU9j3kEV7zgK?1$+x(7)s1%RTRbh)k?}JOQm7t@n!jhoR z4pZ0~tF6V>`Pwng&suD~PjUy_fYpKC!RoP1pwCe@W7|Pz*nu^J9+ehsC+PO=!ghnU z+=J}}-P(OvkJWmy6TT)}dlKsd-MjtRDbP_)V*{XDG>Dx6eRMjDoda!o9=ibgzuk*i zWHq;%;{ZBCDOTo_+(YJJi>hH#Sd`(*RVkdl(mM5`3(ErQyV;8J;5sN^NihG3n66p1ih7AQh zC(^NDpeq}WeGv3@!w+GCUU3%Hg06EMHr{Fz zu!*2&>m)1(v}G=q2fEIDtN?Uh7h*H4HWMoX-DkzvEYLA$Vy~Y+`<)HVT3Tz?h|2G$5m7t?kVT(ck|GosP23>UxwiI;L%dq93W3Ir~fR3^j zTL-$YYq9k{$rZ2xs{?IWk8SelXFMKl##bsh6e3ENrBsL0klms>!^shK$uq>-(V`D+rG7cLL zy6qFN9KqefeJ~d*0v)p$n+3W>v#}DZm1188-7jCk=72t?&c(_=N12Ds2R(NeVC4{A zg~Aqso{@{NN}ptFtFXnO&pei3)u3b6U@JlQ{3c*mwxON=$-vEb_TTNS?nC>nCGzzpj&hi8?iP%Vk5CpptS@x+G=C4v7n=j!^Zn0 zk3bW!iJ-4rOv0x4B=^CoSRUwF^05M|6=E|$w|yp71bS?Xu`hvc(Jh#uf6Xqz1g(`~ zJwC~Ny;!s^9@EuwE`i<$v$3%d{vv@zR&$S&K#!(-g0w!qW4aBT$)Hz%CYI%s%$JQ7 z_$2p&LaYdMFBN06AjjRvqtk4x#A>BjIp};9SkfmMGlfM~b5D{$_pW=2>ko7-X;|cw z^mBD2(BtRoNt@!9ZZqkb_(y?KnM`bvcVYN(bH0bdg zgJpp}-_6Fxf{ro{TM6276_x}&cT-r@5ZByu%mBI#nOGL+|D>|90-xl1D8y!gu6j0B z0+ao_0!y(uppOW1u^!Mdd$CiXj|iu+0jmvSXCTMj!K-s;vB)R6zqsew6QGaU!?9?q zY@fVi&}}1u-Wl8rB+zr-y~y4LeNLN(MWAPhYvhUpU1tKz0^Kj!SdOpBGtOKr5A?X_ zV+Fn@_wqul+$aCeF{~QKx(>FX21~*u-s{Ghh1G!`hk9%a=$H*yqfc@!HDRqV$=y!B zHY~E5Yhp~$-*ib~5rl6Qe2Lo2GN$~3R0@kA)VA~g0MK91=)j^jt9_T+_hg^RZ^(3D z5rlrbNpDoe-yQZQ=|8RZV-jdT*G_`auY(j-#qX|lck@2gyLsoW|AwG!QqJA1rS5s& zGn-4g)@qYUQ>}I#39fR#m2XM?3<*ZKC%#U~Bf&VgM&5awPx`F2yq+}8T23cHnEeJ) zfz41zO3Eny7f@29SMQEj-Asb;e&H6<&b!&#Y5&1lw~G{6OLq@r-ov*0?^r4%HQy7z zx4wnca}T5VZ&B;LCo%00acw$Q0Q!v@g;)vb?+%n=WuTwFn1_{veg>cdTWGaKSQ4~f z3LF2&c!mjB4(Q5qv0*3T`G#X<5N5y@`y`*!Sb`<3mcp7rXK2A%K|f2=hV@ykA3FuQ zMW?X=(AfvEGoYiK#m<4wejbag=1$&|7;-M&hBPb*I%W!stme+MMWAC2$1*_2%*3)l zKYfvn^}QF5*^do?jyZ^(^ELUT%XuvF$$w(ypZ6q+F2pkwW66v0C@HKFbd)Bn)oN{6 zJLtA|U=e6b_dcrz{nn`ASQN$k)49c5D~s78|4pyiq@2Z!;y=ZdOKP-Q6RCAETdUte zOX{&&FX^w+?>E_;t$!m8Nb38!~R5Jq{I6j{wJVJ%DeZbJV}+1aFMNqjGG z_mb|7zq!+WgY*qnow<Amf^6UH&=|8RZW72->cYxGoV|J5{SnVk3nDskO`h(T}NIFr?`QpEC?Ih_> z*7DD!KCAVU&RM_nr1xyTf0Ev}e*YpxlKlPC)o|9;aCP|afy^eA*Kig4&$3mJZm)@F zzk^h1Evra(*D#9z&bWI>_ex8Db$o*ap;k?TFhdOq!WMm#1mWtqkF?ZUE+au0<$ltG zR{IudrOmgB^pLIkVN%lirARMH&3}UHWzu%(=kGQ2T4D-mcJmqWuv@Jg3#~xq?1I5J@^5i1g+&l% zA0S@waN^FKwQnifvLu#&u^gp(ipOGMJ!y(eIt@azX_IJ|n6#Y_IBWTMetligS%nmHF zntO}%wvF=p`x4bK*?s&@p3`FMKu4*?vi>)2nT-`d*z;J@C)v9xtR3_ibzoia8CSxX z-B^#+da(h>a*t9Q#4do&cM*#q9QU645@$fiJd2$JZFwGxtmb-2eR0k8lR#&1e<2Or zmzd&qFrOPF{UaXJo#WVoo&{-G1fk`5jvwgk>DVaH*%Me6=sL5p0??L)*bLB7W@6Q# zYpKE7K}YGpI(<6E$_`>(R_n%&fsT0`>+v0Y+B;eV$^czlsE8N#5eUJp9<%dWRz6CFX1mSz{ zGD#5b>>nmU__qE3M}jc>2oi+(Mv_L^3<(m1RezKO`djVpVagF+0FTR8>w7s{xkIzq}y%2 zJ4g^#wvYs2ix!dYw3c^~Dy8P1c~p_^mgHx@hXi51#UuzvWeMqC8|53MYFkSU2|~+n zk|4}}9|^(?OGyy+%QDjaHs%AQf-v7k(k5x?zmZ`x3Br8OkRa^& zXGsvYVGF52YW_Fyo+E9y`F4<+ZN3)LYf|%P!RsUlqwFL>SlKQTgnj*O5`?4j9TJ3E zD+$8N+DPBE+V@BhX4p-FFvA`agnr*ALDSuB0)Gt-6RP8eo2C`eZL|>SlJO0 zg!{`;5`;bYYZ8Q`@*5I_+A$J@8IF@6oY%i4L8$$X^hZg4ohL{T#ym-au$DiO{w&F# zFMT8kqx6#?tn)7<2y6L2(kW|sn)Fvm{(km1(mPgrmjvNBoFSdFQO=Vf^m~s4q2)hG z7p&z)5`=Adp9Eno{~|$Hbwq-2#N7Rf2tw`ONDz+nC8Qzu$9J1F5`-}?B|+FPmysZh zIg|w9dQT?}vwp)#5a#hA71r{TBnWrfu_OrR#5mGKYdMK@wT*HODMyn3UhrJf6l*z^lxL&l zlOXJ+&ypa_cRgvE^_zY_-vAi*JC8*W?$9@qAZ$?q3BuMEl0FyrbDt+cc)m1)1Yuv# zBth7pUmz9PD8;0krRLAwFOp{2e6vXq_RE(@w^*&@{=_iQwG78HK#xi$R&BK!ENQhA z7R`zKxw)jTO7hP&zD9zuwPmDvlKi##brOVa_zx0+;VRw@5vX+%32s2cXAY6@ilOP=1dq@ywUrbtJqufh^a8$lQg0KzM zBnY({5`^>Ynhrwy&QjK^SE#3BsP=MuJd#fdpaIFOnb}u|^VvZEqsI zBrW~*_cCd_ZQl+Oge&e9Qj4_o=k;qO2=l#8+G#WFBDG5L=U^M@d)99^3Bq35L)vTe z?IX3@C>^BzRy#oYsrBn5LD;^7q{G(o=cF#Hb(0_*hhLI@W!rv)1YsMFk|3bfaq`yc@|IF(D zNDxLjMS^f_Pm>^Q!(T}dTKmJ2e>aiz$;DiJrZsKsr&){-cuo| zg0xU-Q{)|yi%83@QiNh-E6Zze(5XI~_J$y(k*DzPz3NptK- z&n11$MkymfxQEOmK^XJvBnVskAEfzKTR^(i)_EHV!q%3PDr_yclOW7+2MI#Ug(L`j zVi9Sv)s~R%wHdxaf^Y|`Cf#Q(my#eH_hlpqSL*$w2PFB&@Z}^3v#%gQs69x6u$FI; zlGZXsf^apiCP8TV2x*OtvX=C?B>y*-CrA)lt|vj*6B|f%c2w#~5LWgy3Br6ENf1Wa zM1nBNX3{f~{Cv-n8f=v3NDx{+PkKRW{_*`q5`=AUBtaOZiL~ABQaebmSnX93gtau2 zAe@)%Yd}!WHnJBnY)1liICc2kECaLnjHso&6vQ!WR7x3Bs5^ zBOS7J9wtG!OZ}V#VZL9GAZ$?=3Bne2lYS}5ANOC8j#$5=q+{0aI0?cGza{<7j@VnI zzgWxvBb~CAr%C^``7V$k9O;WB2(!OWimatuL0S>NJHq;hLnL0a@6k81w?Hg}R9wpx+|p_U@8x7r30 zgxQ}Y)mh7WQm3^%NIGP-!=xitJ4%9a?pyNL`d^dYw%YGWJ=U+6^e3zRnRMD}e^ZL0?Zz$L9Maue>h6 zaxRN&xmW}`gB!XsQTf4mhAM0^=zBSqVAWQu!ID17J4sSl^dTE1V`X9;=sIh$2*M1R zq^y-Z*XTLTU!}9zqFdsY9atx5%Y)bv(Ake-eOBwo21?@EAQnM*<+YSl9`6ZPu`;pr zcB#p$_{*@KJ7tEecrR5ib^)|@5u0+CBsn9eVizi{hDD(LToto}wj9JF2(`OcCT4+- zIU6hUNyeOqm4mjdz^Z*sR#t;8_eol=z>=UXQ&`VE@tD2X3DDX}tj}uw*ae?tl#5v2 z;<#Tv*0&_?*N>e7UFT_Rz$fW9h-FpBwQOuG=zQa_0$-Dsh1h%0bWa3ar{{HP}+ne#@}spjW^OY$fP?tFWX`GG7Wi2HNsCb_Vo) zYiF@@Ry&Wq=WFr~lz(FHTkT(1WHt9K(n=Y{|J7j?=_#4rzk8#Oa{_d}aoBj!+5{}m zC%H4^V?|ag#%6(T-)yV|be*MG8R#hUuyUV9f0<8PVOy-$fb9gGeHRv4&DC>MV4VBf zS9zBL7HyIF{BOB7aHK(JNXIgKl67WcdC$kSd~62j%4TBapz~E=3qfB2ScFAB$tZ3s zGeDRD%kW9IArs58S~ivgI%Y1m)oRDXbZ^)`FdTIqr8J zi{K+pw#aQ~pF!AX*f5`Di-u!KtEI4Ypfl8B>wQgHZop1h?IhL*y3T%Vz-oio83vatwszqp@qY#|(5Y?x1S9EM{V zpvNc^8)3DPSh-KKUn;Q3YVHtw9<*NvwjXqV9>6+%l2>yNVqI42#(F?k){A8vj{9X| zBYcwiMq;BtXGmb9LHmutrh%Tx)3E~3?JLA8eUj~~!m2^v?^c6twc0kU(I=U&32O%3 zpDkFY)ed4kplj*HPJpiEBsKuyT*A)yBx9b%&VjZ(k43+TTe>c;aL`q!uwy>Ss*huD zfu5~zV?CfN>&5y&=j+E#`6Tn5#s;i5h@FElI~G~Zb+1hP_`(Gn~Z^^~Yl##)|$g zt`%dmK(}EwR^n^2wWZh`(Ano=Wxgh}&%>7cBrR89Nvox>br4pKt@kx)xdA%|VV_|a zd`-r@h((~s!JXo&f^bz~!+esK!?6sjWn$%^+g^cH`A~o3Z%=@v0YK<)AGq zuq1?Y7i;!Owx|W$2|C{{EQ;c_IJb(wJg=hW-vNCIDY=SK@f-u9DRf*l8wLMq_VVw=6=OoD|)ZFu=t#M1Yjr4*f`HYc!k<=(-%BOK% z6X_+J;bjtp*|(G0CHXDtAi;PipTc(kMe4EndP$(433Y!Uov<-al1^E_)1(2b4U*oG ze*Rl>-X;BA_L+RIwEG7svX<^4{@?!)HUH}`M@i8`^pnqFI`=UD4}UmbbsA~Z!|^&3 zq|pzD?~QR|NS}B(-b+~|2wR&?8gI1;q={CWM9Q@>uO;PM%g>S^Y{T`WY1VH#>C4vg zD#`JzYFZ@Nf7?e^)v}WZ6j%m)f!0cR_h>j$(a7rUEQP}YuUS+ z-_DbC1OFpRVbQ?q__Hm8qzkL#d-+Au`>VMV$sNr7ixgSSJ;LiNkMNFN|2f56(iBPZ z4MA=yDbH&8q@qW7SFU`so+~DmJQBVafREpE)>+F1q;g3Q$tV@1I;+)_w%8~Qq^(xl zMrxF_OZqjD+O5_>>a^NHQkT`bNyn^qoOH%&XG!NI`7Jt6iX@$pZE$P2!q;#P%C~yD zG}7=j@pbz_QijzsNm*;+J7zZN3aRIem#=1Nd=E`zI;T^$U;())KX=nV$v)db2bS^y1z@mFOf>5-{qC0Qc`3s z-DCXn#$$}~=Nd-2>aj$@=*wD$}yy@T4wN{ z5z8hONb*-sA!&XsV}4R*UqFH!cax-BYZFP(S_+F`lDl7Ow~@-@nyVlsYvWICr0BO% zYWWMeaO-Ee#tA+Akd&X0_p1hOfzf$;3waB&$wf zqkWQfj={1(KN*#cjRid?#$iREYbnNNf$oXfSPAGEQi_#XZ5}os^!P2n%6(1F`3h{I zPcr)=ED72&g{|{7S!XS_-fA1LEud>@z_x;J`!=i*bd)A+JLop-z?wn(wO~6zulHS8 zD`>wqY&Yl@?ZNg!uIpyi`>?WU@hJ1Ka?m|dfh`36Ow%H)8g$GWY$@pM%djNqs#Dk+ ztF6V>fu2jX*m|pN!0JHftH(C^BztKy_6Fz-Z({ABN3#Rl4|)Y0z&b%&9>k7--b0RJ zJ)qmri}iuF?8gS+I_K`@wGix_PjbgRk6i#=^+jyR^tfLdHVkx6497-*Zo^3VcdKP% zV?pN|Ck8s^1Z<*D^2xnPSRUve%*UpI9?j`kfz=AJ8K7g%#EL-oL@_oCbd=dxiBEE^ zlwxzNHWy2RZbJ%-K*w}9u(hD0q+vrr&$@JM80a<(#|mzYTNYvwgi#8Z9dwjZtPFI% zdDwi=Gjah|ZM7P#4s?`yY!m3--HdIw+77H4bj%j474%5AVS7PG*@s1-EnOk0=y9H@ zUdM5F#iUt}CnmV|@A5okHt9=`CqC)QrFILc+-eo1g|h0^Qd{(R;xf=thGK$_l8y;l zyBwPgW89_Xm%AyLpe?6jc~;BEJ_|bg_1FzoyAk^WeAqq6-)(Nf1RbRaD+ZnKX6zQL zm0({1oqY}_=nQi)L2F;dz6LsG8CGGn+p$HUv)_pc+V3t*(0-LzR2|n`4Jm2+EJbR6 zoM(sf`k(6{?T^n8cYp+Xg}a}SAe>7-<=BCqOW9Zf=;K%+Rs`Cw7)yfoOJR+mXHOFr zL71VF1X^y? zA_!~wIaeHL%QS2b=nQkQGGFtL%-DRNrD|1Ffa7W1#Cij`e_ULoasDYUi;C!WHl~qky(dVG-yj zwB7F+9dg`vxbJ3T<3U$80h{M=xBeTtRI zUm0CCY1&iq(VR{ycq%@cg{03(OaC6-&y!|K%aihLQ(qw6BsKr;%pwwmmc=9pEpH}4 zsC|(%+iG7T!BuY0FL>^D3kgEY5)y<_N=fBUv9)?;k?xR|Tj$VkAqhU@KD?2%i1d)v z9wveQZN?=@aD{uilx;{ol_&sxRj3dv_esvB3alFRHMbgUsnwQY%Y98=DO`cAwAv~x z>65%Vm%`dXUxn(x`g~1h=*Kcv$FpZ*<3V3@n}Fq5Ef*`WS|L^qI%W-)d`U);F;iG0 z=qOECyRXSwI=m;5lU4Z)H>ifbvX2ee-=7J=>w_r_C+A)uqA zVafKmWeRHqZP|o1gC5%!EV7#GU=+}IFr;Cf2cF`p8pSz(5bJt7u61LT{~On;u#A&& zEfZ`1Q(Wu7+6UuW2Q~mY<{-B4Y+PG}Rr)0Ff~vw!{UffO#s(nt!_N7dv^{ zJyI06TQf!NV-*O{@ZnKBdx6C7#)?rg0CVat(L0e zs;Xn#d*q%h=@A*l|MeA1u920=ojrv`YvY=Gl-lFcPww+6EZS(bO{C3riEzJuh6Lfe z0-q%{%3A#Wx`_m#_7VyB8@240mr1*AlszN}Ex%8KFv<@|Kem?bBnT}#ND%h!e~}>k zE&abq`{juFcTOK5!4>W%x%2&m1mP9_pOPSq(n*5wy+#K~|5F#gPyA=3LpJ7N(l2B! z{_pc$BpBy1uVnkWNxzmPSAhFXU1H$4^pp1t4Pv8z8`s8Q<)F0+Y`N7|U`eZ`u=Q5k zfYte$oXPdrA*&t6x~$fXMLqEhu9pnD6aKs3Qlz^2crVqH zUY3?}uXEc;tyXIz{n%>lBnaO<)j{gAmffU_*7AK)WHt9RYk4}JuaI=h)2!vhlN_-U z5`=wSN-C3<{#}prNb{}V0@CuQS!bHOQon+<(rT+n$*1FEnfYPdKS{V3AMK&$WatO~NxzhqBX6blE7B1g~FZe*YN=ey~oY1VQ&>4uHW=kIPek_t9*h5KiYg{03(`eFs6 ze4aEz(v^~ClD=ZKIi!VFTSQ83>vTKri)NID})o=>>5 zq`yBKUq}BSoqIOEj?R-JX?aBc$KbYbMsA^{{|?hM(xqGCqjDJu!kIjj1fgX*2}131 z5`;(Q50I|5+BGBy{c=bUTIP~wS#36Hx%FE?T5q)tq$jQR6sgW?^`yQn95MghMg62x zlKgj4o+d?Ba}Dh42A(sV_zsUiDNGA@I$G+ z(U3?&cugOR+O5_>iW=faO7|SkO`l`W>-Q@@ml!qVx$t+Xgc#`GZL+bkKFN0)j>E$;7%0(;!ozG>H#y-cf_2=$5(x;w_xBW`ec&kkyO?-}{;vf4bk#ekMF6mlp zIhmAaE%QkQRx2dUc#b{q-`A0@AHkyNy)-9FIAlk$Y6+%?LGP( z#3pWuYm=}>&@r1N4aBu3?83Y8m>03;GjXj2YXzOJ4LjnKtn4TjMR7~sbjiDto{x|F z2T2f)^oK|gp3i5HAk;ER5T3Vwm^95=PA3&vt(XMie+)O1N~~om3Bo8}CPA3*E2LX( zzS~F;YULydYpEbXnBjKP9oBLo3BnAENOxJSl61G#?jbF)+Px$Q{k}m;S}jFdZM8>8 zYozACOLi^kF{`a3L0EMy>2a$)L4r_QPl7Pt1`>oZpCmzO`4kC4t&RktR!`b!qiiBU z*h`y9FIeqGQj_(2iS)A7wv!;-=Xa1=to9lS!hEljAgp>PX_q8_pZGQjLd)-vTCHUp zsnco)Nk6mNA<|(R<>#c|Snb&J{Ld}!m%<_l^ZkwlVZXdZf-w8rBna#LJ?W&?{zU4t zT0iNu)&5F?(C=@g$ZBpYX=}KO`40<^soO~Ylh&7$hHc|_4l?fL{xY0&wbia6qrp#eTFpMYB!K(Zexr5|Gd9Ix@j9L^RImtk#4qrUnC{1Uy8JTTX=kM z8%Piy1D>SMQ`^EL$v7+z^pPYVE2)cHmSSa~wRzZb&_|#Z*gC7#Vp~9GXuuk+)`V>b z?Y9GK27S$@1#1O;jBCT%L0fiU`$1n{Ie>Lq?I6|#y0UJp2lSP~UTgq#ErZz5`nY8} zHVt&X=~!en_w=^J?v3&6d$2yx8TzqPRy&OiSZxqH13Kne?84@Fl#5sd;aiHIVJ)Cz zrehxiUFT@*W~+S>6SOu9`B=n-=-kY2QwjU>=x>zYWf$=ND@ zKXb2>cFNV^pW*HzL3ows+oT^_%O8>6kTECAHTEW{eH)MHUzgND8jybe8TBA3vVQIb z9>-pY?{1l-tQX=}aI#-WBtI3`QrJ77|5JPy6ZBd087#UouDS7~Psjb-RWBq8K>HP9 zITPbrE|v#c%f|{p$1KEJt=5LMgWiQZu;}WzrMrd%;s3BXr2M$0`z#5Gyn!6urQ62LCL`y~zIgHUC*e zEQ0Vnd37Vd#~0TQU_%~}BwLh*4fRR-rDN4=<5~?C`Q-P+qa+ApK1Ny}kLfm$>KfUf z{`W2FNsYGZrbd3jCT^L+x}aEv-gJ+c=6y9&J|2-1?SQyNn<7X$J=ov_=x*DTkAeW8sEe^{UhZBQi0U`Xa5UH zN$KbRx|||4+6+ykW@#y}P`MV;_a*ti%>ID1*J}Gn`>l3>)YZhjCrkc@+f6zmWBP05 zXj7v7*KxlNECPM4$Ni=$(Q_=W^m@}m$1UA<=Gz{p6sdhXE&UdCkfQDJ>~05p zcLz0nH$5r2Bi?5zQu~g0)g7cBY3bM5ONu1K;_WLXmA}Gcz?EO4Wd&*BD~u_h<8zBhm9KEt$#nu#+^d{*uf{F2N#iB?b7BH1$7;ExyjNL^|DB3_QqpQEQr)ZZUaBWGzRKf~f8C;q zv|U>I=WaVl?XR*H`EC){LF%(n`bnoG`PomC2COznie8Pk)-^MGbG!{HQhRfJ{5nWo z&G9JRq)1xoo^K(w#K*|J#$I|YPQ9do*Wzs$d@b?pyRWg%A9A1Hf<^DW#tiu}!R;GW8B}J0_{~z2={w}qXe*QlA0aDIR zYJQ*PlJcbXJ9%`r7w~4s5I}`0s$F&YD0{$N$?|j%u+7#E^=ADTG z80Q}2{S$>)67+tS!XgM`K0|Gb)vyS(pKGA@oYk-hu5wlK8pHF{wpk5}AgtvDYH*d? zNXrx!`Q-b(NNSAxxh7g}mws|yr?5KEyG=cI$ZChNg73t<vwc<9-LQL!kW*V_iPU zBUd*TwZ}ErL5g<9&(_?oL_yZB(610nLRbqHS1 zOM-BvPTrMRlNZ<4Vo`NGpR3uG7`h^EnT|Ck<60Be4mwH)7Fo@ucJUXbU7X2sCrZPH zg5I;zu`FMc>pdGA3;MV+4$Jd3>6ecseUg?bY#nIJTC5KAzEqDjLUBQL7F!Sc|L_f1ov+C#_1GrRd(UR9 z8MI{!)(X0^Hf*m?(r+Ku4%)H<>ja(sAa)M4-+64vqw%WKu%V!PA{`qI+HVZD6LkA_ zVY@+(!yc^N*JNuuu(v?_y^ZzwnyjoB8}Lb54q}nj++*xP(3Rz4Q$RKFJJiSUczzbzu8_P3Aj*b%O5QgILrPw{*Rn1^u#p zaxWNzWr1F)+1RGPNloqyo3YUY))JfVlhhVqk=5MbuEYq?wT#3v-ify%6DtGVgY&TY zpetK|m0PU>tM*B@p$1!HwYAtftJVI0p6)%)>S_Pu_@beqn4y?m6Ej_yZp<9FqL@ul zlx8T3VkU9wD2k#Gds7r*<{X!DojEw_{9KBnC}t2vX$Dagds9qKX@;Un>HB+q*5&iZ z`|gL;-|u(dFSDbnAa0gQszvrnF5?n9CG%xk<}c`fX9_G_`0#GUI*s;r|$ za{rP|)hjRbdQwsW+pq-Wp# zsYUPUEbpU_be2_!_&r;V)@W}n`c`}2A>vn0A>y}7qb+(FZAA@wKDVJp?KPo&vYowb z_oKgLuiM?oHR}M1q-T%Fyv{Z2b!1YTWEXUX^z5spUa!Xz z#JyTdQbok~dcPjaJ5#ok&wcZ$qCT(tYme!RibZnmF>zEOv)J2oC3-|hK8oh*$a&~# z?LC8@)m|0)xAvZUJ=S7==w(nv3$?ch)yST?%MYdW_|m9RWM9=ap(q@mF^jpDFAm!k zq2k3HgMCLZj>;Bu4ECO=91(ZyX7u7%OX6?0RC!i%?Du9rHFJf_sBOQFG0j#(w8FQdqdGbw0Aukue}K< zp}i!UBP-Z%>^_7Dd$yJLQ65GW+N(rWvYkDj)#xRWy;Z%87QPW~Rg2Iv>DgQATWI+k z;WAo*-qGH>s77Qjv0C))8~(avzC$VL+3!}QQG<@$hJMyL|A&6j-cHmgva2_}5z8a) z`k7A^S=rm>uW!Ukeha-)sxm9-l~Zln6?&VgA}hPyZnP)#Ofw?>81}vqtJo*!OzxK| zsj7(URZ}h0-Xf|R;{NCFTB=prTTN9*+%eQsrL5#ErK$E1w?Z>jWP9#j5k)em-B0r- z_k(XDd!5flu{Y(imH(5j9U}gbxjo9$UOwvhX1E1(LY?2_npG(Gv|UgSowFw*zIPHT z)a{DUDLUt=i1_tRL&e&QqcWM(J_jpDQ{Uv+?Y;LjR3&rT`{Zg=Biq?qe=SPs$n=}B z#Fo%YQbjv-yI;`0&@=nd0qsSI_&Lo|J|kMn>h^klGU~N7T(5hhQte%Wh`%?z6phf{ z6=a|_dQ0^-*NL}-vQhLlv)K0z&2lc=a_|lYVmGZJp;AWUM6buE@#l* zI@+T7+FO8L((PVGOSQKQ)o8C4McOkpTz6~2>Tz^gO<4VMRHnUhG*x@kP^3Lm%Q4il zoxR)-u8s8}?z+^MYLJz@79C9W5^*D6ri!fWE4~(NNBnkFgRJa!R8h;YoykP4WTbsx zzcp&3y|#$>Yw|IO_*bpRBI5Tm2NCxg-NaCy_VQ65U7;@`e&pGR_`h*D2NmmfaWq8N zD?!BnK5Z!)CfnKX%wC3u>wbwZx|3KI4dgIXzGK+m>dLttKXeXkr zI`T(E{OUiU23>s{YSdYpP^8JbA71g}Q3idWuiz4lr_c?>_voH1*o{x&( z58K62nfA(2q&@Qi`XG!nD|wExl3DC?zC1+yGlBe-u>^6?y^~ZC@%L^WQRmPzT~ML+ zicqo0KHA68W7>Nh&DHJZp(^cFqlGKOefgr5v7B0wJRZfUTGWMJ232HbAFJw7N_y@y zXwAQA9^=wfjaIUH6IB%HoaRF= z_YaxVUYh0Tnh*V)W(=AvvX7KgP=)p?(ZUbIIa`F5$Sn5qdjl;M*+;cy=q>HNjh27t zuOVgyB7WpMi1=4-@1h#*)uL58%WAYnduu=Bk`CKtP$f2pkx8m3B|Z7BgGn=zxc$ta ziXxrGe8hF|qi`*cqmqweZOohzY*&hgYHt`Kx#l?ee)(nSD(zj3ChB&R&{XYBLw9_{ z>h|_G9o?xT??MS3nM73|apo(z#7s4+(UG;NPDj?GueJ9LB7VC~i1_zrHls*KnpLqD z#64TfplU_jCseJevb2{?)rPoFsM=EX(_Vk70k-!npNI{l8bsVIgQ><7x55OfiQ1b) zHJP|iuclD#%M2s;Q$@soLVVb&Sc14YlT=a1Fw%5d70V;8mrqqk+JRQl*I7E={#Xdt0e?5x3oLsy((RpM5q{MZ|wH*_AU$+{g^7mc*U2 zOsYKX?BcXo@YP9<*j)2Mpco_vDZn<^>}bDB8nx61!ri0O~+SQW0<(@{cuNv@G| zWd-?sb0Ag1O8$?4Bvr{%GN;_1l~N5S?*9)MLG`@t$$jJtR4-Y{$d{=$5I1roRkSqB zVwQ0XZ;NER)2Qy)8+y~JHti3+%~UP^3cU=fR#viIYpN{mWmC1$UR$bM;*O{TRS)g; zq$;$M{VbB+fv{drszT!Sx`?V+dvU6MwkKz)Kh+@HlUW8+m1wV&YL@NEe$J+P&`L(m zp^Bogo-wP@YVK*>x6wXEAFDn=pQzTLHLA6ImufBdiuM)Q98|HEUhFAeH&&u5k)5*| z)vV=ycc{EJtVPjU9(`{VnNN9!`>DSUnuAg9ryRR|71;ru|0%QB_gF4KWuJ1bv!DK# zqtTy+N9}76@vj@lpo!X>glcq!T2!aKdi0rY_c=;wFO4GI&aC74(mH-e=|A$_gZy=| z;*;0;Yh;|NjJQj>oNBCTQP?h%D(V$_ruVwoOT^9cGF4O@Mw&P} ze;sFHj-0a#(13NZZsrxyKyL->vurtUQ5D($UCYrI-{V{(n$R zqezdz)Nwh~gMA}CHrpgn`o(y zT!!A#-rFdpy)3z~##J`1bYqDXt@ zbM$!_X};jr|3w&?j}l+_+q6ldNF?{I#;m8ep8d4o@ zGpGGda5Y-4y%lJcZnqk((UEIWo%ZU{hV|TQ*l%%fM8vQ6H6nh6Z_p;`+226fj8Y=I zFKM(zx7&&uwbz99i0lzHBjUH)i-`Z$_@9V$GzU%O`1T>{j4rhXYN&1vX1k$pGxc66uAY2QD)3nipyFX`l$v4(HI^v`CtQ8g0x+MxVQ|*4D6HHdWrwp_flJoVZy=P(|7^|NAo5inuvjQx)t8vvj2@B(7IP z)r+|GdQ%k>x1VvUJ=$xg>h(+L^``1a+;;t`254^})gbK+rW#J%dLyXnv{z4+BJRwm zsiG*%Y0Ou=KKjaEeyypZ7GH(?mkdPwWqSxB{`K^si1>F$S|Z}#3(7>qk30+!Kg;2$ zmG)YrBej=>vbA>>|x-U&%OM0{@tBK{bDLB#L%PDK33MnwEEG$G>8{I954SJ;b)-GkZwQa}@eQ#}*KJ_WUK(U)noB_2}O>_+Q+8j4B!%)-&VK_zk>%u)jn+VMDCXjT`*$@AjoC zCT`E-RD-lPn5u-hU-2!a8cN*AVN}DlH-aj@h$dfn3#u9fX#!-zY?$>`O zP)#Im&n8hVC2r(0s!hbLx0z~}_I6W6Q$x>8LwAPl%v~rERxn8vZ3yoLnTwNNmyYa>inSL<12=Mex37UNL>J3Q`zi1cR3dY_ zzg5^6OO(oX^6p5I>WiUami1KohKJsMs>sUj=Lkgn_4*1la$|TtF^V~fpOY%OD$HrF zMrEOA%F$@q7yHWQ8Z<`dycUhu?Ixg!y4@r+RgYmBO6c(=(JY;1Hv2-{nMhJa#DBJQ zKYCDPKMR?IDs`5BBI5s7>3^X|WIg-p>{0ZX?%Ct0O82Z9Ez~_*gg($YSE3KK_YqnZ z_ROqCbvkD~O6hiK)F`swIcY+X$bRRB!sB8#?k$v{XkfLvLyCZAAQa zcR3>d+PVV0qa)u%H8RrvMNTbRt=oN!h~MrLv`)9HL&UF8k5W1^jT)pEmq){Gs8P3T zLci;Ff1o|OT{DVwr1^$V|G(kh+y07EHfr+?f6r`xsi`f>{f2i{?5{X=Ks|J1PgM8~ ze-(X={Eku)I#qi1*O*R2eRP(-s8~kYUtEf#emYBkbpAISgZvZy>rvM_!7C z>d0YexUM$>mFafnXsm8G4vp856VOE6vq@;G_NJj(y4`H_p!VjVNPA`zpPX-EukB~o zIVf*axcu@_N9}b&ou%jg4ij||+1us`i1=AfLu@g?w!O;s>6vJ z*@{ZyKHqFj6=~13MWmzID(^fWgNXmc_EtlM(B|+=?qT$V_MSvfiR>rKPow|pod1Vj(%#ExscyFn zy`>}HM$2{N3iOVSd>7T|$XfKC_TEPyXm2I@P-OSzBlN3|{0;5bk$)lLM;<`LUz*Y8 zSRrw}BC6h2a+c1d>Z85BRK>(yVsWZjwkIQJQ{8VR`|<$QgT!4Y=1@JYy=SN-ZkA`M zByRO8s>sS-&&;>nYkbRXY_!~a=c5AAEKyg~>sw}dMbsN5w3kFRx?L?w>BuySzU5t& zb+VoLj!$X6^Unay!QaJN5ckY6gDQ)-XNTETImA6vjZvLS-1ErOsQM819J4RgEaJAC zO*NOek@KjY(%#clb;QkCPnFVMnrdN-u-zi6#l+3IglZ{q+byG7PTcA%s6HTWy_HmJ zh#R?SiJN5_Rn;M3mTIcPL&L}-s#A$OgQrpT zB5u9jRK>)t7pLk++?@TX254^}RS9wXQc5*Od)HD;ByP@0R0-l{Nm9+w-b|`l#JxtC zO_e$-te&RYLfi^lsT#DmjjEBjIh&|<5x3rMsy)OVLo-#RJ#+MTv6jU3GO1b-cP3g> zWf3=LHdPLBtH-D&61UwXstV#pR#MH?-aM-3iQAVKsJ3WtD^&w=N4t%xk+_jfRC|cu zYpQ+P+fTK;UFfZ#T1DKRt)^M7zz zK20@Wdkd(RYi|YBD&l5YO|?dQYpLpp+v|F&6mjP>O%)Nho;i_g0dea!Q|%*e&iz!8 z_Dlh{D&n?FQq3T4oF_+#(NHKb4IWl*&tuGf|-S9=|(3bj{6Rjj=@)nx5Wp_)qE zF-)VHrM=ly6~ygJCDkL^dz5M}aeFM89#O;ttQ^}3qsdE(C53sm#9w}5J)_7+hs zC2r1TR4L+)FHO~`y(X$%#O>K`suq32$PB8M#2ry4)v3gdJdLUsaVzwuDkg3}<5c~K zTdzM=32`G!sfH3aav0SY#bM-ns!hbLu$iiXxRKkawi7qY4ys1t&O{T{F5*V+ri!#@ z;^^FP4CXwpA;gVLQY|NL&J|QO#2sxd)f(-srAiUEU7BhOamTlnYCCazy@RTexRFg% zk@ieKG$3qe2BHhYNOKWqkhodmR29UnP)QXH3HxbExQvMFWl*&wZoN#ZR>U1qYpN{b zMrKpBA?}>DrOF|0B>&+_Y&dbNkDwY!+?=DR%CuKbHI}$J$5D;f-UO;Uh?{ddRf4!# zl2jGM9}(3f#I5%z)m-AXn@3ee+{kLG=e73&)du3`+(@;ZxRE=kqM_mPGsC#VE(^Uh zRU>ihHBsdZ4?X@vmsl=wy$)0Zh})NeRAt1iS57rnd*i6461Vy^sswS@o+Q-_;^v%5 z^&oL`&Y`Lx{!CEKC2r(As`I>q|=X$CQ#9bpdQf(q`h0Rnc;zp*a8i|{wiE0mV+ci_|BX0HmR1t9_O&Mo`xP8f_ zDj=@cm8zHadQHsm5sUTB@fvkLfi_EQq3i9&UsW% zY42&OD&pp>rpg-~w#%pLN8EP(sY-}jp_D2?+zLsmS=yUT^&oNE&7rCwZa*ujmJ&D1 zGO9Y_W~ryzpuLS$DdMjMR9m#Sm1;Y2+wGug)Ls)+MBM7;8gy+KX|6-W?Th(48XHzH z;}CIkntyQZxjxL2NtH)jFQ2M_xMS!_)q}YG>`7HZ+{jX@$;6GELN%4RJ)1_AAZ}lh zRI{`1Zm$+HxQAOiJ&rCoQL(fd&(!443s;QnQZo3z#mJ_%73aV&o=$UEg zk+7Y46g?L9%sh^Wo5jpUPw2=edCVbh&el{p#EpzmMO8Yo8ZG>e*Ua{RJX(a_&=ua~ zOe_l{lT;D$m(g2@_|@O$av*NKe5%gG^}0~C{3OhhNtH$1{X{lZFXFcAP1T3EORO(d zF>$lRsRj^t<_A)hXs?uNIB~O#pc<*YQB-BbZC6f}&|Z>i7IE9nrm7%r^-8LR#O>!I zs-?v3%QC7O;^wTS+M>O!REafV&LmY8aU-j#qD^`RH}jlD0r{)0JD*FamJ&B| z8P#&_t)QwQZq8b&4~QGNl4=!kd%c=!jrP`3)e*N{J=K@m`-*CV_BK-OA#Rpts_4hC zo%soE5Bq6$pvG_vrU^x1U-QZ%h6x?_do^LsGocX8%{C5=8vS zQbhcxdY2&L-`~9y4U?Yz6zVcW{HKb8q5%KTHPeZq9?{;*D%xQm(a5^IX*zZIM zoimAMrNaNJGCRemh$+4kEdSq2k}6t};@w{R|I56Ci2wgk-bFRqt3?~+Xzl;ovJs_3 zM^0j|)6DsYY*&bqR8ez^&w55kZ!d~MkD;+BFU`@~?-}Q#j%oJVexJ4zIz1iE{27S& zUN2NE+u6@I<0v6L_nkd7BOPuXGt;pi_eoE_8PJoe7jf6Y-c-ffi&G6C?v^u%mkd#BEnfHI%qp)iA2D#H}!nYA$gj=TSYSy{D-bYHtzMQsP!vM)d)4tFNSr9tb`2 zU^PppvxckAL zRE5MHQ4v)YakErYeL(zPQ>BO-nWl<}8);rZ^TSB9ARXKCO6YB+YVm65Wl$9pw_Tj7 zfw(!hQEk`W4ys1&HBs#&ZeR9OMZ~YZ5D|aQ7NMGOw5Ar->BxGN(q0;E33HmQi1=B4 zOviGFTOmd@fVlk}NHvzYeHlkJp16?{s3sCOauU^4?Mv64%H*tdz5M}aof$KdY-tEFHp@VZiNL@tB5<=)l_S=x0b3- zd-YUb5I5&~sz%~wX`mwIpt2CRG-3BeSWdYHu3VEaJAC zP4yu0M@v;f+$@z;bBP-{k7_Y-BbQJ`J44SjBI33)O^CS5!TgGLYwveN{9gZo_Jlc2 zGm65QFy?#i-M-g*H`Mj}@W1eM`#zTF{yq1K^8frKsiH#d6```wGv#Qa&N2xlzUN-i ze$JId8^7m%*M4vNYZS?LJul-kO!EVu)c-)w{vSEnsQ3rAv)@FIqlEU7C=%IO%og@~ zOZZjDe00qgwzJ|dl#LJ939x9~g9BKgcTPBnwLUlN;1Rr7i1)lx;N&@*Y& z5PD|YmRJFC+jXUCAa2fWRNHM&J}=%u)u_EDs)p@hyKPht{uZ{ILsdcCoRw5__ljhe zc~nspMjEq~bGDUB%>IRTHY(ogzq@SWs7!n1D51S1s?c5~s@lq4+us_fMhkW1BD7RT zE<-iit3|7HyVa;pd-doG-EKYFpuLSKrQ4-Zq$ACbv4-*=xz5Y4>TaWIB<}c{sG_RS zGu7zT&@&5Bq&@Q!=jA89oBe59u36bA?IEoL&1#c29J! z_Rd57ME1Jd9}&OZ`H1-KEvls>iEdYluF#QJqLDhw zC^TAo*ZdSq5Vt~-Dk6Sg#-Q=ivwJ-OO%5Z?6hwUQ7Id4gemlBTdiK2Bg%a9Jq8U2N zOmwf#avyp^drzWQbe31qLfx}P=rtYrI$Er=EJ1JSc5kEQy7~(Aj*fg6)#%7t6v+zi zzMz52y&)(Yl#rgi4@sh^f$O0A zU88Mi8?#K8XNTrzR`{7~*1^ZfH47F0%)TZaw-+ zM{YpG{pEt$h`!O@CPaL1Ga`QVZxQh;e20kdr4aGGG>Sy_uUX9h(Em8QGq?vcW;_2= z;C8l?-+?p-AyQyg$hTn*M#T48AmYEPl7Wc(TSRjRBL1H5P(=LSdACHwKjLMg!?uT8 z|KX^W%<|<+tk)VHv7Pg2zcX|s$`aYvYT4*0k$skaG$Q_%(*}|5Ce7bdnzrZ|-Irrg zj;wH|Tqk0vgY<5G9vz4Bwuk$rd{iuRy2rTfvApYQwo6k*#Emqis7&_S zUhB%Y$EFhh|GiM%LEQgcGMy?S?tgJHqZvv3NUEwaVPrMc^Tho>BQH?R*WLoEmx%kH z%3h|*yFP4}Pql}*Ih&~>?V0h2xH-*)?XgwF?b&LoM&kZ=lqRa28$vHel}ifEhU@qh zSgKyy>rFMl_T+y#8Aw&4y;7>7wkQ8{$}p;_+M7m|us!=f+EZ0%uafE!a=Mu~j$f9Q z-i={jDyg0#?thJWnrfl;7Ez6z7|zQ$s`11f?F6cc+M7f*+4c@*g(*~1wKt6_VS9GZ zs49p%OO;fQ*q$8Gqf~RXH;?Km+mr2{rkbz41ynEDo^1CrRWvEgX>QscYeC$lnL!m1 zx1PC~eIf1`l2m)9hF&vOMEs>Wjai60FBw#c+rr2sRYcrnYi_4U-2YOOq?$q8EHkO1 z>0vu_CnA1@yI6tv6{r%#UC)y8?}@No5!I>0jXaGip}i#44C4N;o0(J*@hc=zN^Vv5 z|7T94NN?|E2lqBR!aZ0v%G<$xfqg$D9~FoeU%_pL&-^o}vWVN4Y^ogX#i(jZ!ko2K9}u_QN~%@FZMT|g4ROb} zma0yB^;8>(+ioM(Cfk$a+f0?RJ^7x0nraJi$FP-ZyY0zi{|>4~D|x-rL=_Q#w55pn z%jgnB{Mo$}U8cRu(eSV@W(2xIdsiajFU^r?R5&7Y6(W9xt5KPbEJwtzHyRN?@)~r# z_Qsngw;;&hg5%DWbLAU6B-ioH_dbgq5Wu$$LcLySV zyXojoJ%+nbLT5>$NLM$%aQpj()z`?QXg(_Vg;{RDRBnH$*Dsv2M?}5RT7Lf&OYbsg8J;_ zoY_}=eNmbA%F$$lZA8TH_19>V_BNxm_P$3yXm1PJqrGMn zY0otAJh_SMnf<xBKw^~0ND3F01el2lQaj?6}RO`K164M7FET~~Cqjx0mO z&smOc(%#Kzvi7E+g!YnXhRD7nF%uF0O6p!TOIEkPpFA7g-^6*b&(a@256VdU8fXrB zs43ikK8z}4q+4MBI5t9=MhBwIeQd6rt3Y9=IXx8 zLselGQ;imCZxMP;=X@R2%9*f#BlaFzr@cBv{9n)1qc5aqujT8}2Ay*w`dZKDH)xaY z=VtV+?&o)?LATq6BI((`9WcN0n(J48>?Q*pA+l%pNR*|$Y?SjW+u7H*F+}{CZ-)wW zyRL}%GuRCgf3)3E582NC4O~xDs3VI|vB+M};^<=S4M8P3ODQUo_3SIR@?T>K;#NqC z#)j>ZRMEuHpM6+b1{gh_*ud%wvLa&}GdR&iaE+YQ=G7k~|{Ok!-C9=1w zYP3|3VHsK;_Qk9~@5o5|obO#!BR%_Utrl&To_$pN77_ob{T)haFO7bbBeJ*KpHQQ& z-h_S^+1tY(C<;ep%x|3C-{{%PJ&p$a#w_x`1e<}VM0=&EOnc=hp}pj9v5EJE-Xy9h zrM)z2l%D;2nI;rz&+J0G!s=!>_e8sq{Z>RK$`aYv#o36QU@ntK-=k2Q-MsSdCcm1| z7Uk^b+3%L~QEYdtn7CQuR1xW7E-I(j4iUdXdz34^6Q$Py5&wR{aftZsjz`3AmxqWS znZKJ~s0_0tsUqU{vm+vYyH2Q!Zg&DI&|X(&=@GU|QboicQBQP|jy!pH><;2qpH7t^ zZoMQ`R2+IHj!Ht$l%g`3#l6>s#_Z;L)+g%p{c99rLg8lBh=Z#lF*4i=y3J3+(UBnBTd7`JJ=d_YAhnKrMggm0P(e z6SdJ^TXf9tyk>4Gy<<^<_PU}%-L44rlHRFjGqN`-){${^-tQc({j9tnBL4oYKO+8! z&PT-G8ZSTtWIKD`GY}2dkryN4KRF+Qh@Z0r5kF@sDihgzw{kR2=Ct?T|3DL@XK$M~ zpoubzedaLk;zlZy*G-{OXZj#UK zn^5#Sm*36xJR3ECaIgOd$7g?s=SY+#vX@^r%K3vmv)82<%9Eb`{hoYO@CTQ}&OyxC z^$$KVlAiqXPLe7jUCf?RdOZ;Fv-CtKY42pzOMAUhUtRrdMErK=pknRC(Eypn{_4;` zRH7qGQJIb`M`NUCFO_Ri;*anwFNtdY2$yCpN{Q^_N*XnY>}9kK{qhHwrhO*96GhrH zd(a+w?mgpXp6fSrCf=3T=&ey!Gv|7w+;Xx}UNie*uLb!iY7VzQvzNF5y?etff9{Pn?30o5y@YL4+ev}BhxK+)McOm_5$WiDA@46l{A;HJ zD3YGtYx5_sUjF2WTFUQ~9)w!_$+`Zo976_bsl80p=1*pEzg&%SbYu+m(viJUAMN!; z#oCMi8B3h|r$2@yRdm6h{us;vG$iy)2_o+JOerdpo_&^AjuL-{V@RS%_RPM|ZuX&l zp=b89`hK>vmvkJJ?GKkxIZ8;+e$OL`D)xtSU5Tppb6)Ikc~$R^xny=l!v&?cvy~sZzxC z(o~W5%nn5SvHyaIzx;NhMqQx^MS3}yzqrKy;`(VnaY&-7zrrO}js7FD*UuNx(!av> zdKr3Kd&^Oc_G(e|S2%;_0N1($L8WN;fpF$WppgeSXBY5)te8=V_+z*V5q}w7jmi#$ zBPvG|ba$jJ|Q9@@) zqEy70u-|P;qbTD2kWXbK&!3{mv^&zAKTsYY4nha14n_y7TA&uH43wcd1RbI}6dkH+ ziCU^MQKsrJbeQUJbhxS&YNcw8TC0vgN2rcON2;<=mMR-%tByiPsg6cRtJKJs4>R5EFDhK7LVko9+huW#yqxPy?l&k81I;f6A$El7-$E)&Co+=;Zt2&~Ns!phr zsx#`W>VmqcPCzH9PDCfF3Q&QnE9$E1hPtV`qwcC6sE4X2>Zv*jouoP$ovbQEg{mS{ zr1~5Bo9YyFit1E!s_Ha!n(B0Py6OychN>6prRt4(tIkAcs?I`ZsrsNks=lbN>TGnj z>Kt^Asu&fk;wY{<7oDp*51ps#hx)1dqyDP%(fO(i&;_aiXn<-U8mPJuU8uSUU8EX> z2B`+4!K#bV#i}7_h^hpYs7g_(>JoH`>QZ#6YA7128it0cE<=~8E=QNEhNI!C5om)>LzrP>SlDaYBHLv znu4aNZb7%GZbi4MrlP5;X=s}2Hgucnc67Vy4s?fVI-0J!6Wyu03*DtkpoA)klB&DW z-Ku-gJ*pXKhH567sk#^4tGW-}r<#Rksb-_us{7IXst3>mst3`7syS$m>LK)y>S6S- zssdH0Dp95CpXi^ef1!V=9zl<&9z~C;9z&0*9!HO>=AyZ(d1#*M3G{^ON%W-ZDfE=; zY4o(}8T5?mS@f)`3RS79QMKyd=-;a6&~vKi(etVo&np#P{|L@%o5qxq@@Xo2d# z=)bD}L;t6G3B9Cx8NIA}1-+tr6}_rjh!(0Ap+&0K&}*vK(d(+kXt8PuTB3Rby`g#& zy{TG?ma3MaWvaK(TdKFw+p6VgxoQPkp?U|sqk0#;tExdYs#;X5dJny)dLO;7`T%{P zT8UPwK13g?K0+UMW3qHp>?V{RHv#(^{UU% zXR6Q9=c+Hz7pnDWz3NN!rRpp6m1+appxTHws=h{FtG+?ss5YTZs?BJ#>Ra@!>O1tE zDuq(2G)k+!N8hV{KtHIqpe?GcXshZ+^rPx0^pmOqHK?|sZK|Kq&#M2S|Ead4?W!GU zhw2yfi)ts@scJ-xswUK=`W5}E`VIZ2+J$zhcB9>@-_h@?KhPhlJ!p@r88xf+qP?m= z(Vwb)XrF37+OPTx{iQm94yYm&sZ9H*g=z0!d1s)OroDgXi~rI)%95UaWtxrhME3pU zd{n3-i%>6-eV?H>DmLx?r>k)knfCq@6>|_eNOdqeSk(fxP-UPD)gkB*)uHH6RZG-T zm5DM{hoQq%hoi$)txzjfYt&kG1Uf=>Bsx-+g|bxHC|h+DI!bjkI$G5RwNbU@e6|hB zM~SwaLHiqnNfc?%9D|MtBh9hxqtvlMX%rnBwlg`*nG<^Xs4Rz`ecfM<5+b((s?m|P zD5WFQDAJyZp;&wWzJ_Us+Ns*3_NrWztLlI{sE$L&sgCCwa(qx6l^w79iK;|)KdaF~ z9k~ca$A{xHdCZa*6i11?FlQ1~<+U#`r}IDGm}*p$7gnf6DUn?vjUt`JgZ>m$!DXLS^sjAb^X{yuF>8dl(8LD2Wm#R1FtvVB(sX7atrRszFsQRM5 zsWx*T1u8jgmm zMxYU@E6^3HE76szk=&|A2BlFnGCW$CQD{_nlrdMKt5jE`t5s#FOjVA`k^2V2Xf#@N z4Z21(28~f&i>?j(Vy;8iss4`st{RKRs>Y#ls(+w=sIEuXtHz`8stIU<>IUw6ZV0MI zH8+Gkt3|0BxRUhfe%7K$du9szGKG=$leBykiR}M!Vs1gVsBT5Ks;0J&222eah)Sk% z4EFa%OHrBh+-IgJp}i!knX3DOA{}X_u`koYcKN7kS{PZ4qG@3Ta~reV7F3I(+roC{ zc67Vy4s?fVI-0J!6Wyu03*DtkpoA)klB&DW-Ku-gJ*pXKhH567sk#^4tGW-}r<#Rk zsb-_us{7IXst3>mst3`7syS$m>LK)y>S6S-s)93E5mbw!ig2z?C8|{Y6a7>5FZ3_f zBj^#;qv%o9W9Tu}JqNp_f!IqnA~$ zpjT9{qE}T5(L&WCv`F@1gfp@1yrsAD|CZE73~Thv-AqN9ZHfDzr+q z8m(4+j6POQwcpUiBIJO!YbXT=fO|LbV>PSAB`T zRDFfMQf)vRR2$Jo)z|22)i>xH)h4t_wHa+zeT%+TeTTkNrBF(hMrqae=zG-<=m*sn zv_-WQZB_k{JWn={+9nH*x=vAUebmXIGt{lVVvfVuNl$$ES#J?qs=Z}sxg61AIffOe zM%H`nBvgw&FuDHkpjV<*CbzpeE~2*@eWFLS29b{D;e%OWE!rTL%D-e^Hlj^(d~b+0 zqm*c!D2;wHxks9|1~nj(=g##uMEvSMBjW#7_J3%*o{1f(QD)iNnmubmyJWlf|N zvJFK#(zIljmh4N~&VuS%>MUqW%P`AUv|W2UP$WG&r^!T_Tzd|mBFAtTI!tvqI$YHX zwNkZ4tyM>$BUDGCBUM=_OO?$nC!1?XcUdoqBGJWm^`p>Hs-w}-sy3*Nsx4}ZCfV(d zLC2s+td2#;vfi6kIjomM?<-LfMIyVpiP4Lpy|#x^q7JvnnMYBKS&Bub9V6S(D-$JA zjp!axEs8|{u_N1~_Q>s7F3Qz?>A)-<=-K^DqAJm9*{&MZi0taMC?&FcmPY$TcD?;5 z>cH(`yIt=%?xl{SXOAe35+b{L64e|RR?pb-Qm6DKz)XESnJNU%?3*wF_gDQ)3F^AsD-$hbI#P3-KI#lOu ziHJYGOjN9M#?iSQbNiU?%wo<%#GkW%s6X)H}u9rmj=`6Dl z@hi+m6*@~LdO}A&iHJYDPa)#Bdm0gcUY(LiF zay=q`&%Q*&ANyBmgKoDG5kK;4MEuBa5b?cDi1^hvqi=QOcPORTi8P9&XZOr>;=$3(&$&+=JQQRau0Hf3$xM5&ydfuOs50$t^}CW){nHh9!vj z?cPA-DD$g4=X(>qrL(+^$dTqhvfXmDLbrPd5kKdElzpp+;d6^m>yjs}SAECbOXk)35Q8m`-oKxNu1M`J|? zPi4+=Xrkx@(IhlgG*C1RB}C&zNi<9JuxK`_5ZQgHL~}*2NpBvi64^bgM)O7MrMCbr z6zvf$LQ6&Mr=ex2Ms&8Q7OfJMi&mpL(F{>N+8}ySv=OC5_Sn;?LG-cowxLGR4p9@@ zBg(oBHKRy$ipX^3^+Q)=&viD+5!vI5p>`s>LVJ`evd7l}9VfEq?0A$XvS%qDbrjk2 z(g}4I*`w`(P7v8MaUv=Z*?sAXx{2&%)E)H@*}d+GP7>Mkc`_;#*|SuH{wA`Q<|*h@ zkv&VNq0>e7?4E&oiR@YGjm{L=vvd~fBeIu!Uv##}p80c7vB;jKI7*1@u_sX^vPWdP z(d&lnF(gqWvggHgr`H|X^O8i7$etI|gB5xpyPt7XCbCCcjuIk!d`VO#vin($YDD%d z)uNQh9(x)^B71zMC+dmZE$$@dJPEn|M3KndHczH^GP2i#B#K1#*i9k(Sr`;Y36Whr zi6ZTpBCdl)+`nAaLau{oPZ9STXNa0nq&@RD^tUk5oWf)HDdB!8108Zoc`rp$-PuI$~z^zs>?^j-}7}mCAS6Pw`g9>G#OMaNl!DC zSu&~eNGJ0=%BSi~+{i9e1;mZ)O4Unyy{YT<-sr4T!sA09bheH>2ND0+QH+Q`6LEA2N9FG4FGZxl z43Yc!p{PuIhB{?OisoQh86v16OnY3MZ7>F9LT8LWN=&qEHool7i^ z2AmPr8;DBI2xp=cl}Yae8Ci}dpTVo^!J;W=MeZqPJ(G>07f^OB`RCI^-rlW-RlBiC4FUsom zsIiY8AKKT4OY;-y?MK9)!M{+XJ=2%#L|@*K_(Mikqks2h&J*uM&!Gi<+0UA;=)Zk) z6EB5ck}4wpyu6GSg`Rm0Ez#}XV7q0Zm!wL(9kxqSMZ~YS9Iepp-eKgsp_inJNJn## z>{$&Wg=V~{7S;6)mr6Zq>>G}@3GMD1&cyG1xs3YyYY*Qo$d&lBWX{fwh`-zqLW9l@ zvkXSj*=JSqFq7Ui82M&_f=IF|R3$b5AiJ$?7eNa}%VaDdG}KQboicUj{wmdsI=L_VQ7e;&4PKpzg)t z7&Q5&l98v$?V%dg6mwtDY%f3bf%MwmMQ6D&-o1_iR&e)BI5UK6C!@Q&5ZmuY?q{pNH?=f z_T@W7{KyoF!bt95c+L>#a_Ep?WG1SJb0+$UDp8H}ZWh&|4cgm?Qqp@udTG=ssueY% zUv<0R5GgPX(%XghXs;PXq9c>^%(={YZdg5ywwz1vbm?tH4Wdz^ZKzRX_qqw~63vj_ zZnQ`Af1+j->73>~j`loypGhwhwGuUpTB9sc-rXo0wGmw)YKwA2H;7^=S7c}DfC@y9 zO0O#_K95U!xhRhMNiQYpkA{j4y$20L!*!MsXryj83YAIkBpF$b#^@~9qOqc3(i?}y z>nsz{M3LR=Nocajt~Uiu72P4*O+$Bx>2~IP);m9}kVIAI>zt@YWRIv8r9|h*3Tf0Jx=yqWHHz%{Y(kO9Uhd`s zbU|3b3_t@^1Gz2@3?s8q8C~s?PQfnt7P1r%GAL3TdiFtFH8#s3NNp#E-J%)(}38K($nLR?o;RR4J>KC{5L9 zwH-B4MOJpN+o8O$FD4&#*5m7f3PeZW%X(cAaYtmjA>wZx-4XHUr3Yt%6c{<$BvnNG z$e#2_fsvz4QbksBeC8y2q`=7WC8;7SIX-hTJyKxg_>xqSl^mZbq(=&j9AAi_V#x% zJyKxyOOGnD>Ubks>sTo&*AilKc7^Ql|7#$&=tD+m5d~A&yrM;m0a#- zBqIKDAH_)G_me8JvbUqF=#c^=d!3|;tn9V?YI?+7%ac@*m0Sx<89h>9WUrG{QH@Bh z1*R57y1KcL`;ZI6z9doAg^aY9ST(8<+56U7RHwaq^o2;S1!g@;=`3l~C_Q`4YC?NN z_UtyJNJpBBxDUCA=SwwmX=bCmix_Di)$&o{MS4$*ilz6njEti}+8c}_>DfnLGl*FR z1?8h?P?*IGX1&4m?Bhc=Y9q2o+ZN@D>|S?3c_Mo`|?w3=xptsgCgl= z+7*WKJaTAIUsOCa%o0b5p<%rwinM2jasN3itPn%R!`QB;>}MP$r1#G6D2Zl>a`vN{ zs6u2{uS7MnoqZR)7DYOzxr|vZ3v(t>&1GRrGXxy*O2rBO|3}Vkl4N%)dN0i@1HsriyyW$n05M z>w2SN(cSjEpfc^1qey2l!+HHMJRCy?BK|BLf{4E~4@E79^L+k7IiHz`v@_jgy~7YW z)?6pO!x8aksTCrAyVi*K?T$diuXiNM()F?t@!K7Rh#z@0YNI3DqMYHpPjsH_OAHY| zvK=Df8i{gsKRciT-OsM5hmP!tP7>MUI~f(~c17q^-LunBFI}%UI#Xvk3-!?z`l4dp zE{+E1$bo2(t~VH!=$xhK5?%dLG+cWl&`9l#LS;HjIT|Cq%jCRVi-^Beu0vyWmT_pJ z_9mgJx}Vd~o!Yw#5q~}t=x$x_9yCKo&P20xtsM-lOt z!()i}Th-&}3Ej^p(Nj9h(`dfVvH-oKy?0TKZdZ#|>B!Y+jrP`}Po-z?&(}Dw12O8IjwHxFc#!m1QMIn@tsEg`UYq$BMeh=Y}~bcSN`xI-ujE zXYbvPM|mRq*pZJ;(2*yi0_lCUn_0S!$Zbj7J#;2jD=S&OHC3cN(+v%lk@nvE;t{!J zC1JaAs>sUrO3_eVZy353>jZ!*G8bvzNT+QSA)nS%O6kQ$G zGi9hu_qv?xbvd`x@p7-2jgBf0pMW2Ya>~Q|>@k#Ao||LtmXZ18xsqeeJaiCME2}kz z-@m5HvU-(XHq~(Lji4H-y-`$GY42((iTj>o8I`1+X=utc-bIhNeMwS9#P4+i5kE4C9+qQ2U5>T_ zk(12&(@`a}5PxY>MZ{kY|D-opdsLB?y=>>9XLRJVs7lX#HTq6^_F9)h#IKh|Kj_FU zi1^EBDC;y7?WEw!d^t& zzL-DJKHY9VBK{cuLd1_ez%dXvOOh%g{`ew9{C4L5dAjpBspmg{;~$k;UAuH`?J?86 z9oi*y(T<}7u}e`DyD3Vk-HxB`X1XI(iX!Yf3sDJ0nCVi^T`EN}Ls8DwSrp&j>;0MU z=l#d~@%TO;@6Y``<~!e+*&W7lHY}rq=`L542=az;&DtX0FE_(^uQr@Bz<$%I2C6xn zE1&%?RV_sLpRuQ)I>Y^|x2cO#hjU$;Cii@4sNrz8ZyTY;qUExJO%UNfhx_i98G%OdYQI(5G$YYSzQGW;x|1uzoviK|i^$!Lriq%1 zrlYXvQc(oGs3q5;&6;}yZMl8O<>QqqOGY_gL>&`f#w#D z=JS5Z4f-dA&-=*{e!Y9pQkOFiBErutLqxKkS&j(5hlkK(TJmv3_$8k}<(hjEy*`@R z9+q)#Lf>ibC_1LO?~$iDa~HZRBgZtpj~SUU9M@yGpRnJN2%`Kk?u-i|qPgiPzs$H4 zT{(ucqo>rn3K4$pYE+=PLNrj?ERd3e&@E%Q(-|qc6%}d8Vl-k5`{6CQdm4#GOUakz zyOVdJk}=HPp6{h-!Whn+1=7kyG-(Xii6=#q(KH#g{T}CZ6duEV|4?!fzJoR8&4TBS0ie2HEmfYsod}MJ>4&trMO3 z0QJ_RO`6+`qFOIDHsk1@E_aMA?}Y2YoJ5|MG$mX=OWazgpgJY&Y5N`4x`^dofp`t4rN=sUIrP zl7*;9da&1}Vl=GO9a+QC2(32~m6UR1{U%3NDVkKutM(A-eKML>>egjC3YWT9rwH;y z$=8l?su1Dt_g}|lwE9i-Rc?xD z%~l5@`5M=etz4Uv^EQty?}W>lljxMRDeunAY2=M_*B&#TcMRic^FR5&b4^i(C@URh zqJU_>8L*cz1O(6DT>s?S}}8PH@L= z40#jW9y1d;t0uZNOGD>OWX*m&pZ*%4hLT%aogNw?g1>Yw{WV5SG?$J7Qtvj&1yOU+ zu=c10YB`Z3tFSFC%ALr$Y46tZP?w2Z zHD0bqn_W>4DLGAgzZm6?q=?HGr#+!UK$GA&ADQKR~JNt|9-kT zBK*JX+yW8Y6Un_>OGGp_>#E88P@B71CHqUkwutcmic=11C$d+t_Nc?%9A}>opp}j& z|88bv?*~K3yIaRO2~AQ>rsQO&5GoMaJI6v)G1*fX@7{$QXdGdzg&EIk_E8#YFpXo^{wk#* zY9z9+%#9Jz%q)`Yd=o_YdovvcqzC)`-5_c?jd#$6(t9gJ_@i!(2>-XTZ4lv?Y>Nng zJ#$dIXP7 z*r&@86rI5w?6>}7XuIV8mO1P|yJomEd^h@F26NAo3R>Lf|;&fA?hzVdz24Ai)Xrhy9BM6$$qf^m3SpuHPgNOd<>P% zWYqRmrW~!)R@S2(Gu^)3i3tC{7QTxJzs+5U@b~R*bWrOZLU9?3J>L_E@Q;wM(P1rl z1bwF^k0QeF;TZC?qzQ92h8fEsS(j?4PMA4N5!FTYMGuKmQ4`SyQ98<%dLN1M(1nuw zMRXCmQB-?58ia!CW~rJy>qS@{|d zqq>O5Fac4$*%|pn1JfOa*a{QLk`cB%t81iWeMI>6QW24?XVMVi_jfjGA|?Abq+U8A zvdu_orRnU9Xr?O}W6R6ZTsFNga=8dwQ6gC{#uk4c%=KXA@C$@F{*}+1fe8QVRt*sW zGhh0vj%taX6{Vm$a~PR@?Wl`V=eSqAG!&S_F=#(?3nGGNGjhk-9JLVHD?`gU8DS#R z%qACMtBh!BcF;pPTTz>ndNH;MW(C{GQ9yMX5_U&N3yJwv=x`FJ-UDFSsIXc^O(V6A?{K4=I_2h^A(MC>sT(-WXAH zL}Z$KL@j8OXlkBC5w^Ux+DZ;;CwfOpwnrt}UnwH|+&EexnwtI63R~U;EjbZQTFUEf zm9#P$P0`#`G)=~G&PsCA(G1PaL}6*OljI_3mZ*d<9t}g{(Dqf388{z93l28aUm)a z#UxjZN|w3(UW&q!v+p7!$Xn*F6J|Mg3Cp?ec93VykH|PtSd=e{pfZtdvmC9~l50>@WZR4(Pjs8q zGb?Cih08^dCz@w-E9rfuQwVJo*&bd+p61LWwDO2689|;X*~6pgQCH8bqRmxkjr0&g zp2&{OJVq;zIYp2svhy>KlY89Ni=e2;u0#xZBD+fF33_Us3M>IL+I>P7UTYAsr;dI`OxdKtZ}T8GxD)}!^R4QPYv74(W~ zBig8X6}_r@4ZWs%9lfsFgf^)*qs^SD_MXqYf!yuC&^YwxheOcAy<-vDHqr6TM>f zE_xU3v)YArsdl5?=%mfPhu%}YkKRWat7I%6pbt<_s}DH_KSV=CG31Hn+T2IzBb1!O z$LM3U+U6=y1$y7A5>=vPo1dUh(9bsaDf$#8=e`H+L1~Z4SoWg5C^`3iXdgifb-`j*IMUtI!FNox@4w zi5`$v%-5{T*KY19C?K-y5=3pkrlcKb4$2kTdU>d`XoIwJ0qP>MJ#y}p(2r; zQ86mfdZlQB)|-eXX>KwK>sTUavE=M3EkVmfb|sdh6`~W;-%3;_viqnUtrOW%uSc6S zw;4r6c4RTMOJvtwcX9x72>WP3=UqaxepG34nu%{Q#)H{|R}q@Yxht(S(HXf7QE zM0N#(sIADhnS*jgcIER>z9_k#C@ivVM$j^mox^gpLS%biiOMurjw-ZXCE72t^E-gz znoFSLB0H8UId|L z>PPgW>L>J*>Sy$`>KF8j>Nq;CszO!VCnx_B$FJyD)ond80q9^&T=1rm)^0cJ+8~yF-nSanfs#CnLI^}c* zBF;AprIl)kXldRQRX@eA_C;T!2wNWEkEI4W^AvaRri_v`QLR&MWGRTqHW`vT3laXP zYoj_+ubbrRq6S*BA!;HTAh~qZRO>ZE8K<}kkC$8~BO}_Gl_5YgCvruwx;LZ2 z+U5{+igxiBM{-Q=SXy?=v`@L6e2Rr5z%Nw_#?Xu zmFOHwnP0@U8DYztBPH#ApNr;c5AzY>@394l@Oxi~?$LVpqD4CQ`_MAigISJNXvvjm zm5%H&^t6oHo)6EU)snME-5T_~hD3Lq{V{CbvGi%X0ZF4;$ z{5fnugulmLK}5FkWCb@O!Y}zMBK(zj4H5o0Uq^(025drv-{xlYhL(I2ZIM>&mEkS) zww{~sAi{4YiU_}z7}}}1cM;*YvI`M@z1`@&Q#?(}dXhQ3k3P{g`xFs=$vuehXS5gX z*Kr;|gn!le939mCa0tbv-lft*0v(om_UJi+2!CYXBEs+QJ9JcY$I$mWzaJ3cxAG$* z{A2eg^s|oa7xb&P@*DbHMrMzmKM>*f_a{1`J)A`UXuVU2@b}+o^slsHujT(C!ha3$ z5aAzn<}}y&(>${(lDVg#I-*&kx+wKD&*YvKrJ=xSJ`vj{3ZmT8jO?f=4^2AF{Y1)B zXfg_Gn-R1{b8n%j_7Fq6wB&AdRCC9Wr}fOg^!Km(Msgl165TG}PcBAb%|(#+FLSrg zsLg-qKlhy0cwC`9J~gp_<2r;c_gEKu=WzwP)1##Q-r6V>_P9r}pVdXYjPM?hr{_7$ zFT$2bU)Dg~ve z&O&FYYNOh!I;f7SE~=}lhw7>7qxz~;l&VTYX{xi)*{XBUIjRPzfvO>DsA_~7sT!lk zswSw3Dm~LHOLr)0$RVXErzKt2iHmXj5y~%4QT5*;Zo6YwcPwZBQFkThvySgK|{u zP&-w7)LzvAbx?Ig9aZO|b5)%(`8$bD5meSG)Bny_Ir21Ta#5};Y4T8>TP1TII!|>z zI$zZpbyi(~E>K;FE>vBFE>d;L^p1CNszP2D*6gc|tf%RUy6RZEG3stk<;d%n*}%*! z^Ff+)W`t=v6Gjye?X?&?KQnU*X^p5{ys)WelD7o&?+m!L~jJ?XEfQxFyP z%yj?p2zjF9e^JUu`KnMRf8og~1?7wEe@Y6WvXE=D9C?~Em!eBuNpl&xOm%st*XMGl zzNqMOj!65zn2J$Ya&{~cRHh}%QB+IDkf%9w1@pVYwHZW3BHKzaD$`s!@~&{BHoY>v z)LyRlG!*XTT8SX9mzL~}daL@NKB~T`uj)#4rRpklmFjAAwW=TLr@97Rqq-Jdt13VR zszOw#x(;2Zx*lDx>W})X2A~0|8_*4^8_|ubfoPy=5E`Vq3EiZ+8QrWJj0UTQpdqST z&@HN4(XFZ?RHQ0K#j2rbsOmO!n`#&urW%fht8PcPtL{K|s79a>s*z}<>P~d0Y7`o! z8jVJ)?m~B|#-K5(v1qKS1eK^tQK@Pi$LBbwaugrOF?iccTu~Cp)0`R4Q9j<43?gql z$LJAyhEouEGh9hC6U|hGIj6%;#V8(j^%97G zkdSGH%Bww31^AYUTR}66lCzu=$eZO_F|*NZ)f~n$$0>-sIj)|Wo9XqK>v9*P{JELg z=3aZ3fV{bk<))jZ_jz1H<~c=Bbe^jhL*6`B&&)^jRSPn`f(1^6D7-*hL1mgNN8SS0 zrdi0kEaWJ7N51V-jJ$=6dZ&Fox(D5(x)bb7ibUgEp<+~~xpL%bJ@Ytv+_hq!;4be8rzNQD3D;&hii&KTG305^lyUTw zxsnl7R_4Z8j^dKD^%97GCgP8ie-h3ijnuct7l$dm0oa)pt2XJw@8k`aun5E48=vZhXnFoaBZ3w(Tl3J zw7J$Pf}(4gQH>i}i5QBn)mD%vvU4ymp_f!IQ}1P`7>d8_T1g=9W!HmQ$I7pBvkju6 zb*^MF3TrNc7O!KEHEYEDmY^~%S&qDQ+U9z+UbTT9HaG=Q*#<^tU!BU4r#bTqdc~DA z8_`D9tLRnLYv?uA>*#gWCbUVl8Esa*LGN!km80kzZtgMUy}`(m|C9Ai^rmVH+M;?3 zy`_2^y{&o&y`zevs4B)v#GHaCKjwN5Aur}eX11cOs%_NU<`hKUHdoJVC%0X5D1W=l zg;03A>o0;lEopY39j>0)$x7^WilO*Uj)LT!0P-|v-lffVUC9XY-lf0f9mFoQ%jL{& z)_S*7Ir4V9v6%Ph;XS7W^4@bL&HLzm)d%PU)raUq)ko+f)yL>#RRyY0RiaAOC+HK^ zr|47F9<)cb7wuK;L;F;pq0dzN(SFqdbU^hv`dsw|`a*RO9aJ4chg4spFI8WmuT*gq zS0zwF^)>oh^$q$)br>C19YIG_-=c3--=XhRN6}H$F?3AzJ^Eht1NuSrBl=PG6Z%Q@ zGx}Ne3;IQM9359xp(@p{=vUQm=r`5x=y%m0=nvJO=ug#O=r7d?bV79!omBme{#N~i z{!yJmr&OoWY1O~zU)6u;Kb40(6+aB{OqP32bOt&@RSi{BRY%oTHBb%JndnSaO;l4= z3)NDkpcK_v=qy!jR9jUC)lt<&byf9HJym^FUzLhdRcR4-Y2&PC^{I#I8aQ#p!@?9-S8@;bTRO)j}yrv&nHDQWLbO&-e2a!**#L+7c^ zN9U_Lqt2=e&;_as(S@pu&_${)sEevA>Z-D#z}wu17zyH-LdthoqU+?`{|K1p1H$~0GwqFOJ8Jk6ONsE6ypT#PPOU4kxA z^`yU^P7$=YryI)>TwRb7TIQ(cZOS6zXwQ1wE+RJ|EXZ>I<< z>rH?5zt5MWxaJbb>&-EdyszuSdiHVkf+)X_%Y{%;AC6Xg=Ux%L<7eYmnv-OHmSY*%s2=cU^xt4m@x>iES6D9jApuYl_3!-p=>pg-z z&6z?*U8ti*ULkuW`BddPa@RSPBkwv_(p--v(Yg}X z=6Vz@aV2A@LUWbqc!}$w3VE6{r5x9#v|?8xg1l1KgBgd$sm7!6stIUTYzmY7&~Hnv5o^rl2XRsc5Qd8k(k>j;5<-pc$%}Xr?NR!m0?0sAi#As@Z6^Y7UyC znv3SD=An72`Dngs0a~D1h!(2uLHDTcMfa)}p+&0u(0!`=(fz6i&;zQ)Xt8PuTB2Ht zmZ}~^52}`-Wvb<9x#}VGkm_OduxbTbp<0Pnsvbd)s2)X+s#c*@s>je{s>jjeswdDB zsxnljDo5q2C()Ctr_fWXr_s}@XV5dMXVJ5&=g@Pi)o8V94O*jm9zCym0llDlkt^Se zP6_0_=*~E^7OmynwEs=qyo6p-y^LN~t>Zee&MAb7)@8Lf58lGHpcr|YGwacMdiYH4 zsLcknLG=oHMYR!aRJ}@@uR0YY?^QQy^BTF=oQhHSHA+797T3rKifS%~;;(Vls5OM% z6UdX2$+5hSUU#jSP1M`u6hrY%u4Dpvnlqcx<}ClyYV!tqL-i(lQ?&(cQN4xUQoW7d zR=vY|zT*@^(RXw#$kUvOGWVz}S&Yh}u4Fk{7j@Uj^@xA-?8X^GF=mv!mTzT#Tb&Zf z+v-{|+t4=EcC=l!1MN`lL_1aQqIXrh&@R<(v|IHadQbH}dSCSc`aty|uOuHj1yTNo zjN0z$5Zd&io8M*>{m{)lhT@t_AWvH{AJOJVZr=ux_mOMGe2hL;RZy}*dq?>dE*C;Y z6>coW$kUvu%<@tzUA;6Eu4H8PxpxFDuB4v5-(7+>X>K!$R&tM0DR(n5$4 zc{fq-Rz?;?1$*7uUx>ncDQVx?L{ORL%29kT_h-3d<@`g(r4{=ws|tBq((Geo`&^sF zsBE8G!E#igxk?n@=UPc1Pjluo^qFhL>__`m2hai4=je0Q7w8MsL3B`c2pv*=iM~{Q zg}ze7QCyWk3Dwu=Yt=W*;Txv}I{pp&eUZHTtU}&5ZavLmbl8N3k$1$cnfaC#{MM-)dEdH{<~#JA>L@FJ)TtPGM_oO0jNCD&V&oljy_@gR z_o^Sz52_#0kE);0PpY3eUVe6pAn#|_gZTyhqB@R_tEy0y>R0ru>NoV8>UUQ6cc%pM zey6|WRsRppjz642s7Pe5D8(qQxdih5aIKg>S8rw}SS;p!D5PjluZI_XN9ztP{Sf6zawQ|OfHG&-&N7yYaH5B;a|kf$=)Jk`wB zr<&*tRW(#iRUK7V)j%~=XQDGzHBn7fEmTXDf>Kmxp|e!AQEgQnR7X`8)m7C)^;Gpy zeN`$-Ri&Xc)!FE5)j8-KRRh#O)etpQHA0P4jZtG&6VybNj?z_4QBzej)J&CuGE|u; zQ3uKa+!URP9hZReRK4)d6)-bwnLi=c02}olqxLF3MHq zp*+=j=seZ==zLXY)LC@_xtNNjSs%y|Ss%z1;ssdD? zDnx~<>(F(o>(TY9{;0od02-jW0o|aw5#6X7hz6<#p+Tyf&`qkF(aoyCXs~Js8lt)d z-J-e`-Kr`=MXF*{tQv}js%}HKsfM9ps^Mt3>UMOy>JD^=Y6Kdg8i_`#?nHO0Mxjxv z(P*^lE_9b_3>u>vi^i%-P>HG(m8!;}ajNlXylMiPpqhv#s{V)mr@9;6t(t@;sV1Yz zswrrSYATwlnueySrlaYq8EA%TCYq@Vqp&K1BC1(vmTESdt(t@8sOF-%s(EOhYCf8; zT7VX)7NUi!d(b_qd(pkBMQD-gK6IbzessU;0rY@sFPhsZ z>M8V;>S^?}>KXKm>RI%x>bY#M3zADm!dM!TDglYN73iF&Ns35v?#8*1oEEK zR#uZ+?OI77Z?)#upf%h6&h+b5!MQc?r@oN8)Qx)=F;#J0ecgwt- z?WMl#l!gK?yOKc^e%XyBg37dHIf`p8fjq5e)=_VrYbAmfuj6&hzV}^%$~0GwymhVz zvmUKiZ9p4Tub@{{8_`D9tLRnLYv?uA>*#gWCbUVlnb~f33L6(cX|dN(l?Q*C8rTb+U^e`|JobEbT{6GBB>-FgwF~W1?MAy* z@1gfp@1yrsAD|CZAEFOcAEA#_AES>|6{td0i7Hi}pifkvqEA(OScyIEhzz3m9=Ey) z{N_kG@y^fPPT@h<;T4gnm-}jDA-Af__mQN5@rFs7m!K`c?HC`c3sa`d#%0`a|_6 z=h&Z4Ar$|Ul}JA6MxN%(U##a}u4Fm#{^H0=J`Xv;xp~5=7)?6CYrym4IBzGTu;wDD zOk|&xlq2tiYtx)WCslu=zg7QmB>&?SL`DBFPWw)=7?u3PzO|nlmZI=KT-i(H6)1u{ zt!GZr`zhB-If|Zg^&k*)_K=rN5PEm5e&;A@mq}yn3LqIkX5pfyy*jj#f(#&E#|RH7F`23kISXs*qf%rR{J%k)K%UflV<;s}jetjFn;9kO3`8_H)yAM|H3DIxsabm$f7y~PkH|DNo@PeX z$q{~zEsyY9sX>nLb8LCFw9OQh?{X$Y$%xBE*zz9J+~a7I%bCrTB>2BVV;KuuUQ}}5 z$v9(Zo7US-NrL}hyq=Px9W?@3=3AKqTj8CqWP~k`2$*f_XysjUgg&DBMO-)22T_-)olsoG{5%F*LHMthpD_>t!P&06j=_Kb$AsSSZGbbWv z+)b!R+O*fYViXqH^DlxHOPlY@Zz`4`Pun!L*tfN~f^~hKTuQA#K9M{(Lu`4ewcJ%I z4Fzh^`*t}Bf~c+3t0ukYpj;_w$C-z^)RJqRl$H-p}6$0M6RC+ zbi5Yl*q3q?RG|~KIIA|u98MxnM)s7J9?meY2~22j)hRTj$8Q%@+6mh^{va?>yle1-yBOpbwqX!bx~?v*Jc_DNY3uR zAnH<=R_v=^>zp$3K0#= zH=?T%k#7DJ^+SZ;-!+KvbJrrGv8neQB?}PI&a@O2BBG7yC%O(1>1Mp>dPD@xy`uhz zXk%Uw4M0RwQz^Ou5iQO4q8kwrG<8>_frtp2j-o+`@K^9AL}ZwLlDioZS!RT2Fe3bw zAA*Q>X0GIJK|~w#lIT`MG&g%hMTqd{UW}&6n*AZU=_p)}*S}h8Py{WIIdqoKk{6=I z_1w9#1QC92DI&Npc!ZJBpYh8~mN?fLLHBK+g}2}CqBy`^3m zBK(r&Xtm^e$hB?_S|`WLWpdqJk2Z-~%Qb5=+EUNmC%=UV|E}_FMEKtrd=tt2S z$^C>*Y3?-gL9|uyRVCgY;#cR)kF2`Gl#!Lsfh4% zY3S_w?A!C@%6AST{FQHj8fmUEYNGYh5z)k?Jx`lW5#iTshBCBXCL;X1`z(|#t=M-A z0Yv!6Sr8Ha9GWAdq4{3k^|V0eYMY%Q2jtE(ag+Ponq413Rtye?m@OZ!td`= zMEEOl8M<8dn0@WI0`<|7eG$>vTq-MgB_jOSqpJ|%|HqZ95#isT^+N@E6cnPc$leo0 z&|+!RzJ@PBWtuBTPwL2?LWF-dK8*-})X$*R(x1I!S%cQq=PVv5<6Mt6${rgaEBGpU zT~~e+A~MYFvSyo6v_4m&$x<(dwrUUC5RqI@vmNcyb=i$Rmfr2V&k97On|!(Jszm!` z)Gy1m=K%U#SN97<`1|1?ifexfbWC&KBf>u-e?UL#9DYJSYsp^_;g97wBK)JZ3Z2lg zoJ6N&k3BB4J&g!|J^w|7KeGQ2;paR=_}562%Dc~0j?X18F~1s!@b7KTL_}k=;9+t# zQLR+BE-5HAm9xs;ji#X{skE7VmVg4=}t7Zs$s{a1(v zqy{>f^)jOyP*JLTl_^FeQaSrqa6Xuk=+0EG-bdxWXA~+)b;o=unj~7gjND`t7X2wl zNCbJ(ioL%!Y22Hp(MpeT$tN(b#-fO38L8H;vWZDE0CX z;kR-g%GXvxXsqT+5aHJ=MMMYlmCSt{+M@N|La{Vf>8uyXZAIHPw*wJ=ZYTO!Td6>V zKbA^F__O^45m}~{wD~FeLURYvA+7f%ifb-`2*1s*(NUelG4!M6enNylvY*jknmd7r z3^P#H>?HC;gJnI<*&LB)yK&Y)gx}wpsOH&jm1-fvU%?ba`13mp5&nACMs>86x`^;g z)Je9bLD_h{~3v`BOJq5IEqYxV$Ie2!b)C1|PEdk{UO^&UnmG`A8x zuDK^rx#pfk8?=X4&_*r!Dtb+GucJ-pxU0=(^q!V{AAP88euO?gC(zNnDd$lIBK%{b z5`C)m_Mp$S-hM>**RuoYgyv2nPjjXL=U)TYW-Ubc`#q&WAY8YBzsDkMdG$0`AEj!& zG}NJiTiuQ*S4-w0!XNc{i10^tKDxm5U@k<2e|@7={|6C%@23#q&*3!kw4`as>vTi*^!sHT*VT~MkbCIYM5a+7 z+^vzX2wPtFM!fU>R&qVaU9LH{Ji^aifqFIK{^e46{p*c3yOQP&v_*17l6wn9H5WtM z8!?s{lG}kQG*^i}m7E>t9z^&(>_z*uoT%*6p&nx$50Tp)K*%d*6D7xZBSdy<)B>x} zlDkob%*ei{tVH`|oc8m811O&EK1WC(!e5uK5#j$wwQmsN=ME#nU$Z0VsOFBL2h*^6X*>S|Ppv zyA-W#8VD1~XHOBfyhn6Kk0QcO*4+3X6U&B+Dt)Bnz3)2U5(OFKy-OO6h!&Weru15o#aV#B_dV|p5W*h|ru0=!8EmHEaLUb!C(p)hr(Xo`Gu=Wr^p7w4s z0$#HWHS&0Z+9?{&qBrB1N2)~Cs zN)p+oKdT#I%d@&jN}flK2$+xQFT$2*wN83J9}&rVrZXi8f1GT2M7FW5TtJRU?xP4> z9uY9Ml?%xcS>{3Mfi16#YsGXWM8?PzaCp2;pe)uC1HZ>=^$JAR!?4+ z!XFf-q~=TyTDe3=#+FC;t@I>E_%mY5Bm5cVlOy~!W6QI$M^*?GNh_K3XNofdsYG(E z)7Z) zjylGcr#Ul%l0>$7kX9n1k*;KfEsyYz&pXi}mor}?BHN_PG4U1k2tK9Z_~ai}afhoV zm5`)P7SvOoR} z0M8?JoE{?l`I$`a^)ub|x*8(45@5anlb_mpBkNrA%k~3+jAe(!lS)xKjWSSMC>!?TYJ`P3L;wLdKcReDSn|)HUKO%zWFVO%rP}>|t zD}>()Tm0;WRcgGJk~bm3@BL;<4%Tt9#m`>oFHcGiL4;rO7D^Jy`9;`@63JYQEq)T? z#(66xiR8LO*y0B=u9YG*)aA@=Xqe`PqY;`LiSE?djzXighr3WoHdmB>GR{)^Bifo# zD8g15kztmia<-y`KTfvz!3t}(R_cvIL~@*FJeuH2nu)YYB=<^$Eq;{ZN=`zAzb=!} zRJYb<8VYO42=cU~32?Lqxa&GYKG6&Y0)1KpXr%-1$NI8$HIe+k>iyXA+Pa*{LAfIP zZM!_wCBTtvzXQ@0Nt@8kv}!hZ)g z1Qkh}_Fl0Vg+8M#I&kM?^<*wN@N6l3&PzzN{)Kb+7wNkZ4tyOJM8&zA> zR+Yn5F$dW@{}eAmCwgQ=+Yck!d`7ILxkVT<%q~Ie@ne9P_G=$fJIMIuQ%$W_4=X$ z%@v~l+R6YlSX&u_inLxa8lf{9iAtoN-S4F+oWu7g8oosD5wuuld%0){dQwY1g`So+ z?G^eNv|86?4cee3UqKtSL=%~!yzE?Yjew6+^c|H0G9hZ9ce6K>k zOFerH`2(GhvDhakC(++h(!T%u2c43!*e468k*6~*P7 z`nTL|<9r&e*4!GjN!#3vqV0G}^|9P}#LzAs=WbLXCGB;$67AP|2T)wcnLtO| zah%z20v$ugwPY1Kq3d}Pc~a6o4>9f85AEHW1yOE$U3pZ{-mQEgDr(O-JIM8=7?nuQ z?uSwo){+ra)}DI?`yHNgv{7@fqD}4HalILB(cD|;?e-i?%^s$QcM#!UHKK^{kB}Jh z+PkZ%=|HZ7D;YyyiR{SYh-hRQ%cv8`(~_nmSK5wlCF-Koj;@C^6zE7R_8W>pl-rU0 zZoi9~hw?kJr}xR84xxgM?m2BCD(dJ~uo#s{D>LNPsT74fGL}c}l@BeJoPFN21eJ;G zD`h!aCq3Awe(TXD8K-@<*o@xPac)6`f9-h-MKu>g71E}?7F42r9of_N_3twjml@fo zR0(uka`x4?3V9vfxnj=ceZaZQujy#!Pz@1HO_iuRN;#Kto^=;G3lV;mHU?(oiog*&AJ{xvLPtJ($$H8eMxXSCn4tEl>xo*AeA*q6d5R&O@C=H%coPpe~)*M_A%%E-3K zcYmHkt2MU5+QT-qT}C}sO71{AJ8^D~l9hND?b3R?(FeMoAEFA) zRiZtb+l%&V?+4IfUC$%vsMLE%`a6b>>v~opPiJd#IU;j8!|z(bSb`{DWbaKws823) ze^{<(eNjO!qqg_+g=kU?y*sx#`W zx&U3Ex)5Ecx(HpQ>Vmqcx}vVg?g!HibyIam-BmqM57ou!VyL~)2@=a6kV#i3|*$W99^!u0$ri%g?g!aqu#1MsE?{I>Z`gE zU8%YXU8TAjU9IYe`l+r#*Ql;V*QyFofvOM{s;)!Vsjf%YtNNqIQU!>PB>< zY9Jb@8iWR^ZbCPyZbmn&2BX2MA!vx|7Icg1R&=YX2oln5%*R?5-p*{p>99DNO1 zC#~35we=_}ve)t$+9j>ncUikp#cbC4PuUNZXup)Suc8M~T*s0?$7i!j`(9+6Rp`WQ z_fG00@@Bgu#LVHH_Z-Gz-@m7zRFQo(OhXOkxV_R4HJZbEj+XD@G)7G|*9>K7E)%uY zTn@^e<9g3S`E%Twg;2p9UbXGBnL;#hj+^ZuG+4%Bzfmv*6-k@+xGqMEwTC6BY>wMU z>jH^C!}QVcycGv zsX6XwJ&pd=R{leT|9;a$M1Ax64b(GpS^2r#b=lw2q#(lor2Z^aXD%h}GnTrDNH=Ng z*zfgFs^si9pwm!bE+fm4ToARCoP8~Bg$Td*)^h`A5Xs)Fu~nT&J{hXPmZv$>1`+J&=kj!`5EV%e_Ni?#3eR86HMx8*%rJjA>Q-wU4tvv?KJoeu_HL6ks8wUuM&xU^Dh1Gy?h_&xlJep||3X)U?m z5#i_lKqsVLZ^@lRrfsEv}86S{7=UNi16zLQQPILgnc@hgK}j? ztK?~R9y(9z*{ksRi15#V&M04d2%+0_oWl^|mmH2pNGtXn8;MGm^Xg=umzSbR+TUap z)|H5$Im_KOaxR*;oc&<$m*%4d+WSJZNc+1FE!LG-f|hCT%h5y2IU7&QbE${X3Ttw7-{8R7=LtcI{yY+9~_N-f_N* zcFAn*cTsmE!hgMe4-x+H`9AtUd-xC${>p!Z2>(^>V^pCdt3-RW_q}Mp_HY0Z{@+XZ z91+>(E_u!Q0ulaf4@}^uT&OkL*FqR)gXCflg)O-ciMCmKoM^D^=nxc#q?AsQS z%S74QN&pdl$si*9lFd(?W-Jw~*5LI(ir=xp$;LwmiarH5`O)6@4l7iYQ5Bnp0AeEsyYX#b}uHZeM+eQ*wl~*<>Rn z+42a#zmcd!au<;^rD%e*a)W3hnzV}Z!M=)4MvJxH5>zHV*w@r@v`*^T*Z%dZ0{I)H zO?ib4u@xqg{YBXFUU50I5fT1aUPYUv75f^#8AYX}UC$V*(2|wtGhN;N=%D5f(O+EZ z$?8Vf@(90&1Ue$Q2{ONL(NWDEL*MHP{;(<#C6e`GY zV~LSvhGFh76DmceDAi0WrJ|Kq(@H2>X`M?8g=nQ;m?^RgZA6)oeQWGHGiA*-){y1= zUY~Q$_w&d5@%TJ_&Uat#o$0{@=W);h=lcg50UWf^7~9j!ZXiQmZWiIFx?0Ah+nffl_lxs_+9k+(IcrQmwgte zvLtL@e?-D_%^xBXdLJPY_VoZn!pMQlllV1@Q(02aRFtDKh#v9V5T~*v%=2S3B%02A zf`;l6hM}+Zs0?Q=BckQSsVqq~tz|EbL?oPI>o&`7K5ed^xKofPkNlZt=bX1na%-eGO zCLBMhsoXV0gGM^p^Pn%O&C-)?J z0U}||<{%QL`vZ~i*@3yth4@~a%95B_DesWxp_Mw9Rg5J5dG9!tU8B9Vh=e7qLz}g? z1(DF(%3MgybmiIQIF%)#w+)f7)K3It!3HKK&OTs4tD$_eZT6&zylCZC<&?DiDq_QO32dg3y)+`ZS5Y5F@ zMD7v+i&I(R_nApT$+84@*Qkx^L?cZK(~+>3s4NMeW2j4y zgu5G+C1Gpp(Iep#7F3pmJ)eq5xR=KmNy1uFSrWD=4K>#3E@C7J>q2EoxF=sskJL4d z*Rdw_NLWuQOTzYDN{@tjQdts?!)5eH*mf#Q!n!m?By2-7 zM8dXT&U7Sf162|6y<#fsGX?i>Y@5>~;gcDnt8@uemV|wM zH9b<#%;)tXF1lAoQdttV;XZmKtf%M^T{@K|;hn&v^hg*c_Na1Rh~^omvLqbM9_XcLqQkIFj9XYR~LBVtX7--c#XnZ&PW7FCY+ z;#8Kfcjf*30D2^BJC!BjXbxm9B+P}%52}hI0wH)BrIVRBT2YA zMDuhyDoeu1`Dm5Q#ck1QMv|~R(K?-u%95~O)}yUDmu-wBVP8{OCwHC|(IcTpWl31; zVno6<{TJFDjWl~WM#T5Bs5%ipT{cyY>$#s1sPeT}Kvm>=V>o+?sjT+QKTJo$_EFh= zy1f1Lh~K_Al_lY)vjg-DoPL-dG$=O3rCB<$|INbgwJ+TSrUH2G$VNpA4yL>L21%Z zqmf|=rZLJK8U3u5g<4C`y}xdQ?8s<~OfK(3awDZ664ttIF7G@M3+a(C9hD_vx`y-`X^+a1&}+<^5r3c0qRJs*&8RH#%QF|zBcVrSZ8T4QI7E5+ zTg`9KH>z(rM&Ggo_bm@uDCb)~SNEsf`QpfG&x}H&qLF5FEQk2-pp8>mQq7!_=}OV7 zJenoQ|D4CEEU95C{zUJ5dL&FoWvl3PRS~IfQe|W!sxES$o3DXt>d0D%gn1@0PvVyz zr?RnV=_ZXH3Cp9hq?Wl{mQWux(vgi(Q(ay&M8apNE@v*Jo@vc=aVkr~GvrsG7Nhxp zlBZ>!Em13-=XEGkMqVgSFl3<|T|yj@ur7TN2~QjJMI>zdyQrTouRkJTc^{$yx}F2k zAf4`G)|!OlKxIjyX>cFwGML^F-DgymgynrgkA&%{?5Dc)&kzaw<#Y65PQ zF&E;GQC#%DXlvtCmV`a{H9ZoJAC=9GrZahng#GdjBS{!ZWk>1KN29S(&x~Uv;r|ij zh*4Q5_ldai^hnq~Does~m_Uz&9+f5GJe!C}ILjxYd|i40S{O}d7NN!3Tf#g^I0vaL z30t%jk+3J0F_MI(Q&|$0x11jF$0$x^Nm#Q#5edhykdY)TkIIs;eJjw)XbEN&BT1O2 zXmzx_IF%(e%^*3i*B}zky0wUeZCHo)L~}9!FqgeiFHU8NzjDkzdL%55%95~V`wD!c|OVNtnw~dc+@xIF)sB->`iQ z{i`ESAQI+z5}nfCe~5&2In6wY-@Z5%e?f|)>CQp(9sk?%U9|KkQ0MQWd3Hf0%;h;m z!WMP?j-Q>Pk#Qu6EB${vK40)9$kg#+rOTzj28a)zvRF;HZFM7nUOPtD*u$Oux8%@WD2hbS)zxXlt z{X}EYSk*W*j_LgW(v0UdU_8A_GBORNk7p0Mr@S&yqwy@yJ)hMWH5Iwf(li?%OSybJ zb1BF3<#nlI#6R_zMkVq8i&&pZ;zu^1O4nWn)luT7J4SUvdnc(*Y41NO+aj6^Uv9*! zg-qw3x5+}Cr01T($VQ#Vb1r=%&lhw-Jw)!)zdcbena*udZ}g`0whUn8TPSBd|4Z|O z9HTh0GSWTgXeRKgJ%RnQOnRjdsbhACN~7}HOF)$;FrC{==OYq6aa0A7(5s3_ia959 zNkmB#I1cWUX2~cfBi&Q~X{gZzw$DA!&=@rpxld^|Lzyztf2sj>n!x$up8U&3Pszxx zvVBjZXD9GKz3wv(oe>G2JL`g8n85o__dQrW5D8De^+dgN>Ag{oY?1qm4{?+$J@<52 z9vUUnxxel<8hs~Q)Lxz+9D~McZycI3fpvesV@VV-6p>oPG?9Fg$XsQaLIC-N@OJ^%S0>ZiT_Xz)bV?5KRR!VolcB3tC{ zV8c+Z^xQX)<)PUkcXi|=5}p<*Kno{w7P#+cScFzgjQ;0*C0aj`qvFob4X8-uj!`ix zk#%vOkU4}%xW61mBz!ja2s%2Ev(A0@_c3%r*3-SqJBdzBy2 zwtX^tu=dX!hivrJWPXy$5=mtKbG1JrLaPq?)a^+y9Ha~*XR4Mc-vWFOJTh=kr? zL}KPU=?y`j$UOdbx1GHc<5ZUT<(VH5iJ2c|EC<@ib zK>5?ygYH?J0<=(+Ez>PR>viM?R5Xoa>z@57Mt^DV?`eD|m-OCYx;T|3VF^3XZt2PM zL}m{|P$Scq*RTmPoG@vOn`% zs%-h56Y~rDMUV7!G+i}=eLVxY&*x;J9Fcqf5=Z$W|0y4|a7MJ}7aw|KO*5h`vGKir1_O| z_t$7H38?C?98LEbu|$;gE9Zp!cK>8VYMK(+gSAoYSLW$H&y|KUMee-LLY-u~C#SG? zvr*??IqTf}@-C={jCA|6C(04I>nM(_&c)1R2{W0_U2$osk?2NwqP#I`I+G=ooyEE| zLoH_VitfIzt0lT_CfB?B1lRS5gl{3a0cFmN_EHvVHLDZDlYc!?FIms_@+sxsC}$@7(|uw(j&f)6dFB`7S@1kGR;F|3 z(l|7JCfB=sZ$3iE?Bv?s|x$$+Nh|+_z|dkEY4GTqxf&^aGlw zz4?fQ@6BF-toF=r=r^|AU2*1j^t);{nyt!5`KkhrSOMpf`>aA1>Lj{S)-xM*F5tMk z*Rd|BM?rK2^h7xY(f*91+ydSOwUHJZa~M{C(HjZstSxo<9OhFZ*FJ>5@WEm5XUmxbDi z+$RFsqfR3C8-i@qS*CN_*9G+uxjREo)Jx=EWqPBWInlWkM}y~ZjNH#$Lr|`c%tK>k z3GT1#jYCuBMB6@fPORy4>B(9*qp~FYHKZ9RU({KCKT&`d%1HMU-y&2fOYp}Ct(Ttr zKbs9`(;V(u?)OWZ=fq;9wt0!YltyK@$Vhkh-a02%mDDuV`P@Vzl_g=iZKy~`7NgyB zIM3YgX7->GS-ShKxkKp0oah=miL9=f`2+pIU$+_bB->!-qPePhXr5|5`*VJzI2t^k z^>kOx5R^NgtN#|6OCFl8y%{KfKJPdO$XQT;td2AbnCF6Mx;U~6qIsHyXrXEmufB^o z>)cQq47^ShG2Zgzq5w1I=B;p1*Psy?Ka) zcM0>+0-4TT4+{}VHj`x=79kQ|`xhe;K5MxIE!DX!LnK_`%Ml4z_@9V`PXHAn5G3h0Tt;yi_t%_ybbcC;a)_-TJJ+8+B<|!$XfevCqq_8n#E{wbpJF<&=S_n zU1Me`TFTy?FI#JtacwSRJ>|C!CJVJ*7Tt;3pmxhR&)j>P_Nar%UDF*=r)8|C`~6Ec zdSY4h9`i}mc^RMP+$>Azf_iALC(2pIK6AfGjH4mTI7aTbaGxL&{^r-GC|7%VC|}ld z;6T>109jd=gx_Qvmh&l*<;ixQT{Dv%WOB&tZ&g78M7#)%hOcPXzy97=d{*!@s-K4#nsoE01=kKI?QF|{@y+-`KzjqJ z29p|QH_IDB^|khfQ%P!>)AUAA<&tz$Z8qPvO7*Sl$rg>GlGHTK>5ZmZqP?Y5>xn;i zH&AWT-e#&I;@7&EYOnV8QAz5V+nMKnDyuzHd3o#>Qqw$3?^derB+0yqo~QaqdjqHj z5x<0wsjT+Q`K%eKZ9ZkX4OB%=6HzhM9_{@@wckmu+XGaRdS)IY4^kaNRs)Bk)|pY@%M>jDoJf4bE!>bwPzAhwP;_P3lIr&sm?USABQ?r zcaUUrlDV{{DqSP$m7%Ibd@qGcQp+UfS1@&{>S-^PDn|S~)2Pz5mq8`**U^Pkmuasl zmBe4g&8V#QOie`mwP|WG9r0V+luA<9G-O?xQQ4%ZXOdCv=t!G7C`EeiPFoidKb@(! zJhq+q>wO2+KH|^a{Zv+aCUtqN0r6K{I@P7bZ|!AN65ne|CGovxR1&`jFQ+;}{K%tJ z$BAFwzf=<6J3%G!y^~auTIMFU_7v5B#E(2pCGjKAP)U66EY&&fSt^O|nb`7J72>zO zDpid5y_7~J@xA&~l4R47r8l6G_+C1d#7~z&B}pu`KzfU z{%UMNCGq#qmQ<}=PhO|5qiREv%uUSmb}Fkq(-@I(N4bdCV&a$3jY?9>Jj8U}sb14w zFDiS9jFk5;rU|-KkKbjeX*3tp3|%f;+k?57D-a3GyAqMmYmP|R+N)3t?X^VL$aL}^ z#axR>xB^<2}`(<`yi>s55W~o87hf?4Ja%BPWZhIy(B7$ADK)gsb!YX zt4-BOd)ZWym?@_B1eGMoSo9=ScjD*rJe8!LsWqp9d4Z}o@zcFdwVL>mYpB)|eqvzc zIx4F@a}%#e#Bc2Zs)MAaxs#D4RHwCfhU%>L&QVE{%@d5YRFbgIZf3s_zpu+sl_mb2 zYB{P=+8a$ZhWNcSmTI5&_ESmxHXNX`+B3JX1mcfF87fIl^E&HNmda|++{!44dqq#C zYDoN<)rd+`*L=)$jj3$gXr#Flk+4N~Arj8WyHPuNpFDx-OnY=sw0F(Dh=lj$_aV~I zERvD;BjUHnJb*}8!h@)T-p@Lshx9Ce7!m)fW*$MrUq|Lq)J^Bo9X&5gI4H||0g&jI1h@WRFmBepRj7n0++{)6^sJaqAvKy7ezdm%Q zdY<^{UZ9frSBoB065s1d^)m5ud4(#6_~Ra@vf48rAriJ>!1CAyB-wOgU8++_{5)$= zN&NeXnpCxjADKiY@kcC~svhyvrBYeqN1B0XknYcq5%Jfw8H`AHH5`J7pNshf5&ueJ zK1E;15p(ytFA)iI8H&Ev<1ieJ5cQH{I}(vF&s;=!@=tnsXq3JO8;!F@&`ox>#g|_k#L6mgh*JIpAiYi;TJ^sEr-ly zI+~^X>^DRjn&Z;@9nI0X{DJ1`dd@=&m-9VJDRUXQ2o-8?1zN4st>I2Y{2j9lRRZx_ zTY<`I&#dJ>Nc=Tcjw*rpJ6{DVNs?*J@+wkE{MJ^IeU-@?p_)qkwK9!r zvG$fwttJ`f3$|em)h6w2rjq#H7AlEf-c~A!e~sHlCGjJRs3g8uOeOKx(O*;tiJwae z)xX+1L3KuZXQ?Fq9oac5t39&;ktFjgYi%|zkCh{S&!0!tfcSGFol25qRxmPyO5%GL zQZ*!gx<*tIf3`NJx|aBnt*9*VBh4n>^AUelwWPX+_*d9#sg7yyIF%*gJ<(>gMI=8# zo2{JJ#Q$_%kxEkA>}EYHQ8mzBI+et~o5`SRuf2PyB=z`hX9aUF)hX@$MA``>3q;%np9r zMEn)km1>xi{2cxj)hO+arkXgya-f1dJ z!aL(V>;>YlfMhC3s=1OqSX=&Gdv&Pl5&zneN+t2r#i%67<{qX?qiX1Sa?ff+btUoB zHK)3s_}^9DK-FG*_fSdv_T5XhN_(rRiip1p7gPPMz3o&b#P8ihR7bRTlX`Rf&jD0}h`)z?OeOKX!Bi68 z8$z{!`1hX+sU&{nBC0~-kHZQot37joYmC%4BUxS>E;l36yjf*@1v6VcRKe|Hc^;wqPkX1S zB>tzKGgN1t`QwNHEdsU)?{&GZgXN&IvNsU-gOy@cwV_AHeo**wa0=3m}j5Wnr`QKb<7dRv!D z684$=yX(ns9a5!8uNl>q#BW1$s!hZ% zVKddGC!+m%8C5gl?>(1OT}k|XqB+$a+G|TC@$cvFq#CQeaa6w&f7h5vWwmEcqO-C* z_kV8Zkd-xa|L11@QCPNy65DcMqPB|b0|l9 zag?jm<)ITg@+3MfJ@?&OXAlX`(49pjEd3m^+B1cGYNL=lQL_8qNmQYkfPK(Rvhwfj3%X{b?Q^#7ub(Iq1HEKU>Dw2(8){q>$^s6}D)ghor$s*rVg z<6f3<9m>>R7HU@*J>AeAbtvTcx&Hz0h&su7x@Q5hQBEO8#XYYWN4Ah{cmJ<#R-hHE zi+f7JtmLn$u8fq4NO@r8i0bW=0+wt0TvuDKby@WdBq&UDss>%3sMba-RVxKvw5sRSM*KSR-YX4~acc4jh~vYNTL=QXCH=^}Sd%s~08*`MyaK?~4{)jYMc zMdo=D+0`6}b0V__t%;^HYtdTOI@W9*b8*j(#1Tn0SIGBx_CX{Z_rB=eb>TWP@1ghC zF&Fpj)(5Dc_WC0d=JFx>XdU+n_g5|lAQH~Hfry0OAT&hh@(CiL_bK{Jm-jjPLPvgy zhDy)vmtly6wf+i`@Z8h?5D9DjH6r1@I~)jHE_N1jAhN1F9$J?rBB!mioC^}d01ajz?hD7JyEb+oGuK8(Hg((RA^Rv8u#hD~VLCNF#Gq2LB&Nbu;l#OWs2D zruN>V${~Kg#Hsp``sVng1k<0&5xn-nHc)NW-WICu#J`U1ppy8J zJEr z>Sp5Sc?(r**OTS7p=ztWJE__c|Ji}|R2_(4UPmgM8!gY|p?r~h_PhWsl(WG7ZI(r- zP>zv%mT?7IFC*QZXag#ed1lGkQ;c@Y$hPu%*gdF3M!I(%htP?QoOK;#@8{BK7 z*@QMlXMx#_HmkPqea2g&*MM@U{FdllR{}bJ3)^>%d|svsBH=Z)Dk9Osm`+*%G7nq z+7dh6T9zR1^3G6M5}q1wgF4G}ner{+UC{G7-3y3>ztPbHk+8KrQLil=2Y0{jjdHf= z^Mk107LK(03orfAU|D)w_qkp4g-qw}XI~-`)@3LnVQYt>Tv?a#@@!!q$`|?17b7ck zap%0*8kynH}_$GkbC?-93 zzfMDqwngt>8l$G$*c0y4#?8=`+gP&~=R(BUF}f9C$4%@Jf}zs#58$DjVTwwUF)>&X0t z{!;yo{#I>A+f_Tz4%JSyQ?(22Qtd{&ReR7L)j#MT)n2q$wGZu6?MM4n2hai4L3B`6 zf=X0}&>_`fbXau+9Z?-cM^(qrG1YN&T=g&dS9JoNP@P04Rj1G?)qm(e)oFBEbq1YL zokeF==g>KoMOJ0ftmO~3n@#R$mO`aerBP{B8B|797L`?%L*-QGq4QMbQF&DYN>Eim z6;u^bMO7tKNmUtDR-KQ|S5-k(R8>(`RU%4MRYTQO7oZDN)lqd-4OBx_6V+7JLbX&$ zC`px!l2x@)ZB-pqN0ow7RCQ5ZRXtQsm5NeTF~q}v;fST7G*x|6U)2CLP^F`ERR+pX zU5GAJHAD?njZh<1W7Jr65xPiqF}hfFNt%salGe!FzJ~i!8nTzr+q+2ayG_z;sV0$1 zqcTm>E;ehI&@0;{tvtEZY+0OO5~!+@MrOovKFvbaf%v&}r0V2)6&RUK)tOvu`YcN@ zU8s6E$vk^f^&@_|{#1iWBeQfxf*C?Jlw4}s{h45fQROQ5*d!M6`yQ&+#IJQ5stzRV7phLguUR%#XYF;N>fw4FSkInRC67nb9ilox{GLBa zWwmEIp=Y9bnrGP)Bpf5EUfrT??@g6M{JO-c2D_f@&mmMpon$W!qvEe$hmpoyiY`@M zhAvYzO|w;+MyiU)#pd#*?CV5CE;UnDqiV?NNYe~8i>5P|qsx)M*Ij|GP+f_xR5eG< zRac>_R9B;`RV`2pRZG-Tbq%^kbuGG9)e5yzU5BnyU5~C;-GFXTWui<~7RplHh;CHf zglI!*x=VF8x?9x_ zwNtf6?N#@ndsO$LdsX+L`&9R%`&AF12UHKD2UQ(V2USPZQS}gdNcAv!SoH{cMD-|o zRP`8oO!YW=9Qiw6C)7!M*=e>YJL(l9o1NCcoN}+lPjFs85%sDea+SGnEob=!$ZF3# ziJpu`ny1iHs;AM@s%OwMs%O!&s?Mmhstf9(dJa9O>dM`_E9;W7kavKkQMs=4+KbLZ z6}ob5_7GJ>=XXtOWxf(sK_pC96_LP7UT>Lv6NuK@$Oe;V_0n$3JUQWom* za&*4*L^&_BcNfTd#t~^`PK)}Wi7zuxcMeWMKWOhql&`%4WJUfS`3ibP^(uN*^&0E) zTBI!0>9uIhvXRxE>BT#NUeU-j)S_23vL(vw6>UQnx>X)l#Z;(IYFt3C4)%F*S;k=1oEy-{zL?yut4(d()=&>N~Z z(VMEb&|9jv(c7wb&^xLel%tBHxT+88qw0(Ls@_HKs@_BIsoqELt3E&O=IQ>Lc`#Y5*Fb8i)p}2BAT!kI~1f!Dz5*2pXdL1bw3V6n(1t41K2h9DT0(0)3(S z5`C!}iiWC&p<$}8&{wMeq5rAAMqjIjqv5I%XoPAc8mY=fxvD&rr}_qcqxu$os~Uwy zsYavGs_)QusxfGcYAhP78i&TI#-s77321_9BATe0geIvbqsgl8(f6t;Xo_konyQ+H zrm22FKd630KdOF0KOujA`5FDJ`UU->nvSNcW}q3WU(v6snP{eJ7Mi8{4gIG29sRDF zjb^LzQNF4G6{zN*IjTR>S&3GvR-sj@)o8V94O*jGi`J^vp>?YDXuWC! z+MwEqHmWwEO{&dkvuX?4qS}hKs5NFY;<_Lb_RDj~YR?=(hoX_@FgmO{f{v(;qNA!~=$Ps_IW^=-`5PdD{vbIzwBYwFiFXMd9` zpcE>lDve63%Ahi;vZ$=894eN~+4Jvg&+vzN!kU zqN<9jsuEG6sv4@Mx&U3Es*b9wYM>gbny99#7OJI6LP@G*l&q?aYOCs?I;s?uqNVfhU!9ep{gNjsA_~7sT!lks*BJ?s*BOZ zs!Px%swSw3>QZ#6>N0ehswrx!YKEGrE=QNEu0U6)u0&U=nxp2btI$=dtI^e}7N~`) zC2Fa<23@1N7G0}qg<7euL)WRUN7t)vKsTr|QKl*jWvOmNH>z$zH>qw$H>++zx2SGK zx2kSKx2amA)~YtBjp}xEyXp>fhpH`VtGW~2sk#f@rMes4t!jtbsoJCVs(a8qs(aDB zs{7D=s{7IXst3>mst3`7st%}wsw3*CdI&wFdKf*ddIUY9dK5jXdJH|LdK^8j>V!I} zvQf6`3G{^ON%W-ZDfE=;Y4o(}8T5?mS@f)`GwQ7Bg1V@lL(i$YqOPiLsGF)g>aKbo zJ+FEJy`buWdZ>D$o~jqoi>jBw~tE$(~YpPzTm#R1Ft$H23u6hH# zp?VX&sd@{&rFt8^t$GK&qsl=!syK?P`k+3lzNoM2UG%Q%J@lUHee}NS1N4EaAL^&- zkNT@VL?5a?LLaFHpaH6ZXrO8k8l?IdeXJUc2CIgkA*xT%C#p};r>f7;XR6Q9=c+Hz z7pgDOm#U#?sA?D*ruqtfrTQQGpXzJ$wQ4vTt{Q%Vtoj~(ubP6UsHUQ+ zs%dDN>Id|L>PPgW>L>J*>Sy$`>KF8jYC4*(nt^7henr2kW}=y@S!kB(H}sq8cl5hz zHkz%s1~Ayszqp#YB5@@T7s6SmZGJq zWoVgdIa;pz6aA?wM1`sqXoYGeTB%xvR;gB_)v7gUjcP4gt6GQFsn(L2uvYA@QW+K2Y3_M`o(1L%P2AUdcjK_#j~=#c6#I;=W^j;M~JqpD-*nCdt> zuKE}Kt2%*Bs7|7js#EBc>Ob_K>NGm7I)l!r&Z4ubbLgDPBC9eD`1!8^Putbtr({zK zl~R>PrB!868C6+SR#gs_Q=Ny-QMRS76TRRL8{RYVn4l~5&BWmH*pK004j1yxa1 zMO9UaC{a}nRa0GnE>KlR)m1f64OLB4Q&kJqQYE1zRWeFe)kd{dbx<8u3QAGcMRis6 zP(4*DN>#;BOqGVxRP|APRRh#Om5$O?87M<_A-Yi25H(aaLXA|7QDfCb=pxm{=wj6+ z=n_>E)I@bDx>R);x=hs+HB~i3%~Y48%T-sPD^ypaD^<-=bJbPoD%I8KYE=u=Le&zr zR9%CvQC*9!RkcE`RM(;FRM(^HRX3m;RGBDKm4&iYH=-L=H=&zUH=~SM?rxPxU@}U-bd{K-CZRQ}svv zRUe`cRUe^`R0Gfe)j%{*H3$t-eT+U<4Mu}iL(mY_C+HK^r|47FXXrE4=je0Q7w8Ms zm*`8?P&8CE3=LC#g}ze#5B*Q|HTqgL91T~EKqFKm(MVM;%2nl|Jk>Yo8`ZbyTh%Bu zN;MjdR(*%QQ;k7mRAbRt)i^XxH6D#uO+XV=6VXJ~Bs57i8BJDwkG@w;K~q#y(Nxtm zG)?sb`a$(0`cd^0`bqUO`dRf0`b9MzO;^o8GgQB#UsW^FOw}wjOZ6N2P4zqaT{Roc zR^_97RRJn!aFJQvOaA|14w|F-1O1_zi{`54p?RwLXufIzTA*5p7OEDZMXJSUv1$og zqFRcUs+OT;s^w_8>QD5ist^^bR-hFPQcd=&?td83O4TZ~O0^oTR;@v6RBO>%)jG6J zwH~ckZ9p4T8_`D9CbUVl8EsZ=L0eQ?(N@(qv`tlnid4m@Shw~s^q1;y^tWm|+OFDx zcBpouovK}EmuffKt=fb3sQy9!sP>}0s(ommYCqbqI)DzS4x)pq5>%o(gbuN_^?J+k zJB$vij-Vr|qv)vW7&@jpj*hGTMgOW!pcAT-=%nftI;Hv#{iiyOPOHwKGpe)btm+&( zr?SYZOgcaRrPnegY3}DgR7zDEl~$EOWmIKRSyeeyPIVqSPgNe3S0$i?^c0gV|0`=M zpbDyrsG_P8s-&unDyz;%=c}rqDypieswxpBrl*=W-SVoTYPv2LpbJ#hQFT=fR72-d z6V+7JLbX&$C`px!l65Y%QEgQnR7aJ9QdD(OT~$3)PnC*NRWTG(rJ*!ceNtx{4ONX$BUNM6SalJ)NOdu~Sak`yMAZZ}QC*5IRb7TIQ#D0RRn1T{ z)#d1N)fMOp)s^T{LRW{02J%OH3J&B%FJ%yf9J&m4LJ%gT6 zJ&T@Ibw-_4T~HU*bLcr$SJYM24Rup>N8MG=qvusGpchmW6YnM&#k7}(GW#%>JLJ0?ve1pv zYjZmzZ$fYONe`b4ehcOFNv~q$6WS(@ew2CM`xqmCLiyS&KvtJ$`m(&f%;kxej7&p~ z`bKNkxNmx4X5X-8E2!2Jzb+f7b`!r1d#Ef4+mO{ay;CO{`4U@{O_k$x`91vYJgR=A zzUlRR1=F8ufc6GbStps!WcN)kPyAoVN}#eivV;rnq!&khq&Maf)EB+mH`@01`lfey zKk9X)vLwUwm60DHt0T?3=-p^K^BzlhFH#)kzsK^n?O=ZvAS-f9H}9w03*SdKKEXU2 zqNbwCT~RaC;(gYvX?@fZwbGH-p-k;%p`7=lJs(FWM3dy3j7}mea-SVHAEevzAEbxx z-bg@6AFzaV@;)mW#YErAQ&(xoeh_Vg>4*BM`lJ4gd{Vv(!+eN7RDFa#QVl=@R0GjK z)gUxT^)dQbH5d(64M9V6>7SraRG*?xRiB~HRG*{IRbQYlR9~VmRYTEG)i5+Hoxid9 zifrFk=&SVTr@{ZB|D`uEpS>)-uhG}Mlk-1G4M)T2wRlZ>BhU!dNHkKFi*i+YC{O3| z4f;m)E&5h93XM{YMx#~Vq3={<&=}QNG*&eZjngd}kH)Jepb4spXrgKonxvYHCabaseDXOVxYI>S^$L-H)XqxH=^n>b0^rPx0^b_x5Pq}^lGx}Ne3;KmUILFO%I-0JU zfo3prseIFx`4#=jec~(kZhI!0shWjmseVJhseVVlt7fCws(h5MDnJFQIcSdR5A=s> zE}E;Fhvuo~a~02Lt!K)5#!>Ek?geW_c_@EA+qX|tfb9JAdM0U!Y~KRz1q(P^n~Sng z{sQ)V2T=jC3!=H0g^XO7-q6I|NR+cMnl6s=7e-4jKvqVMaZ6al$VD8LWuiE;i&(;b z*IUe8V{x=*X{hPqXnD<0&f@gyrsh(a4%x*lud&E1K}+-)EoJ0V=GoSbMEOf~T~OiD z^efCO(p!N@*!Go(guS#1ZPwlvw0&u`yd7xgQto_XWG=hV?xoT8?Lj3n-E8R{TAE&! z`12)^Dn|Udltz_H{2t7sDj|Nq9HO$W=l0p*rRnX6-%IVO_`OZEJhKcfW1gF3d1g6U z&NjUAkmyhL#GlcAiKF~Kqh0~Be@0tm3OSmE(b^4a~E`GWJ;<*$s?rC^8$F=f>!0 zW}%#otmotHWhAnq8{EiEXp^qzW=3vKZ)zTQ^F;ZZS+hZ+0%SMq$SsWAqU(w5mS{cA zR+hds+GklPXRGcdWVh<^+lIF3aVSDXs$x{E`V0M~`WyYN+RhTTM@KA<^0(7-$EX0= z?a_Lg9cV|iU(8OX+o{tbyHnR}7rkB4T;eEqS9JXHQ2wrH`wGz9UD4Sy4=vmk&1Df< zEF<0ZummmL6hmh5|nB8c1 zw0&j|+7r#i{KNMB6RmX|*?*#~HG4UJd!ud0LOFY*t16Ci_v)TNQ}#ydIThva)%}7B z_eR&l3REO>aaUt8D%l&IONWq^p1bpzed)I9K9=t8<%uXJa(B!$)O268F3nKpzGz*t zklh#UYqOuV-XAHB^7lt`DL__w?lr(1KnHX$9YhCpFO{GY)gk72NcS1CBKPWK4zn(Y zqvd6xoWs%e5J&cKG@Us@??^NB~3y z?l7vVB-M=Lx4nr}Nu-`xfcVQ;OmmWXK8Wg4H6^KL(_8#?Q>rUTebbL#bE+1uSMl8h z(~_zcNii$lNif$@WxAd$A&cr}*K5JjZ=q`KdiSC>R8P6yS8penr>QzSwPLz1RNaVQ z&+b$&6TkFVsCv0xX5R$Uo9ay`nde(nIV8o%JmXZkuJ;?;kViF_`0blVwUGFEE}~jW z{PLDj6*|chR#2^W`lU~TSwm&DXG*c>Nve@vS*mhQGTnJpm5ATFm8q(d6muJUDUm9P z_;pF9YUFyd){UuJkcQ?YYu%En6-hPCIJVbOWje_gWl`NsQcPuz^et4a$rWZ&4ttlX zEvat~(7ThW9Z4~dv1aY5?jwHb_fvHseq=|gPQ)MgY^tY7ijm{@G*xHfm(Yc(8{x0e zy_aCRQ@u<4Jl~^wpZK-@fT|zyd!j$p08-zK`XIp!q#Eob>p6sKsP=|YeXYIWRHKNW z%V?^xuJLQgw2Y{gO@f1o6vzlIkhf>(BN*P1V`;WG-E(1`vPN4Wt@O{JIRG z`i%Iwd`>l#_>se?rV~GM2Gy^`kDN(0i}-E-jVhn`=?bV862E?GA` z*OQ}ihRQlEe}kWP%Wzc@KV4a>O2ltbWvZ&gUvY_4)rmj0HK>wYPwqX*R2_)lzK&Fn zxSpI7k5Y9aehJxBPdHWjCBZyN)y+xPtUFZ?;;)CER4=-ooCPmYz3h5&AAE&sx|1wn z2GuN*Y#MPE{6_UVNinj0v#Ii(K4HzWsM?WKvy5JQs!k-y>_ypBJxD!sf;)6isvJ_=lv>X36{!Z3>SiSO$RSjt z$VH|JuNI@JrjVFfNN*}tK1t!vWL7W*RJLlgo+c4hi?-HWfQa8OraB_&<|ocpQv)T* z$od=ylgzrLMAOBoPS=Y@o}seD??ID_Vo}eep{B9~IX_J^WOZH4DRe3vO=JE;|EW%+ z)2cJ*jOr{pt2&3ysVuT8lVQu4jC6BJDVAOql{XpH%rmd?vlAzmt!gsDcMK+?Od0vA zjLbsTWTco`=OI%n!xojIcNP276r&TR=yepGL{@vIbcStOn%;=AvbE^)(iyePQp3nA zN@r9hRm?D!kVut8YMDX%xxY}QXs<4nq?Y-sUjoDgTm4v@AaVWvm zr)uDO!v!5;@7M@mBi2Uc`8YY8O3_OKxMUOo<}d}TzVi9wx}oSC6Ya8 zdZV~VmTvl>{?RDzsWhu0iW%E`Q1SxdB=2nKBu+b(v^A+aMB-%I)ZmGEDb9 z=b34XNZ6t~(F0|oE8syyLazfNVY-fpg#GytdPGM)it==M-yl-UjFD^eTSUU1AB9Hi z^1ef3q$gL78H-3*`Zz@VaW~`93SGiVv{Og!Lc4XId(b|ar@M;xqY|CVA#_@&JAfs{UK+|2)%g=;p-!Uf zMcF7vN5)aEC|i1YC|^ewpd!(`(kn(*G)82~v%K=>)k3v(WF17p5>gNeTU!^AFwc63gkCBlwau5Z zW-(M>^tPx0B4N$aQHGAZ5RuSph#Kj1jnO5dYh^A?P;=4Fqv$GhjrOiZH)`)DbhF5< z%Pok6CESYc5VN_$VE=S0u>5m48Za&V) zfoQPE?Ux~lggx;IB4ICmiU@O&ZT}3B(EA*bFx?l3gx;6vYmwX9;fREF8G%UHgCkL% zj{F9Z(EApR7P+nc4w2fXnQZMCG){Zt(f1;EjHVzG&a=x5 zy(aW770F(TQ{`MMlC_Rg*_(9a&4{F%>MPiWTTtt0I@1Qds3TuOB&^HJh=lcg1-&Yg zZ7{E)CE8nx*6VZ|kky_^M#<4!Ol|gOZRGbSDiXPQ7Nc{bt7SbcB4O#K4o9U9a{D?9 zbrQKfn2mBoZu{aWSM-p~B@gB6$O2?VZ%NOjFkMPCmpCdCxiu?BR(qx{svC_o^-w)k zDoRzwP)wD^TBo60SzZ=uE&4;$2DKCI6tzd4LE5L~e_U(QX~N z2b~bzCQCSpPKn$e{12Vhk!R3Z?VUqbsvz(Dvruc1 zTkAHcoyaY(J?bEGN3$b(M0<~-P9nFwY}8rwoUC;h)I;R+*AG+5;J z;1HBsKRPOTXsqR!T3TQ^X<0p|thA{KN()<|ZM12DmPsoM+D4}=Xd7*XR;`>LRF<}$ z-{*Tj_qqPKUa#|hUH5+0=RA*CAo(5&QL&XQL8Vgqd_Z%p-aK@XNSH23Zl>8i4 zp=v9+8r@+f??g3{@2?iEv3hG!o#gwgM@^Dn>t@tq^;*$3DZPTIP4XjaN8d|+WINF= z%k4%#Nq)_KM!!hDhhI@F`H{ICl!NNDo-UWQ&PD!xWERTKP2N@Jpj_qrTg^d;IKcJP z+mXSD2)QAsz*;Fp#38PKOvz&r(bK)9{*FUL$c;lpraSB=a^ulOmRppYv+LsAoWbtP zs?KgVTk#UhU4n?v=B0=TZ7$8t+4Hj8oLu)6{dHukoJg0fU@Jvry8Yhf>4L2`%eAu= z6QPyMQ7WnDBJ@yh&Omo4_1wegC5^0DdKrCexfT>#&JE&|{-B&*?z^t6LdsM)D)8 zMQg0yT2yEC>d`uBv&OO>#g=o2ux5vl^Sx)G!BU$_4ncX6pF=(xE&1NZpaRLaS%@Y` zek?_3y5#3K1C>hY`Jr+vS%FfPi%^TzYelh@bi>&1!)V2?O9sl6eD7IkwB&mjg9@bd z>Y@o!dSs|r@^dIb)2-wTR4QGoxtF1o%d%RZ4y=Rj69JUL#wL zYOIG^RHvL@iF)*``(`jcri8f_ZG3XWl%{=IM+N{!+9<$bjo zp-Sca+vO@$Be{3U)uPzyxg$8Mj!0U`LIsk4^IV8ZCI2?N45h4Ogc_vuyJi$i{!O<# z5*?Z3+)=c7RH8yuDy2t_Qj&kO9-#)yHKO>aWJYc*CC4VML@1X0dvA9%Iy%X@V<>qH z@^gq#Ecy5Iu7F$t@^8T-6ieA!7gvZ1`NrY0Nz$<#$;XmALAiDmADc78o$E^;M=QtW zjBqUliPM7j=oUIk2KCN z5i!dB;lF4b`YLJDeT|3_?nvdnK}1-W9q4~afA0U#ZRK8`AybbJqPM$*pHAUSqknXr21orIPE>)0TS%5uwdz5y5q0DER>`k?E83=#em%FNudLqJ=r^J{htB0{bJ5g}KI<}2sNxd4?PpB&c} zD0Mu0bEW!=(3O_E3N5#KD^R87s!(h>cLKQ+k{+s1%?Ub^bqv;`2Fo>~rW2C>n$a(o z`xV8$p1+<=;E0@{R-}%o+k~Wtj0riZ?h}~9^Nc!TEAHV-s=ou!fF$QK5fR3cg$7%j zLr|r)S%q#=J%3fa8Qo&JTT!*uTa9WgSIbz4bPo|*ah>&Ej~c9GBWjvpb4M+fYeli@ zrLW~hd_z@~s1(g9O6G7jy2f(XqUDxbfod#Qi|UFv3j7&Yj~=(&6KI{)TaTJ7*Nom) z&R^Z$L0gKF^L;D&z;Yj=7Hg#y#g=m?GL{pQR%%gvVzRpKBBoYeP8E@G>JNMAo2*@`Xab|NC&AAUeTCiUDdM1 zfpoxo%(f7Xll5-PS_37R@FIYOqP>B=3gR%T4hDfmm$Um;sD5&A1dL>Olo znrm&&L*a4siof#pM+K6;O-jy(2-R7x9ueV=_ZV6?iR1GFjdMME&T`LB%1OPD0$mgL-K)MUA4^uFajKrNPQMQxJ5JGGjJ=+nTw_ zT(2i5D|i6vIXOAwdZEn8$qHtn0xzBYg_CnqM7oEFtrjAEk84$Krsdd*??`g)PI5I# zn-NrLU*8u0l6WNv;exqm|0}b8Ho=p2Eue>&t3XWA$p$ z6E^B65fRRZe<30~OFe~%aQ3f5M98g2M5y;Pdd}wXJoE)rbgJl-1~V>-`RNx7DjbMA&1sDAvf*PYEUbHnb$s;b>$DCH+1cg+`Yo$JrQE ztek(ADk;fH5$V;9*osfHR;HsFNzR>)$}Lxc-c-&XJ#V47Bv}b}GAnp;qB6AcWJczX zx=m=y$w}{9(Jn7Nt?foXS;?Q#FDElbO+l9Sn*i6phzLDQLqxc*oQjC>EOi>1ermFw zGtimV=2@ra6cFKjU@I=QHp|d#$sav)(EL-A{jdO)pUT|*r|Sx|SUvc2YzbPfdj3dW zfv&UM-_Z5S`TNlg=tj#`A|m9fP_^Y&pPKW_ZOZ8=|COzn2+zW|qcus+twnXGCQq^T zXq|F?4(riIt&2aBH=)-whc~o0H=`!2*NnDU?_1GlmiruiWBXwTimgp|8lMqPqd)(w zn2(6Crw>CTPouw`>TeVpZMiY%h|`jNbR;_JwB+mrsof*@|Mzxzov=p7hs>;?t8O*`2}pa7J>zXQ9Do(7V6(3_(OVUWTG! zl7GiC9ObE`e>%)Z1(HAG3eos8lKzfIL^yL!KxbO+EL3Vel%ctjzY5Po<&yu5P=OYn zk-Yo85dBsC`8{?qT5P>9L6=y`OVQFZlC$bEbh+d|WnF<%s^>rFMCi&hlB4x1bd7rV z$Mv=7I&1T9s8YE;AF>~+&`P!9KaZ_Kw^+Sf(QV54Yvk?d4#}_Co#-yfui4$`9xv_N zdr_0+n$erKf^VUBZ3X{}KCpTpqFq*RHzLCM@Dn1!zWo^yA@>U+!dQMqzgZ8zqu6?I zXQDHc{lH(K;xCuciocJ!Kj*|}|CwXSzdt(%rT(loHKPc{mUDkWf3bR{sFZJ=$7wxX z8NX*Q%jxgVmi~bDE~AyJq)w=F8OPEC(mtq*O1>g>Mcq{LQz-*wN_%~TvQV~44v}(D zuC+M`4OW}SD>noU^}S1npkX$a;pkB7VFb#vvE-xCR&op~ko?FBQL*HEFF~bcd>?+5 zdMHEXYUOsR0xk8eNSC2XYo!X+*oxlN2{C9kvP5~7BZw4xrd@E&WuH<`|hvrMZl?CV`$$#It2rZL*e^;aHCI1cM4QS;|R(FHGC0&K8 zt(DcNM)JMaq6W#2tPyRr+$QuN$&ck#^qS;**o=t2Zi`m%bwq@*yn%=?>Lx^l9-7e> z$+x)`wMc&MtthsVZWc%KES)Q>=phT`&7!~W)L%X-uv{T3okc4zsbm>SNyBeJ5vs9z zwWz`BHKG>FwW8QsakJUCvuS0)7``*@g!Yw|AA$Bm-DWegRijY`>T4zYq5jHctKI;V zX}K(vCoNRHd{iLqR>?witn^Rya2%RoC5zBRD>(@jOMYY}XsVT*hEAQ$et%u9oQ9?= z*H@Z>O0AVLl#-l!kI-`IacKprl>9iW&|T8I%H54>Z0@z_Wh?m#dd*tdjGClM)oVs? zTgi9O7RirnD|*k??0vM&N^VDOlJC78eQWi;L*Gk&)H~5G8`*9YTS+&Ey*Y<<+4p0P ziB7279O@k^WuQ#uN~A25EmcT4=uj&;0_7=JrCdH5E!9h7&;-jBp;DjO<(ie-gqp0CX7qvNd-xEwSiM%X&FXDOZC0-x#ZtZ6 zbZ4{2&SoqbEvOT!ll;G&tw#-(YeXMd?nBgaHm!_Qy;c-k&Yi=aK8NR*3-UQ4vryhS zwAuAAl#dFOdq%lJR4gr3t^}1zpD9;{%2luT;iv+olsiQwBUB^#9%@mY2~zJ^{^BDpqzga_z}gDe@bxYa=e_&^UK+~Q+7mqpUeH|0)HN%ymJ|+f9lFdBUN&h za-+~_t2YJ}oJ*Vjsj?6qC;6w*afk>{Pvg;vmOBYew%im%gnGq@2=z+PRE@WvxVLne~1( zTCP_7v-b*AX}Ky?V?ETOHP*^n^oZo2F&{;Bma9kWY@F*+gSFX+HcESG|7}7|)9KSevbAo3*kXeWRS;w>waq)J9f@X4`D%pt;t{JTzbO=idTUZv9oD#q*QTM@vx3#u=gOZIy07mA;G`X7sj=>>ad4t@v|# zD{8SBwW4jdF56L?)oVxJt4;rzYbV+z`El+>v9;+IaMUeGR&XEGP4fFM1MR;cIU;+Y zehZQ|`=im4KO4uOBNrrl^C)zT>iHwG01=`0LNvkpD?&tQFM+vTq}Fy>k92zX4UM2ft=jXr;|z6{?o@(zReUy2HkDC%VgexEs}IM*hlBi|(@? z?niZ&t4B}RnmvjBWh?O%T4!@ukDj(3oGeyv5lI5ut}aAR_d>H|k=o zbVd7G$^B3__2-Ys4Aj$l?}hqWEB(-5-=B_+8mCtLRcbt%V67CP6RqA!Xri?_2^HHoOHiq`Qihh=j4nedwdwbCgqBHu zk6n$HTPrJ2rSDzyt3uV*`)X99oIeU`(Hd)WEvh>|IpgZl6ISm@w9e|SM^9T1&!A^* z1vj7un?oadNhSR_Uq%}(w+X#wJ#0oz)-}@I%}Q=Z-&n~V zsLl3KJNn-0?L@m&&tC_3qn{*y6#R^0E9ovk7m)M61$X6qA}U9lZ(uB0sGyvqApN(C zP_eW{&l)9YhD!Q+r=!wxo@xExE-FLiD*4~ll&nB0%SEWB+&&SZ29+FoB%d@I(MIWK z^{@#wN&Q<!fNFTh3j>_469~`%Jm6sHgOYPf;(FEe(}&P@XhN%11|C!?ASXg=j3Apj^3fMQEyW zH%rq{={1b(aj6W=SIO6<1!%EKwo6OUGUYNpLsz3pX{1zzswF?p)u=}Dk{@S1 zYOs=xXrqmD6KYbTO4DHnMiK%UaouVwLoBaMyB|xfU%{D;cP# zA=s?Nob-pMw)~sOJ_<`P_cA{RDw>H?vYMGQ?KP;v%D-#L#HaYMLG>l*T{aB zW}wrr9lFl+H}p5t_2_!j4d@2b-_hSqH}X7jBO~*B za}Sg$`MGDI)QzmtIE^eq%ar?zbTwKoT`8?V*GuR7)KOX} z?S;Bp$$im&%B@na8|tpyI%$7&ka8bMJyCxvIRIr^$t;v@xg0du>J34|B;WgRlxHRL z(MTK1C^Wi~)|q#vK}H7S8`7KYq_gJRd$Zugl;n3jBYlqL@Q0JSlv~O z%wLnU&|y+vt;^x4KsrV$M8(ofsRT`zu9jw?QfZA;hUQAIO7qZs>3`A!R4!$Fg(}d} zRU8E)rOOZz#&S7INvA6pp-Rb*tP0&B`QGnDHInbW7TqWLQQwc&O1}38QJv&_uSbtb zm#K%x(Q}etiRV$giX)^(Id==6b8aE$?++c(-jct^bwXXFXH>E)>Lz_8WuP9C`x+g9 zdP><+FVt5$LF$M4OTLEzC{yx1WTAtlb5(L6%9d`Ba!{^RCk;Y_rMILZXy`4h?hn!- zXqa++zd^$h5yp8aB0>)%5D`Y5hltQ$J|aSYhan=&;czta7S`+-^)Lz*DChetMB^ks zzwu~-IH}hV>urYq0I{r5k^*yh|og?B0>)f5fR3DA-c$Ni%?4P z{Y7Y*QH|t# zs73cke$DPh_ep-u?ne(ue$D=Y)=2(1TZDNhj5bPsew)y1l3&5is7dlG*o@wm{0hE< zwn%;jx1tXuzk(m4Sn@r%ThXniYW8Mzq6iUT4p*XUB)^{5qH4>nMm3UO`C1f9e$;L? zdu289{oRY!NPaA9(G!v{`6MDj5C1~(>f~&6w{gw7jhvr*CzL1odikipa)qc^@;#KG z>5}h#1}e3ZWhf>26^zhw$*yv&}#~CEs5!lxZciP|9)0*|d3&;+cOfF2 z0e2%JTxn|%5w3%^XpKtxbyCAkqgYBGS$FV8_l`t8P<%(yrn?i}X}XIU z-9<@%H_SqLlE16vqXNm_Hw#g*bh@t4C8$*L_vtcJ?sK|xRiKpQ@01a$l>A+^3ayd+ zlfYV3cUN)`uSX5a`MYl;+9vsX>UI=M{yy#QW*^<1)XPAbck?uJnMRg{hTWai8;T8R?2O1-rI%GfIR(jKc|E9IsA z5V2M1rTtgMR*jd&Qp;8yksftDTMa&^J=Vxplb7~%Gg~cQ+DENywGru+Z)dCHBgu^R zVyhF8-qW4g+Q)KT*y`$YdOq8it^F+5jjjECP9y8V)u@4H_mOPHMA+|--kq~2k)A_Gw&FU=)uTAs zW6ss^S*C_I{iozT5s~HY)DhVc5q!%n?S+Ue_o4I$L}a;NrM=N*l5gd5L}a;sJIGyu zQkIKQT@51}t6V*LLMoM>L~#vsxJq)h)T`xhQ`{v*D6X|9_0>Di zhw@Q@lp__QV#)Vcf=ZDv)-gSn~b3d(pkNp7){qO!uSvO%I?4O#eXtFs(srOl#3v(}U7fjz)+MTa9Xz+pc=G=%IBFoYo#7FSiMHnBJI~ky;c-kNw=O)1?!U@=AgOjlOE=w z`Rf_kFx6Xt&R5Ba(gmno@@-b2lr&Gd2raXcSECxquURc>uzHOsR)6W0c$)QmI$4QA zH23MG-aIs4y2g(MEth=nD^QK(M^=m0SiQBV!E%kLN%G@tMlDvd6}4IK?dW^u?p1#~ z(JrgE8^u=AJ;UeQXOcM_faX3!JwMxdX!$eAj8>qhmGh&11~pi{M)Z>9UPhlueny|8 z*mCY!p7Nee_IoGP?b)Qi4Aj$dz0l}qlaY-w(uwcI?k)aqS^ma9L11zUlxw|Y0A`>fvms7^V5T-T#@ z&++Z*9_@^CJ&LVO_dI&uuAdF4!PLlKk!fTOeN?YA>LMK}bw$J=cZRetBC=g1?T3g# z?mnp-BKV&Mqzpvxmy@LKh{$%`zD4^ZqOUt#>Vb%CH(5FW5!tRnIuH@rZl!b(BC_36 zQcu*^TIq+frT3J}LBvqEQ_5}R-%P4r|L=HGWh*9zxE4R~n26p3in1;FGCx@1iZ1+sdd3KGBlPVk_lUgCe$KuNPJCJxUV%l~k2v zEB5NNi`@I@V{5YoeJ2f5?t2tVCrIuE&eRtYbw>M2GnLy95sXFZh6t`9QU)S8TBYuY zU@X%9h~RI#?nXTj!Cx4a4nV{pH$^%S5$tK{AVg%lYN;n8cq1Y8LIiI>q~3_&J;P6^ z4N72BO21J4d<0(Mah!X%reQJuHoRAty!fjV6lNioL#5?g(-O z-!1)2j;+{h401=3Blr$eIksZ2E0jA56)5L>FGLex;M#hRa+AoONFHM#%K;_arsRCW3R+dSN&?T0;6kRU;L%AzZ zN_s(x(3KkXhtgGOg|t(;4#gU^zv{ae(Tk>+&`YM5(aWY+&?}~Yqko$=qK&3aXp`wb z=s%`c(W|D{&}*j6XtU{c^t$N{^oFSkHJO@Gv*}Irrs*y8mg#Nuw&@-8j_FTnNA33aD!)bFn!ZEdnZ8Hgn|7j|rXSD`rXSIdrd?>4X*cJ}ZqBN%zv^5;HBz2b ziyEX;q(=0;^jB#o`f)c`#nsX-6kE>yM4LZR@_FSV)FAnKjVP8rQO^C$_2p;edoM)A zk}p|;N+sV)8A?gMUW96-^f*z2lpZH~LF)J$J-mq8B;P|jiY4Es`-NxdUy_o0pp=we z3Dh9@l8vZE8mu;3(JssFMzQ2a?S4hS@_%&r|AEi_hJG{sj((@)NovK#d{ZBDC3?c4 z4v6UPS`Xm3-UAW)xdW8j6A?q)`Ks3u?H%*Kha7z&>V&%RN44Fzk5aEI>SnnN)Fb9^ zPTzYYB@c*mYKVUB7FwxgEAFX!kEzXGsIPMU52TfTDAU@^LU}QNefx08 z`&zlPP^sFCPNkJHL=1A>ekV5*5yRadrCEpwN5O1F40T7`LA^O>uI-0;=v?X0D(anw z=G#gvK;;@srp8i%F0y^J2rbr(W~#p>XsP7?f4s|3%6gB`vY6|x{{`mNXnD-vC|}C| zKjv1T>+RUR0sYkKShelm_J03}I5$SWq?MA=Yy8Mb_ zmAq+B+T;@;|4(Qx(y`PL{lVpiQSXh2kmEnkjC;D={oDsST6>{PmwSNwztd0_8m!#R z8E6P9aJk*xqt~NCG(ove8|beH6}#LX?r*1)D?z2!W*H*(bH^8vn~CPS++J?>DQF%l zcey>?5Y?+dM2=gk+(JYQaF0z#7b0Rmx93!J5hC_;2dd;EM1&dr6%nDoixI*9oVl8k zixCmVxdf$L?!oRSwHcu%={6m^%_vsy&q%HVbMKIpEJVc}ax>l1GpJXBrgzBYUlOcE zGdkp!66yJsv6VW#Ll{fMR!sDCRjVj@1}aT*t_;oXkjtkYt@S)q-XYmz6)4pqnL~ss z)n9}5Llq(pa>H~)-h@_Ky;bPe4(zd0v`W>82uHzcRAar@qBW9V&$XyddQ$6BkJhP` z%cS+FLAf8aR~pgA4#^(dgqk{V^z^xvTr=9DHS_2DR@7p-R_jpkyH`?U>uo z9kmdZp zs1`LS=l4+~iluZbojJohCr4{1)U7k~J6Y#+2I|q7k+tdAJpg5PW zP(kP11KrHojHM8r*qLMSI~~aKC4hoXZAxUoyE0iO=pgY%$w0#RHruIRh#u_o%HhEXgzAs zjJD_uZ$uk4maWQdLQRrCQ=8G2&Rj#zlPx*TQ>b*~L6!bwv_`Cdl5fSc<{ScAk zmg!llKg!%EIbO055$YX`h){1JBEoT&jfl`n4kE(+Ar}$h7#xI%aDNz#h%o9QhzR`+ zMMUWD5JZIjh9M&KHyjZ?To)Z@haw{MHv$o%zdSVB`Wu6YFwP?o5$YX@h;TJM3K3zP zV-XSR9gT=k?-)dck_Cteb0|dPl=J&A3o4v%B{Z&L?riVbfL}YB1DATB1D9H$6pZ<+PoMo zwUU>i)IP~|B0^8?!#REI*^Ff!dTk%>w+GBcoA=305jk$sx%}U_Y{gBMYeod02FbZM z(OZ^#8?`3&+$Xg1Rg#O?iivca?rZdo^}d6WM7sBgtr{ZNjbtpfY{hL!&b6ah^(r-L z*M(=WF08zNw&{qvNf{5a@)_ukE*z0tb)~%%)pTLyN9ev;i|(^}_oFq+Emv+WddTWM zjOw~@eK}LPdh~?loUEN<%-gkmEWr8r9BW4_Sl|?2&>x>P3X!BR-Z?kMX0oEvZu>Xs%!2*_lsJI&`Zks z^?Vt9U?o39MCjoo)MB|-L=18p)Wau;2qiy7pIOPz(KnXcfnv+KeYuP7n~baz>auUr zN>|j)av7+{zKr_SbLj5?bfA?y2=!D+KZjn3$mM^mVt&055k}Sr^|g}yP=Ct}Ktz~P zCL+Swk%bPH4wy&p15vis%Rz&z`x)r;46c0VD0c=rGlL%b=&AWERGPs( zV4qu187fzsOI5N0r7}4GZj&MuTkozrx$c~QJ#-I`P)&E*?5=aV7TwpKac}4HFan1dv-w0=)LaATECB4x^unupAB2lw(hL1 z|8%w;wJGO68@8ientO%Tb2s`)z4zC8{)}Rk^q<_^{#*<8PxeYjwDxGCg>fVS5_4**9uX|EY)O}GuYo$LLY$b>6 zpZf`MpxgLBC-*5^igYWVu@zg+4MjwM_r-0rc?cS2y$?ru`?D?wX?63_Xq7xfpA^TS zBUI9V<~kA)2e><`$Q^}Ky}JTLg!vVs2~u}Ge-@z`R`PUohRyFxM1&{* zv(TTd3exApgYchcVwG)(euGKQl(=|5#! z2{gJl*WJ5x{~Ln}dUI9u@3RWggx>V`kXE+{757e#i4rtjtvtJm+zeDI`J=83r6fPU z2*thGEB=jy>q8HHlJmU~P3S|($~yIciu)wTdH9rIPFS|$B{SdD7broTehqWdI&UAiBwk^Gy5wWv<5 z{7+Z*`aZdX*Y^plG=wew=V(f%-$7Tt_U(}a5 zq(3G1V+H$hrdH^jj?nUcTooVF`LF_2_M^>N+GACyx}WB*&rYjRjpWy?7Tw=3_fWo@ zpxy(BIM{tJ{R0spw+0bB5AH#3EqcUqk0PR{JMSshvknoVUOigZkGcCdF6$A&Cls~v zG$IamGo)ww<)(;q$%w7E!AdrwU;1&ic~57+uZZaHzLS2V9x=dOLMstl1-~b`LbhTe z)Qb@j>bd@$fBln@4M9Y>uM9;*AGbtj)gfqDf1aT)dz$$TM|u4@8w>RgDIXDG?uQ{F z%>8gQ%5r~1L=HcRrQV+qaj>h_+(#qgV7FcxgO0FzN1~(D-;{OK8;c4oSBS>h9LA&L z`|~9Jo?1BpO|V=MA~M}aItotgpPM?Vf7lNZTQL##!$d?3aIspMgr+FBr$$|jh#YsN zRDy_s?gg#O$%x2ycT1-rg5Oy^gQlYC{aN|Xl$(KwaA!Wfe{MmkS}CH@JorLPThD5n8PN-cxP~N?9&Ku~y#S zW!wNXfa9e}y}L}5Y0Ba#&q}T~`Dk<&`!D@;hzhdo4up!clDkC-nx4g2dR@qIHUpJr z(Vzb;Q-)?|F>3#2VGbg~Thy}=(a$}21NF{9#DVSyjchI=dbrj8O*bmHHY-rd#u=el zO25B87#+;W{99Bvkh||d-oRv>`KVxE@~l{hCJf{n;y-T|p(z6y zng6U-jEL~Yvjml@2mgk=45cK0SBp?QkZbuX&$3rsHcv>|$qIHt-Lfg^pU*N-W;P}L zr@Aaukj+uoTklj0(S&TqnZ6gJV%57??*~iJ^lawtzx|kjO0$!@QyH48oWBRmL*;74 ze_K|8Qp)*HOA%U@o$RBl(eiBeroTI_K$WWJkDe;DQseY*e^#MtmGp08R--$V+ovP@ z?@m->t<<7**28-Atmfd~6Ky~ZR<9AYSjkrOnf3QM+NL)BJFD&J8?}<9{&t`?&3%;A zj=r~6cA_6_4nLw@HtOBz7t8&MezSVNqgW*;sZE!Ia%^wrqFmD;G{`g<4K@uyLpYW; z>xs+_MMF)8phM`vzddln&@j{R+~4*d&havldcU(36X7|e6Y4gc{pUXkXP`{!3-yqN z22167uRjFk4d-Z`u1`|=XtZ+vco~BVB>!|#h*HB@&-B{^)S#UI9M_0m8lF7Oyo_E^ z&OcB58xi3X?M6g|=blaIHMQx#f!mClRL?)xHKVsBKk9eTmf`Hb!}WdmR`h{JeOE{H zA!-@UF<7V1qOIsNX}0#t=V;sT-0&H1JNiaB|Ae~(wW$aH1lW$gAI{4EtCm)Fq90V! z|KjCGv}-t5qFzOusk_lHzMk%zzoOr)hu=}G*|ts9IejQPG+8q@f_*fC9u{e2olut% z^zNTqyP|HAU$YFADj&g0__re!XyFLXfDiTA_d-PUavSule-R?Wm2VMRY$cbVOGYH0MK47u z%SC9p#^T?ftw5C{lKozVR%$H%dbSD?J>08$lDh>F-Q6YnUC^zlT5bC8r&psI<^0vH z7B!6Ey8GcP?Au24l2&Pc4fS3|uc&wbtDS!%A{=!a5i#67qwiBUp(fj#&1j28=8y8N zs6})Ci_X+m6s!02=gmC!SRO0?vfgnPqBxJ3CpUf{pE&242qy5l|h;W>Jg0|(eN*}A0?WirEk@;TFH6?M87v zPsaY6UUyh-e8pj0iTvl=6e13Ad+FSaP~~CF*57lgP_^W*FRM|_Vd1!TwW#4Ro;+%v zpk5;)!m;}TA`Wy1>GRl&=q1a&jEF(W=KP3z)6+qa`wx%At~!_nbD9G{mC>MjW&%zV;E=p zo^u2`!g5D)l{%6c`R|J}P|qXD`8RF7Q09@z(UXM+AIVsTY5xsDc`E7O_vE93Ba?eo zAxa&Yd${XSNy!MsM<#Q3N1>xkW4V5gWhK_?%}FQJP4erKfqIUm2Y+?zg)+xd&tFZm z(BQGjHF5~bQ_1vI5f!MUzXuee)YxPW5n4VrIp$X&qPMGD%h`V&BEp&aH$;Rh*!76$ z>6WPE4XDy`Rj69^{PW;yRAY0fMQbz`|AuZYsHKL7UIp+PZyf&ex zv3w$G(keBh7O6z<1X>XhN`8WNjOCi;eMoL`1m%?Lx7YbVsvFM>7Zi?l(e} zM<;uw3e_Ar%)nedm7-gBl9C zmUsIDBWpyl<=nC8*rcR84jpG2hsK%4qw%KW(eb7e&| zKmTcPHLB65=c>P2w5Eu)ULvhU4;CfI{6En{MakXpVf085$Jr4F(%++~u84EmKXKNh zb!uh7o#fV|r!_MFPW%}}@PE3jA@?k5uv{aGHEMr-aVMe^ll62bp_9n@r*t=wtJ_3I z=AU0Wp>7i?>94IBD03pS^`9HF5YgM+su>+TF}Hvi;ObBzTPdQydmTk=H4y2Njcm2} z+}Gq<*@}sDJvWeg#6Y+A-qd5Of#~msqDHn_h;%EhY{l70Ntc6&{!VSWTtuYnxj~3X zx9J8WVt_k=Hr)_Jgx-fDqQ9G?+#!tR(4@bJE&c~+N?xhl2td58!rk&lRhu0|yf zqZK0EX2e!Zgp!9(%&j9b-3yegXRCqe=UP!CTTMiI)Xi+AMkXaAwqk<6)}S6nvGPQE z?uBfXdT9=2Y^8{>o@~XIbALocf7h=Qt^5fQ{oN7LXhaNfr%7Ybkx47=DB2vG%pqbc zCi=TYDtR;_vfNG5F{r@WEJP_gdLp!3@=xL`P^FGDf8DJ@)e|`){b$3~sAghvcGRME z%K2xv^{8PYXUAjeuMut33XalUW)o^s@BX`&W=8$4=CD7xh^+>Ke}hWBMz;9>r$f$d zMJ;OOS$#5XMcdT7|E1b?v_rl7Pd5KUM7GKRnkA{RG^gP-=;-~2;Z4riIz=HKI2`Dh~94G-)ZFwKtUW*={%qsb}RF9xXZPay$=OK(u^F}4I9pLXId^||x_WR^&=lUAmfgerTrnz6_N^;HCCtHp)^;bO zlTD|fQ%qCQRMRvx&2%a{)pQy<%`_cNH_bpZOsAvMO=qApOlP7qO=qFAOn*jyHvI+t z#Z-z)O=a9W%GeKQ&0+4HP|q^vUM2NHhgxm~BC=eCa(U=*D>)L4vXXy9XISpcvfR}9 zNiJe5zSeTf$=z)^w&MFO_W&aLy7}5i|DfcWBp0!jYPWi9#Y7m{x9Iz%q}xf!T}dut zEB+;^=YB=MCpi}*BFx>*zH^$*w{-%}S2>o6$;@^jEM|sCpL1 z{73o(y&Bcb;;h<3*QHw2FpKl{TCG_l+Bl2zwpgDjH=);5(*HhkGip+Q{x^`#s6{>a zGqn|Mo0VK`wj&}u`G19o0qz~Warqh%J=`Pe?;Aw);9tg)+kuEs@_&d3ZwUSm5d+*T z{br#J5ut~6M1-rdrI z(_A#yG!M;7M&`~%=bFw#=b7fC`KASE0sAlgrsjNfezK3;1?=|=k}GX3YPf)-&fl3E z5fMJ$zJNAfKs|pa*@RwG&hMkms7X2hcB>g}xqzqbmWR1FZbdB@u#a}~yCBz!+Ad%} zbkt|VcC_n)W5#TO*|)|KbRL@$2M%?frvdzLeYcBvyG`nb;fpuG^$$7M@@K$%uD z3#H1L(b399h#2TjmaarZrn^YG3K0X`&C)W|pf>BJMiiH`Z(o&M1y9BmjOiGNh;Y16)a4~Qivu0 zP--DPoU3{fYFNlh_$QV|v~giF>P@K0N;adGg~?c2(KfZ|S7JMAQ$7D}bvxQ+{q07v zTJbY-7c%M#ZPcjSg~>6Wfif@Tp8B;`J_{9Gn4EuwXu^dYgZ>k35h_+af446|(=X(R zOn>i$N+tg}rwpa6l?XLl$PwZ{$2Fo@CDWgoFG3g5X8Qf-BJNd-*ke^itkOtC3~-+o zqfv+$;JTiK{)mWd_tlB$Pl(8Lr%ptp5fNG$gNRV_h()=nBUN(dB%V~+ioJZjqsS2< z$5u@AbAu`A#v&s0el#LNo5!F6<@%`iLPQL3Q#FTU5i!91X$m?H5g|7Y5n-I;>77XD zBDP{8)4icKk4HqPcLE|pn-kDUmYYZ`rzCAgY{f*T%POIjspu>#`Da9g++R?s<;rMf zrnSOWO!Rj*Yb>+S*;eu#N)qWgL~O-G80TC>gmKP8M40WlsN70cpoP*R^>86=66tyo zTk)kD=U+63rR0cAcR7mKimymYx)eDg-Acq(?B!QJB1dGp9qfmQt(XYoypkNz-*w?Q zi`a^ZbV+v=IU>`YtsGl15$3QAU1zQQ4G|%CJ@tqI?hWy895pwMT1qKO)j4-2;dSCI3My zL}-PrnBbQWYGn-~2Dn}t^;$|2>3R`cF_GnVsN{p_p`;b}FeM*N>P2kDUVdbC9#!u*~@&m|?@^VA~_b|ZL07qJx+ zpbAOmFwUu@w^` z_YgTEJ?cn$*jizW|Cc72k-M0CCg<-Y z{TJt^i1c2Gqyb6Eh^?5&c0Ww!D9=Ph*ne5*V720}^8*nP_DVK7+;St)sH9EzM>KkI zvVvm}5%$p$XuQ=s9uZlttL||pppz^&k#Q2~V>eFI3H`-NmQs@F=RV=S8L<@; z;p$d~h)g$8_tcq)=;wy3zgdXj7pBr|G~dR#fHsMA$%w6($Z`j1mCh$e3~;-3T(cDu zyaDOL)5Qgd2>q2KBJ@y!h_Dh1(IT}u>tx#eD;jaHp!3%h2W4$`yzRB~yq9D-oe^boNXKVV1XKM)b>twBT> z%Ubl{Vy+s+`p)K`hzPld5D|`ohv|XHbVZyG5nHjBKRX^lM5y;DC5do$uoV;G$f_ep zq{kAm6?>^oSC1aERvt%0xV}7rh|t56wDMGvi`a_2{FBZ)M1*?l5fSP=O}%H6T*Owq z!E(=`h9u`2QH!m7EA@ylYPRA}la+9vkt4!v*@}tK-{*+PbpOy3{1=OJYl!q&T+3D+ z!Ci)xuV<^la*b@o-z06i9cZ`p_Y?gQnQpH316#3|U%{WzZ`S+ohzR>VrXG>$&eN5F ztp+b$Q5xCe|1=}#uY+z0Xa5pD_2^euE?ScN1Cf5G^CMfki1hEoceC}2<$h%=zH&+O z+36}oEx+)P7zTd1uMEI4^O^67iz8MkW_h%~+5$3Q8 z5uwdn5D|V&e=8zF$!bJ|Z=F{oB7CcU8zRD6;@c4segSp|BEq*AcOoMEzUMAPgl~H9 zMnqVp8bpNktVIpV`QH~bA|kBI3y27Bx?e;@nEOlU6`T9N5fS#-Mnr_UZ$d=q?>~qL z`|njmgnjfHBEl+dMno9(>xc+P&l`vcd$S1w-FK6>>Wge z^?Vl*;aK`FBElZqf{3uXTM-fF@E#(rg# zmeKzuqfMwuIscBj8NInQImg~YTb6QG`Crs+MJ-D?vi$eqt!SIIxgE7>e*SkC?P%9h zz9TwJ&yl-PY$J1*q08(jxEx(>x&mEcN}-e~Vn$Ii&Im1!I6nQas#l=Oi2Zkh`l~|K zQ8I_ss75*e?}%#Anus;?zcgHn9*r2a|D{D8BEr95u1D+CpMQtD9yLUa#s9sTMzm4& z{J&7!gqk!K|HiBtwWy^3cRj6Wdz4%YzCvv(>A%HjN3nWHzgxQ!U1_?CW9h17wuLBl z6>aXJ?>8fK{Z%||``;7YfQWFs{2f)Qp8sC03RSCK`uE8fPkzKmYEiuw6(H2;l=@GGc|hzR{{LQTr~IW(g!YQ=vqvlX>m#T(DX>aP_M;p+X# zRk@kluL?(U7F%(fTJh&?JKCjsH~FIn#YstS&8);SN}hGSRsz*Y{@ATY>!knZ>D=SA zn)W}wBBu}yqA1Ofo^qXWEk`_07mA_~etLX6kb#iS#tu-{d^rf| z@A$exsDt~um^yTWx=$xR|E-oD(AnlY2SR!c=m{Z>*9+<^@qAnNg9c3JGXJwD}%}<6@O}=0vbD=G3bANI1b87sl%H)Xwr1%W@9<`I~hW{x=n$mN`8J!OoL`x zOU#1iSXt&ml|tdyhfuZD-9MwQfl?_;`x)fmW{~>~Rz?3LKY{L@5nFI3g!FePv!KN@ zNX5^9B~bN@I9_U?WfpHa^udg{3a@}x&7dsdDg&*NRQz~Z3)NY?dT67C+yphuh&{a( zO2y|-eYn3tf1?G%`OkZydr8w@|J+R870iVEy4w_LJ~P&+04kgr$4e2^VP?#sBUC&y zULQ)JlV?)*XJy8n0(F`h>(CiGL&(pnE>L!6oc%edr^NGLd+P=Dma_P>9DSg^GwDr# zg1z6&_6J=&lPkijoXu{5l}Bw|SAHv$V5I}1&kcl1B^BR)Wl*`0?}rL#ti@K2U;d+E|4>&%b_(w{>1!RsBR{AC|~%M4@%AFX3;CNXu%_|q%1{Hb{1z` z{1=yU(ABdzv$sRyl|rb!dqF~$LHSwST~D|QN}!puxH6BCuUO22DlL^NsKIjB3T-#v z4k)!W-F=Lg`(o||&_VYxdO9>C_ZCp`eRAGg@+*Pz7BYdJy^oT$6yI~uLh~(x7T*`^ zw*;y-Uk!wga_>sKHzAa93#ATkLFg#=nb6x1()zsvA&vJggf#c}Af&!!5YkkZL#yti zH~%JS)(O+xl~8jrrC>>+hp)uaY`^4IvF#2O-V9 z9@=R6ZGsxCMq8on=Gy_K=5zP+2KD~fE3Kfy`(qu7pyKBa zD1E?EnT>CDoYM(3b#`3qra?1jvqJl?v&@3#NJ#%Xsku<)?0B`Wg67XAO~2n=04Q0OYtK@s+I%(8ve{hY{4Z;kL#xbJ3$2lO;ram8 zNy+?M*?MT>?08Mx1T~1yufkiQ?NTFu(tZcD)9StpN`?Ft$UO)>NFDqMVfPU95LXg^ zvct`R<`~W8N-~#t{+iPiYCbpCuK+3(pTG7OLB&G;%3K0D77@*!{q*Oyc+g``aVogpXz^=2@QBS?hXe+r4N&)|B_J|lz%vuC4nYA z9CMfqp}1p#raa7;zx+wA2zNoKmFq9RUYiP`BV4ik;&vK@^a;V;5YoGXd!XqL#}=Fc z%{1REXwJior5EJf%v=cV@4lS{Jq$f6A^o-ZF=(E}dmKVq&PoVr?p4ryOLGDAtkw28 z=y{9x0<=)l^k)kfLDkk0HPD|ze(a_Y(%)>kN1#VyPrFB;J_Iy^=y{bZbVgo+dqGa7z6vdVj8QjRt`0R9$5YnC1Qm9%==66;#(5lDch^&P^v9f#$t+CwKLUmFHKj!Npbi8|2a{mVU z&Pu%j+9>h-c-aK~W~pq0ez%_f140M8VRG_sJA`y)*a7Xdc)Os~(sc7UFEx)k+%9LA z8$nIxG2i_Y-KJ3Ud9mLMpu%~yi+`?B1a+7f$4f`(=y}w=o6NXlpyGLy+ONVTP$wb3 z4t9pJLVn%NLHT)cHYU(i^G$7$1BI4D>n!AYD792vC0_)qWX$`MK>Js=&!g}zB*98iq07pdjOO@| zzSYA@k-jx4fRMfsJAjZaqc6cqk-oWWg%9b87p&Bi&;3w*sEz9{Z}qTJq$N8H9|}1n zSSixnTUWNPLm^~6Rs#yDY{g2==h{F>;~h?#NY681r6{~NbZzk=%}=N(#!IkLq%}GM z-;pu*1S>^Ftbw#yJAA0nb(D9JSZVv1ip$_bg>K=?-2Y*vh?9o$rndvWj+Q1?iuAO> z(a7(qvVdqhqUfkDbhAO z0V<7Yx-uwlzJz!WMPGuIpA&rvRysHO+{5^g=8l!3aEi@6f)9n(O@tng@e-_R6yC$u zV5LY)R$1A;5{24UVWm~(dxBK{E;%fe(=b>m(vuHQLMZs$Q?wZhr?qleDbjOfPeVvs z_ZiYe!IxmANXs%G9|~tq60Gb3i6`eua#&M6$tjF!SSf1HPZ4POe?UlQ+&>|tX+8@* zZ{>UesxjZ2A1AG|jD~iNYKH1dIQ% zjrDU?P!(4af7j!lfSxe=JM?#>C!r^eo`Rl=S4#IZ^t914&@)E!q4`D&pan+%fc|0h zPw1aU&qB`{JqJB!^gQ&u(F@QEMhl^ZMvL0#PhF(7O|Vi={=NNa_>lH3R*JOWJ3%O{ ze6BMgk)Es)I>+K+RihBE1}pXC$8OI>?XxKOa#(p3W=DdRdY_#0aJ}#$EelqPbl#qe zuXju(!Ky}K)vv*-^FCR3>#yG zNTeZ!E{`D-tn`ZLb64U+dd?UtMOyc(Af%^nu3pr>5`{WcVO4vQmZ-r>k*;o~5Ykl2 zAf)BIhWt<{XM&X?T_dl>HzLMMuu>FS&|QZQ>9qqZMVjVFd`Nv*Dblrk6h5RaAv8M1 zOR!R;<-DF6jfpK$gOwu9;pRo{o1?H!6krv4lDZdR<;|C1rAW^t-hvNl?pP@bS9CWP zAJS_vR*Kq&GfLy|A-&27-C?O(c;x+nNhs6&F4B0Wht5#KYGCRXan zpF*4uy%poRw;`ls_Z{LP9iLdKC)owLcOevN zh{Q^f`c~jW+GbcO(mwhSAJTYODbo3{5+4e6NU%~*einZOA#ImcP_5;+8bWQIKjHQ< zgfzcTpwBGi=MYlg8uCNhN?82gb*zJXk-hhek($5A-up#D%GuWhD@9t{0(?mKAy_HW zemDRh(iIacMY_X15FgUqu~JX|bnQX-kjBGGk>=L|AJVael_KrUgYhBlF{~76n;iln zO{FCv53^EZbA^ONy5Gf0QMj(SA_!@`BM6DKH?dNrEq^3F6y{Na zRqe^IQdp@czY}jqNTjJ?rASjb3d&f19U!Empd;~0V#ow5MOv~G@gW_bSSivpPr`>n z$VBMm=u5Ct6ne~^@*=XRF$ihh z=RruXK#xO}7P1O@LeliV68Ssyq>yikr=X`Lq+b=Ef#zGu7C`?H^1GmaLeEObL{6qY z2O(Xvo`;au=miL=Zy|(qb}WKkw2&`BFIyd6f&L{q_&fc@v>?)!$4WhU-xBCGi}yP8 zuBG`Nv`omKpj{5VZy`T`R*27!!4IKTR?b=o>9}4EeQfbQfsoekQ)rEaTnnv_<#b;| zbr!N7+9>4LzoDwn7I8`Rl`h zP)qZ*f(j)SKO&2uV)6O1lt5XFmxD?zWEqrNe(q)H<(RvB1$xEkRp?cte?k8;S_~~V zS^_QMz9W1M<~8Uwqt_|*>yUr9+v;_`*Y!I4tb_KG?{(orN4n0CI}}2}=MIBfzaHPl zw}A@HR|I8Wr?!_r#gn}plz%-wg-f7niFdAiYqbV?Pso3Pa~ZV!_4sz+eJC}bdjopI z?n#!C!%|A^pWfx5zDvoWtvuuG2O)k}AipfV5JGL;QBOe^K^HIOnd75E{g<}Sq9a@x zl*39dwU7fK)Yf&AkOQI8rSVB=8I)fdpN%F^x|EXnCy?&{p#P&}XUV%(_isY}J3doQ zp!DB_^zV;cHEmXn?+N)bO%a5&oJT;YwfmRkek9bcnx~SD5Ti~E6rMdIjFRn z8u>R(Wl&yx{&ZLZrPZ{A|2nm+foe!)v3!Zcy$QW(^cM7%(c945M(;rH@N6{vQ`x)F zyR?h^iy03np!dwT3|eNs<^1}0IcxGmBMJEz2(@|`#}3j z$h}8F%^;+y><1x!Z7d=8hYqlK2STWoTPWY@J_te@uLXoO&4ZyJiezq2kN;z z&ZAyXU#YwQu3tas;^jQMZ}A*y_J@$Zo45o*`fa;QA*A1u9RMMH>pT!D74j=e8FY=L z;@@Ii3k_P%j?~KFH=~f>7OdT#B;KD`srlS+sC;=`Ln@$~BtQR+`7zK~ zNyWcI83*O9wh5Fbu`MU8b5NhQXOAe<(og__vqO~)W zT@l~1<)FNz;{Qc3f$o$X{8~2&sa?=Gerd8Z)I~!2_B<2Hu8dd19CW^g zya4LElHI8PvSmLA>Gv@&gpkg-i=c}wUVjK_yh|XY-_E=g8es7TLYG;{%b_bQl`ElA z3t0x0uVmEuFNIb>W5ws+I*(h)(-EnWe2*o;N|ByjPM}mmhUdEs&T(w?=X z;?H>$K^=sA>vn`tJGV;4+0jsOE$!+5xu^u{Bq9B3(;3RvlBVCS<)E%YeqHJYb+4tZ zpO>-Q1L|wOe$aqg=E|>u~$K*<|~5+)zZ4n_b1+9XlO0}NOHQ+ zFbHYx!=dupIL9iWv9+|)J8~{)9E9{t>8(({mbauWjc+xn`0ubL zP~~ds?pKs5X#VOrZx=u>tY)Tm=Cq_+2q8UNxCpA2cz!K8chOobsu&{Pn0Gg!Ej= zh0w*8N`Gj;`q;Vyp(`xSE1^<}=l?~w3>vhaKJvfX7z_=QlKE9*I5bkoue+llr1Nhy zg!C-`^$=2DIfUA}LuXOj3JB>Kya7TQ@4~f{(9KrrTcEL07Jt1R2aR9P zm*A$nL=F?6ytIqIH%y>PA%DWV3Yxz@o~&8`{loJ6C-kiOo`asZc6k9>X!TnJEtcH< z@4%KouSuKvZ|J`cA?=4ZpnprL*Sx?AT@4}i)j-RnSNz$n<+$j5sRQ^(0=CI zA8Kwn6hH^WG~I#FLFQ`#9W1HbUQQ~9KrJPe*MwR@t%ZJ&{MtZ=TYhb!LQAsC7$oUUeLLg`+3l1 zmiy(<6|sKqN(gE0S3$!q%@GjNzP*liDUYcnSSeCp1wItoI>Abjw%HBPSPMB0nqyelI{s`+XsVG?hgVDsp$qxPB2rVQ#vY zpqDL&SD;tFW-VwUqu^iAYvy|$LTy|-N#zabE%Ut%y<_>k3!x+2-zDUG5Yn^amNn3N z%kOIl>0GIUkmgViAx-le(nMiBOR!R;X?_cBjQP1u&}NIb1xhVHR|nO_+PZqE-u89h zK;Ka6@CCqcp>Me!h5MZEpzn+}FsC=fkU8j_4W#1F&-a9ouJgU1z8m7ssvm@O=W!u~ z^gmWEf{^+yhLAoV><=Nml3W5I4S6YqT8FRa4S24rYx`EpIGpuFMFp1~)t;3;m z@%jBk1vJ(|j)Nvh?*4bew?p|2Tt)r))&xR&=6NE7^!IUhLP(zsO@gLNJilL>0nM~D zXF(50y!rBdhS|`Z4RJp)7pmOAKGOfXvI<&g@fJbV8{)ON26|uGdfPuqFx)vRX=e5 z?!S3k1a zI4CbZepubtjd!hR*mHQ#2WA_0FX_~Vkq+g(Z5c<3Mo`g`F@O#mxp!t%f|Hs<} z5bEqEO8x!;A>FC|6GBJ15nJfnXCb8f-RB_G#!Z#)Q9lnM{p!LC5Ykis3n8SlV-bY( z8Q_Z$(tnG62}1hs3@<}Se^LAjg!Hues}R!D=Kq3F8~6BD%DEUqdLOq0LT%js3-G-L zp^W?CB1-)_g!H$vZ$L=vw-iDrx!0s!{trUBFaI}$bf#8ANL#4}T4p`A9Qs&t@T4@A4Ax-l? z5YiF(D}-9P%~JQ@Af)lOL8--a8=;N(diy5}o0wIbXczxW&jd<0QBMDjPxmA9_D6jF z*B&{j>yPpMN;jzQkBkZb?xi0z;Kx|9fl#T$^Z#B~29^Iv-}+Pd70^vT#*&SJ##+2_ zP+olg>zoOcN-Dl&?kDIcR|^PJTz!6hiuhU~j1T<~YL(ph6+P3Ku~g zHZvytH!V6s#hc?ZvJwbsIZuR;KL0!kI$3;9zHo60gj%{i3ux<8A*BB_cN&EBPO%e& z^q*@xLtU)YXF^EdwVnlK&6k6EZjRTnUQl20`KK!VAf&au5E@{$9SD^Q`Dce^&^1<; zYayiT#2{$!X0CDmx$qEZsHHg!x=!Nx|CAUBjoKXN*l4KS;#ELnE#5fjHmQ++i!mO` zOIiGL;RH%IGhX_?Ed93y+Cu#flNNM8Lq8k+LQDJ-DF>DQ!hASgLY6^e%{LCpn=gST z{lXRKA_+Mex?4itD0C0>w_jwX6}lHf>YE86_057Furz1?(!TlJm|p=_I^W_gfa;>p z)k7P9p*>%a95z9zrQ#Z>QG?Y8>e&!O_JYoBi1j-UN-d<@3T+c=l7Esl@_Afzk%fe_La^B@T6>fHiDIK96}ne zEmUanil8Gbgxa@&7mW7>^4?D|E-qepfk+Z1;k}xuoL%>9GK6X}Pz8vJ%g?d=4s=kp9GynVBp%CJ9*z@E* z3_|)X{NYf!r0K^*1vGXS&j&Y4D~*G0myrH#;2ltYR~(TEg!Fne5vr6_{M(NzXugG9 z04>}V$HXFN@h(QWzXw_ZRSShLIY7&#)c#Gya%h#L>8~WU5YpMW8bbP;$&VqV@jii& zj=@hMr2D$hAf)s5a|r2|z1BcT-}J79zS_m22d4MMHlug##}A=KJke<>~T2ZZ!@ z-rFIh@4$BKYTp=z-rNf-MOx1PLP-1Je-P3d?S#<&ZnuoOT@X^=ZU}L2e*nHeA*8c0 zg^qJFsQYK zYy%xGsoW>eY1=}DyLo52QcjB$L1*vgs_oB?oCEc=RC+<@?q*H)r(w>6dYi8gbiVkG zk~A-X`dT^rK^Izn7eSX=yaBu0=TS&A!Ag;qdLVR}mHKiBY5lH%kmh$Kbd`m?8Y-3i z{Mnc?Xpog#+W2Ckf&<$3<8=;#l%`wn@ zmdgFm1Lm6zJ!tto1kJHj=0Zqo`!MvVrSce5Y4NI{`LPAv0_a)uJqIlm@+YzuLGN0c z??KC~4$GnUt=~U@KC)I?1tD#T+THE5t79rTtUL;O zzB1oBXub8{*HE2=^!-;4ZM2r(1f?-|rVjKcb1a-yNukupWm4xd#qLO%V~wEZF4Njw zC{zF)Jl|lJNnWNn!5;B1%itlZqJE2O+;TxeUD7D(UJ)k{e-QAwh zo<@zK#zuQVdl~%&`ioH$sEJWisHxH3(B4M-K>HZ&3+-#v3~FYyAGDv*{?PtL&7tN- z1yF&}0nh_bgQ~m6eyD*~NxWMn_gV<)n!FnN#C)GZUr6qLo&OT5GhaQl(c*1_Hd_u` zpr0kA@4sIlr1>>KNPSx&q`v<^NNf8mgw*#NgfgyC3u?3t+Abme)nW&fT1eN8QP(YV zlItKLOQ38wLi+1b4(i&CRDP_W?%g1Cgu6^ac87YKuMdROcRqBX`7VO;-C}ztP-VB+ zn^jP?`D&mQ67MZ(>kpy2ZnX7nl3zW9GVVd4Zy?mxy&?21^qr9J_YKhZQkE~o_XC8~ zw-G{mW!?lK4f!L4G{2vq%@%J9^s|-h7ig=+`w#T1h5QXdn&vhL9pU~bW%(UK>iYvi zTC(lX4$J+&5Ym?TAGFir?Sgh&$UmV}d=2vc!F6ZmbSGZxgUPQ5s_q_Jz6N@~JN5Is zpbsF_+I5wXE1;F;`v_Xqos#)oLM^n~LVgUbv5;$_FUwW@s zs`*ZXvgXS{eS2iuxv|m`{h-nwl-ifG49Z)`1gaLF-!IibZ%XcuNW8b8RC4fh%$?23 zel|Y8Y7{_)XXEoLLlKlUUk*are_f$&XU9@^hmh_ndq8>fCD1eC^Q*>u2(@!HQkDhK zv*P$e3#~bu)qC$l2)P!jvmEN7jb~?$bA{sD1pQ?3HbY2TVhhw@ zzO7Jdskn2f`#EtZUIeA*#QM3Ov_#KHIjCn(TB*0>*9*#P+r znQu9S)b~Dwv>!fzR#}?0P+d>1eqW_WS;=~33Mcd;6Al5W5YmuSA*AzY8g#e$?tx}nZD&Cb^@?+B4m3~V`BDBjR4L@g zbrqCaJa;Zv*mGl>4Ts9lrFGXy{VJfm`4VW#xtUgOua@}kg6=*yQ{)a8x(AwWz8TQN z=6eKs)O?RYmFBC0>Me(Fpl{EOrTz~3@m$)|&&{78r1yB6pR%rXVac=H_ zcAl&2ncD@W=f-*D&f|XbJZgK-W%NTM2wSo+&NS z3p)2a`fsSvc~GgvD}%}{UImmlUjn7)#ZtT8^*3iq%vP+CmWt~`$@;`K zBnO?_Czk9y2x%YnhLG+6`#^pB#6Ic=4d|0O#nnpg1EDMW#PNA0gf!k&P^qO^23^x9 zw)M3T(p7j+pG;E}=E~k!Dbnlo;69msQAo2HRyxe$4Tq4fC?g=GE75fj(o{x5sI_a{ zic*h)uD4XmA*3_40=mI`H}=UiK_U01Sf!}7Dyj`VR`b z%3rZkqR(4D|=Hc{!Ltc{A4_{5QSA^5mq`;N+$ar zcPBJC`rH%iS2jfb#vRpWj_1(479-o^CEwX(6kiy8f}{>mj7|`vyuaq`L&VB=!`;%r_7!wRmOFFv;O98Ck=jyyW2b*9kP!Le7F77!a?Nv!OXwvboTsmcwIE zrG>15kY1;sfT}IcngN+S>gd*!dxDiBy}rK*y)}Rpdapw%+1pT^w3)xc)fD z>(S7$mdbGu(yQU|5Yik@7?{};9p_e3hsIbb(j1B*q#;Y76QwME*LBjsOa~P9bse#a zQJ8ThSSiwL$H@@Vc&9+8T3JqmI!R0TJN?cO(%PO5Wvw4_Q0YKM`G2LY%b;=z=~spd zXsm@CH!zdEHHOS#rAR~G2Hh^cV-F*hJD|Mz5(w$soCr;o8u{lo(;%dy;BIK9m3r2| z%)ThZYlf9RU}c#NJt*<~vx|qIIp&)SA?^2vA*8$QN1#ga`RDUh&=b}xf2YS#$geS0 z`lR&VRZ^p;pr<9C--$m1&5wQL7C_I6&%Ygb4tie5_vQ=GLK~lppclpGTlb}bne8vf z+;?E5=s0(`!r4SC-5~?7hA&BL*Ll^`xg4na@YW&Vz*XW>3az2uImSAqlEO&5;j3U z3i&rGKS7&g-?}Z(&sLUSpau!)pQdev{v+i3=vN5o{^d6awRKHellwO4cPsTD&~~fs z4hU&W{1@73x$lB@+vxcdN-YO>Stf0KSzJ-}f(kDqKfe<%g3`<4_0L@nU2b#*A+Ly3 z1hu<@c>a#)s4FssD6A+&SXmTCatsk}uY*R$ z8o5!>9p=kJ=xEnodMtq|uZX=_bw#EL3NxoER*Llb;1dwiG5>eyNlDW`b9@R~XgMr` zs;%W~pgL&@zwXvUNT1hz1Em(yT}h8!Nq!ed$%>%rD`WrFKy_D=ihn{-52aUXf(oySEnfs3eO0{590TRAifJa$of6N_j!6*GnKK!hZoU~%dR1&CcQyBa zS4UcYb*6CD)s$MEG8SQ_4Ohn;wnFLE@xI@cQqEHH^Q%T92x+_Q0U=$5_k{K~-#$=b zX-u;S>QG94e)M#NvgXS{-Adyc(!Dg(5{1#)3M=hlzO$ix^tl9@SQ^)mJE2NTvkF2w zlAnN390kym=6ec4+7C~cW(rY=SA>;E;rgCnrAX`WOlhX?{1~zyR*KZOfOw0dFTtur zA(bku)O`F&0#}mgb1y;FR+btF>B{#e^tR+6_iFAPXqowzLtmM19aLv6Q4gWx+z1(; z-#`u4f?J`qG>!>ZMo*W;{ZgZ{OcsSU%VDLh%VHn3feIz0e*#hj6$?EmIg~(1cSt8f z=xFzE@tp)^B^7@y&O!OII6@Mrx-6EZ2KqvB_oMtvC@qVl++CANn_LsADb)HJLi%Uv zZJ_KmG0ogHnQS6H8M`^GJPOx<1S>V4n+R1(n*N+X6;y4h)Ie*liEH^QLC-*JGt93zWU5YqWx4j~<_6_o787_tVd4uv%9u~PH7 zn;@huKL$dY%FPhcw!Q^In)_G?X*tJ1dGjSuwZ!u~mYPACJyBSR8e=s_;YnM8SSXC- z7FenI+?&ucNyU$)<ZX_~7cq&51OR8W|W30C@PjORWZlxdB^v*tEfg(##_gq1g6f;Gu}ld<0T zTv}en{8FrH6ndowEA>AAOkfT5L!l)aV`WiTiE>!oQRuNASQmSe7VM8T0EIiZfmm0d zuz$G{YmoT{V+}*01&3peG~Xz!auh~L1=d*ejl&v`Ldhmz<;|C1rAV*uYiVl~R;gWB zDblAnUqDD({!3E%Dz;04mDX7*_0Ts`2mkE%TL|fB{f?GL!B>QpB3sDAVNESlgyp>2ET23)(Onw?ThIpW6;0-GA8A!eNjlWA6AOgw>N||&3z!G&kOb)oGC>ihcc`@3h@%GRVak4#Y)ZR znn6hGupiVShI9u*Nb@@cLI=5a(()~#(_#*;6Rm{8t~rO5A}v{G=!{r0*9AhF=9$o0 z7BUMVZKWJkDk1${c^NcL$gf1VLbprq{$BJBC~v-mzC|I2rdZ8UNV5Q|B?|4*3M)lg zmWhKi2@1Z6SSivmcqfFkA0|Oa>o*yiVyWB(Aq_c|vP_F1n_{JRo9`Y7>1dq}A?=45 z5Yim}MjcS_6=0?JS}HRKXBMImauHUFH1}ChrIai@*MO=9GpGHRjGlm=6!Ir*o`R6R zdwCi{n#wcKLJPSFdeOIwjDnY-m!)L>UhNeK>6PnM2+@KP@?VS)6hh{(@+j>260CG_ z^tmNawXdzjtASSeROD@EEmS8y|D3iS+BjI(Pqztb@bTms{8nhY#Pj!*JD~qr4m+XL zYU_qDQ-@Gn{|27zLrBFx1t@@8N=Uy-wSwAN$fKYRLnv9eCxMEG#MUi= zPPBL@K_?H1eR~Sj$$Xum(=E-vLP(#~oB<*2qb^YQA=F)->9`)y*;3B2(q`vCJLWS$_Z{a$NZaKC2&u0xgmm@p2VE?=`%@A9hh!>I7_C)UDbmq%2{a)3+(76uNyU%4 z%b_bRL;^9_eaTJEDD zq~mimG=7L)wcP|LZ~c%!NOxotp-B?Y?}aBrNPSZvq_g-gXsY?94awxuac&;Hl3=CN zEtMJ216I!2(1Szb`|yV#q$h~xKuBvlcSxoX>23xqt+Y~C4axleWDNNS)^-$j;ybYZ zYrg+si9*Zo#1e(obQhK=tmV70Qlz;*1a(71!^)hj)|sF^P$wiztt;%4jM|D{`px8=-{E0+COnQ1Zr)mw1EmG zo?i=!hGzCeS|hBqc&PS@D}lO5D*mc==Fm(X3Z<^c+Khs43s!1AcNUb5K9_^?l7oMf zmXJCMqr3#G6AHO^#!AiSCPGN>yzhjNKG&EuG*gN~DrH!C6jDjB(kapB?t-RApPK>A zlsfp;W)_6BkM4ueaqfLtrS69w5c*wcHiWcqAB2#GdruxYkvIZ-iZ@vZ4 z;^=crpf{u+{AgVYRa>cRhGyPIVO+n1wG4%Oi{)5r%(oWnD-=q$4yz7@`B#sXBAp#? z4$bU~!j8HbR&x~kuK+7GpL+{Jx)QxTG&2^3G{<4(Q7B7-m734JgAawPeS(!D9cS+j z&16xSM>(v%D5TjBD@EE5??KCADsDMcXLYEDzPFP70Bw{U{4CxCZMKkGpwvRTVVV2^ z!*q5eSk)+$tOjcp3Uj3vD@8hw4uo0^i&w*ghh?Us;G2e(BHaZY0wJAoEg_`7R#590 z&$WTtny(N-nqLuggr#yMr9LY960E}Z(N~1E3WXeMvFcDrr5UA00C+lRY;2a#&+gNOK%kdVKV`6Ck9eE{2fSy#zu!Q%{6W8WzXW$q>?zr$A?! zFAJgg#0=^?ERNlN(8W^DaJ7f}ODg^vehIya!kn(bN|A=V6dEuruIvM$E6sNmgml%v z8bTVc6hi7NgOIL+*FZ>p*V0F0C3o5DCs--ckmI1d`24BW1ezrA!Z|r;s^sTa?`hE8 z=DP<%de=G~LK<%dG}GE;7Bok4@O!?w(7a*HBfru<4pmynD$0q%YLj54o_y|4KntVK zErO~oUJbNLYUFpSwb1I=*6w2nX+L}dHJEQJv|Y+_#Nn);JD{CH{weA%D775iaISd6 zNyWbnYz!fN#<>@?w~&7#zYm1Ms^Rv9kouZI&4*Jze^o1hS`Mep{Qk2Qgme#fD0GDQ z{4;?gq4tuBfBKpkp2?#y3KFce!*Cs0t|N4G^tof8 zG(h6{JDY*fWtRKpP^tIH{Yn`$QpjK5M?s?{2Y)@f9=b_<{(0*d=w=~*r*R83R?_tE zdd5MwO74FDc^fo-IJ4NlC7l4}rM7;|C(xb4<2pYHnqsBCYj~y!3Qr50Vx`k0q~A;3 z4WW!1F70^_^f&X}J3O;-=5SUwdAqs^s{w^8$yTh?d~Ozm^x6G=5Yn?-_d`fW`2!GA z-)!hXX%~M={UKg1++$Fsq~f1yRzXi%nomJbOFX~xcm|qp{kH&mR#LfF z>h~P$77mYl??n*OR9=LTUf*AWUY1n+jC%!oRdV-z`!8s*ke_2qhG$y7CcUzl z{90k9NYAak4!t2E{ft`*Ra;qVAf)ws6GB>+x1hHr_ebPcKJP&9O0W3${qI4`hR0J$ z%b^vP=7-QqNz>22kDygTem>Mft1Z8eA*8#KPavev&pw4d6W^E8f}ao1?7K$t+X*$p zDn?-yF2PF8=hi}BM4$VT*^ffq6RZ^Jdy=p4A@yNxMBz?x6IO#Kd84uwYdZ=dcVMO7 z=ik4tgVswtKMKBvkgg1M5YlHl^}{oJqOf8%#!5Z;*8PUEps@c;uu`OJ>$ecn5&0d2 zw8u7(CJJrc7%Tl=$d8a8hG()r#@5YYrATMMPtay5r~hrs76|Do^)rNYckv5^)YkwZ zy~b^Y{$u_AEA*S?z70a!F26%aPa6I)Jkx1=jMo|K3=~?o3s%;XjGi1;iuC%h1NyI} z@;?Y=-0^K0Av+TA49qXO0Bl;x=c2E zoz^dhl_G7I98`K8Y5MDC88qxVO6{+W!=ZBV`QKMnKsR2;81$>+O%T#v83TJHS;%eD^_>=Bt8`UPYgP=35R6poOD!)pv`aB@)u_kX{>=$)m6?C0Hra z{n_gf(vWXJNax>D2x%Su53071HPD|{qZC5gE^aid+h|hpGqowS@90?fX3&14DT`k} z_lKH~j`0ehmg4ih*$QesI_?tMK!u|z^#~csMbPm+q^yc3K*i$o>qH6E$#U-uWhJEF z+vK3BR+eed%+Yb@F$=1ckbZYj1t=kC7UQZ68eo$V>mpXx_ zUQgfp`>JUW($RA_g!J6uJrL6U{B&r(g^)-TWIgGy2@D@=OO5 z=1NDbVielC1S@O499FgWNw3skrRH<@K}dV-esV{89tA6XzfV*|0SKq0>?u}V>RzEp-a2!%8U zV+})LEf|h95{2AHVU>HI?0YJ(ZbBi=F<9fhPoCmTz{;Z#FTrX+A>>x9-%!YJ8`gFd zuD3g|cA^k+7gp+hexJ2Bgo4lQLkpr%&LXTV3eU)LSa}r2V1kwI8$-Hg_>lUrQl$I& z{UD?(!~PJ`U0!qIq0pWQR*JN53o0^AP)M^W*1jmD*$k`Dd_`DU6w=ILmVRbTJXRIs=Y38tcqHvw= zg;nm!k1VX4JjoSd4AvY^@|`-f=pC#TD2##+u|Dx6YtN@xU!hQzby#&M$buw^d$KeVRi5%qo*U*u_)ws9M)LxlYSV7l}BN`Bv_L?$u|QgV@*Zj3OfyJItr=G zz?$iO(q^-;=Af`b&&7Hah37VpVO65gN>x}-qVUw{DXjV4CvCO>Yat5j?jo$k-X}Yj zC0K8u@I8;ESZ||HmUpn0p^*D>tQ9ECjt{XmqY!cnR)hCRD{aO44TYB9hPB<3^z;s_ z6or0p8Ttr?R2pM7K_Q2xSo@l<8CG)?(k#Hb0)@I?iB*b1ZfprrK{XPb3tog=ajYr|x=me}uDC}@2V@*dP_Ze6-JxRaM!g>IO8qLO~kc+X)iL}BzS!di?%Doe25K%vx2v8s{YDPk={A(iD=D^S=8 ze2BFQg_W-s>l07XN}pnVheEs!SR0W(wZ+J;m6zRQa2l^I; zR%(pZ1cg+ZVm0?Zx#AUI9fU%fEwGBcPsV2n*2yS57d{266AGzx#yZ3Mz$o&;UAx(D%rAFaNeu6a#g^-i6QlzPLfwFNvxEzFZ z$JrH1?ON<^;7#ETaR=BBD!qY_;rS9&euK^`S8+q8_y*+P6_jA5Nb|c9x;6UTZP0j2 za{`3)PU?0D=^pwH2x%&L2x-U!s=R^P`kg=(R4wH1mTI6kZ;0*k7L;0=?nc`4#<e^=KZ8gL^m;ZMH}gi6g<2Hj$5j)jokm5+n+=1ZWd zH^%x+gC4&z_H8AEv~R1R`Q}>yRo@s}sRpW(G<{3dL#d?sq1-9Do7m6a6hk(G8sCJ^ zzfIZ;LVDNw7pRN*&b%p8iGr^RD@7XbEC}h_vMhu&l^m2>er^o49TRg;ph;t>k^geU zWT=wZzBk>?q;hkl1e$VlO!F=Xg*ok}LP$r?Gzh8hZV2hvy$AZu zeA}SZ;<;PcZQl}8*$3M17IOE`HTH*^-@=&iZxIWiR<|%B{dXr1g-$l#DNs*~*9*Gf z7IOD*Q2Rni)9eQ!^<4;|u$sDypo=Ys{t!~%B~aeVnLzK}67Lb-gO=SAN7iyEwUBNs zG&a`Hjf2J+-O6*KTVuQ;2?<%-0z@!{T*;vX*8JDz$iJ&^1DS zR$U7r9m#`k&Eyd?ho@8tR*JN52SX_M+>l!{*N(vi{UMnUDbGCuwDo(kwDDYbv=H3mW%_p{8Zo1wh<5-7bjjx%=~JEYqpWuOkX z5l^nut|Nr>$?VY((i$BD9eW$O`?sCPLC0GvCqTuLihoK~0-Y%2f2nj5bn2xN3|81?#SSixy3}-=EiRb504(ckk_3zxeK}cs+cL?dM>H+nXcz!MD1)XcY z^Pt}1^DAE;=zPh~|AO)YsITPU-%a#`kX}hHgf5o+{FAo+5YlV$CC~uz`Bi@)beZ`4 z)5*)BE2Mt@jr*14b(Q2GJL5K3X{m)QgOIM?*FZ>n`dSF-7mx-)D2{7rh@|3cI}{ow zsrWH592#MDxDGsnqz6sg^>2&!w}LZ@{d4B zOZF&))b|)PPkeqvJ`Poi&$nO|g!F9h6VQ`Zqo<(x64L)Fa{+|(oX0;Pq*vd6LP)Q- z&q7Fj&p}8>>+=xOHx(~HNNcnZLh4%ty=WzS2}0UqFGEO6{R;G|`24>0U#t@-%;Kh4 z9Z=|pj#$Mgyhks=>V!h>ow4#LgiNr~#W5AP1bR(!@H60b25B3(gfx{;Af&!eq0g+}KZlU6ifbUG@zz2}N5~h@SJr~-Af)}b z9;!26J@mb`(hm^Q^<^W3bk*1d{b>391Z|cy{jUtR+?L6r(C;~{o}T2o*$XTEIr`i$ z5YqLf0YX}btVw<^b(C;>L{(!byyd4nIk^El>X>a}yLVA_i3GFuDpAgdP zW(pyV=f-E6>^ENDNj1gFqVP;ShgE96GOV;k^tpqfmNA}d1+^K^EcVyi!=XY8Sp>Bk zA6xe*C_6r`d^rfU4y$5U=$!HToX_=y`kJpFRBCCKLF30$2R}Y1KzZ@`w>!!BO!dU^ zw6*N@Yp^=al#ucyu`^bRbk&#zJusg3^!uLKP__ALpieCKPoX+1bv^W*Nq}TlWWOqxm*L4b~D{$7hs}P0xP=uB4jz0G%gtUGsg!D_cZUXl?6Ufi6 zON}6;x$gm$o38@OPau_WZWOAVK+F4mc@^}e`23sDrzT{Yqj1u&0ILHEJJpU@^Ub#a zD}6fp+%pi;UYQReJ+HU`S}FPY>)%Jv#tCsuY=SmRNPlm!1!|Cx{uJp}=#L3;PqckP zru2U?WEs{l6haQi%9}62N_SekT~In9jyiWcx!-QNLk+jb_S_1k=5u%O9_S8A=FccK zhW5IH`uVl>FHn;^Vwz3w$P_jgpY%f!R{kJ~=g;qArG?Swil7d6#I>L!bnG3hXZ{zP z$3e%N?*ypWd?nDycQ9K0cRWvlI$6lh&>2>iF3?%#%R=4F*8?iOgEsSTJIkP9Qci!4 zY&e8;9UK9bo38>IYiW+VBQrBEWs#XP3#;0b>}hMTQuDb4y3^8}1fkYpUoaV(X}(#| z9BCzgHgqn8bf!KG%`@NQP^I~*pl79={tf?g(84*qE=KZ?)qg@1xlNz>1Mm*>7XPyfl+PF(?XKpvkTF9$+}`Itiyl+DL} z&p{XDX+gik?F(IKAuocE?zS(6kj|?9(13iL9Rs0K3t0x03;8ny75Pm51}Tfo=>#i9 zTK5~FeDt{lnk?k!-xLVxUC3P!((B4pXqtt*8+yik^P$D&TLM*^uLeSz!nvaNUp-FMj>PkR{CBH>6SrAuc^x+ zr1`xMeP|(9k^>53H^EAgw$ew?DvMVO)k!=*&gvng*ZyyyZ!N#?C>aXA8mtuQJMRt9 z_tEEmfHqsaEfCT%@iX*`g=~P3rm_`EZDhHGH7lXE{_e9Ww6BoA{xyS&lQ>#SpllM? zf*jN}iFN1(A-(2whx(eYA9QgN*R%c*(z;&)4M=D~f4*lRRBFDmB$G#B6eL)co@Ax1 z!dhs)MOf7+q*5bvO^jEAl_JgWS_tXA#ULmjL%IZdG@+;cp5Za5(psVl`ltDxg{rMx zYM?r6!Fnha^0U}YWG$FT9sKvG3!wbO*q#YAU3~sJJp-C)zFAP^M9SjtysMyUA%88d zfzpZ8$bVVg-N}k_CpD7iJ+27K-WhYqLDhH09BQES&X|gu#4ce{4A}^3GKr9Wd^Uyl zoy3E7<RogC%JzO)mg}T=(|ZgFStm28zyD4D70V>E06RFi&bsD8m#n( z=yMw(q%FS*+AO*IldxMLqmdHV+lVh8eLHWs)#or|-P__7cPuD={Wb3~v#G4YS3@V=zQ>lRRQ&@@o zm*5g;@syb65~zAg?Drbzrzw=h@0vG5=m>YCwC)xtof7-j-Np0ryJE<_peA>byPqpf zp}ocDcR~Bym062|?+dK-AdA-mO7DufyQ$DrqiN7Iqr0KIjqZW&F`5odH=4nYY(`v{ z8bf={pe%k?{RL_wcF^H7bA(5c2zw1EGV2 z%~sH%Gh(kC2DP3MuiE zl@>;yD}s)YRKl-{pbjyl>j)uT>yCzyKH)hAI@W4*9CW;d^lv{-fQlstzh;%p$gDU~ zLQ0!`h_woZveaUw=5r@OCrdnk>gp8eREg);kkg<}R=>^=(re@C5YkrqD|Cj0^ex|I zMrKbGTB0#ldZv)S9-Rect$sPER8sN(8C3>d6U*tYg$7BQetj7X4H5D^Jro*dA%{aF z#OG(gbe;@yT1h35=SvC{F9ioc(pzzDfL#%qODh(dc7VRbNHN33ELu6QL_ zsrlR;&{RpqmwMWaOgRdxa0S*>L~n9knT9pTd~>lX%~$pRJe_xZRrUGDzXD1?lqkrM zk&7USiY87_)O(%aOvHhTdatX{Iub{P{}c; z$Bh%5i4&~j{NB&^`<&;G&+GMh`+V2)UFY1J5OIrrOrAhXa2=lHF7L#>ib5-^aEZm- zRS*g>cQu6cGwd}G(x=+B&~;MMzinR+wMw@BY1qb0pzwA-0#}2=w_6!ptta^$P={+o zp=6WLAEczjnsBR8h^@i(SgaS9Sj^qPs-Uowt8jx+nE7hl@*C6G3S1Y`lY^_E@aei6 z*MmYUy}0!#w6X!0Sj^o7A-#M42;FSwb|JLL_TX($QA+xA)DGR5_U`VwcH&eNde|Gc z7KJzCb+{fB#@UNYEavWp7N_;x66iju=Wo;dp$<76{@+b>LXXJ$`QK!I6nZ>u(>(z_ zWp~!oP)SdSBY~d+-D3L%SzFg4SB@ z>)0v!QRk5*QL*~ zeIcalmxYjiPR&7i>CgYQumY4^XHV_x`8P4wvtRt*R_+TS{p8*6`iV9DucuA<`B(;5 zL}3QoaTSYo;}VOxs_Xgf+UxamPBpF;h2HCMc@&;i1zcpY7&jkre=Vk-4p);n(Zhx)Jn_w0B%R3V*k-0XG?K=N@D}r{J=lp zrsEn>sMmyBJ}Ql^z$Hk3x-%N8k&*dRm4U{mCEWz5R$_jo>!2OdY~3WN-byw=yIh~H z->wkSPuIIalZE`d=@e+H&BWeNR><$39CU!i4!nM1e-!Ff;gV@GXa1ATbm%b2!GHQZ z9BQ<&G(ks8EB-z^2AV14b2y&)L=)X=X0QR5ApNcX6QEhPmri0&oFc0t-~LqNlDw4k z-wYL?)1?)EpPd2CmY6@&bD*;%=ASudvzjP;M^=ZcM`4#Y;F5FFm^&A07V_U!wm=ui z`uXpIE@UQ9xM{0!gHf25YFsimjk$Ty#S#lodnl5z_+1x6SJ2D13T(0#`!e zW+>y5`Dx58fLg7GHhM>4pH<_se~?*{PYgL+a$_3fe<{v0$LcMFZk68sTizlF>73mL zA>9RkhKjZh?a-YvXa2qQE@-jUTLRrHyTJc^`ab9ZiTO2p5JF-7TnB_S)(Jf#{rMg8 zD6@;gyJ8h?uqWAB)wtmn8-c4q;kUywxLO~Rl6AOxq(7^}O|#f^T*Z^TjdtUbzuDe> z40=56-92&r#F@{gb9Ou~K{`v%K}&50mqE*g{JC8Ly(%UB=c-lE>U4*=HPHJK^Pep~ zfY#d@ZGe7|R{T!>5lYhDoonTjTdS_L+ZfuURc{p6A3}PjtDsG-trMfG zrFXe)Vq8+RlI_r)QqtcqcR`N``JZq+3Oz18_)q#zKqaeJhAQdETsMStx4sJ{R?@XW zZE3dd56~ZsZh&qux)Hk3=qBhUqd!7_H2M?tC!?F8n~fGi3yp4pZZWzQy47eAw8-c- z=r*H2Lw`2<3-lMGB2+YLhuV#9hi*5z1G>ZLPS)tolwzoOr`~R^9ZD?b?t<=0OS-$E zyN&LF?lD>nEjC&LEiw8l^jD*Mp?i()gYGlBAG+V@0q6mv2cZXzI-m}tPN>u9A?P8a zhoOg!9)TV)dK7xp=x@;9j2?p?GkP3)+~^7D38TM5e>Zv(deZ1A=qaP8p{I@h0sX`1 z8R!|K5>zrOLuI39p=XVrgPt>53N1BS1}!ss9(vyBpU^*zmP5;pRzNF^UVvUOdJ%fj z=q2bSqnDwVjs6Aw%jn1FbQ71A4>gztDe;-h|#XdJB5X=xyk2qj#Wpj4DvYs2l1wdKY@v=soB?qxYfr zjXr=rF!~Vs(C8!RBcrv@TBCK)I-`%FkBvToJ~8?f`qbz%=rg0wq0f!JfW9#5fqIO3 zpQB=&15 z={BO|Mzt9??izk$z6wf&HWG4uYLkEVsU6}Dk$TIa6@6-X8x?v1dR1ty&?@L1p&NuM zP|~M%tb0Jn^{q_?_NC2zs_1W1Xsf=Id`V)%pp1}jWgJv5^pV6GpxyhXz3%}XF0qZq z5jz5!DQ%7xIvzT~N}dSKl9GE%>?CNm&`hB@&^bcq3!Mwi>zj`AH_)XLyG~*e6kD5@ zLDxvkkNR5ZdW*F}w@A#7`c~*J%l2;QJ}Y@Y^pKQXB0W3|JtnkN=yB*Np?8FyhRQa}rx34LSrzJ)eO z$yiE$5B(stNa)ADwOJJQL=G3BuqrXG|9E*Y9UaqEYTKDTzxw`L#{x z0fqi@xDpEem2ruW`I&J2pnkO@-Mk%TCvOaGY_tiqiBW&3zfl!bRXfhD`;F}K0nh-W zfzUvsO`%PV20??2HiI@Z+8o;4XfQO`s2Zxa-nW3ZFxnE@(r5@Y#Aqlq)MzVcE2Cl1 zFr(qnaHA2>2&0kENTaQxt&K)Oql`vFqm9NuV~oZ^V~uK{8lw!9sol}NB0I#5WA}_p z>3is>akbmKpC$G)gtTM=A&t3hYLjT2+HGCkcuK}lvW>;YL*tDmKog9%g|;=CSR4H` zQ7Oi8VWw@mTBz2j4ysGX;Wvzp2BV#!osD*Zb}`x&+SO<`Xg8zXq1}!4fc7w&3{5tg0!^{p_k{K|+6&st zXeu<-Xm4n5qkW)#jP`~0HOfL+qa2juDN}u$oU#3&{h*1r3hfW=4}HE!=m6*d=wTl_ z5IPWg&C@~9K~P^$2SW!#t)8Yq)1c79bZ9zskza>HphKWTe91$hL!lRae}_SbL1+8e z;n3kw==})j2q=v6Na#o?^w0=3LZf^uO|?m-3EEev8%l&i$)lj7pvAuA3}}YMj)sng zLVm|U$3UU?V>toGLK|dcF_Z{}lE*>EK_R1=wfsLCA)iqWiiCWBF;o)rt(2jP#k!$H zDCBoMbiC0C&zq+2*dlX8}u-KWWTmry?ljdGs|Z4C84mp7e_CqPxuKx<`F2#s*#B{m2`quf-X&7dLY@}6~^&`@YA ziw!%sHku$Ly~ns{l92Qs;cnTVg)H zIS|q|e+iwRw&^Z_=B91BdC+gIl}n-6TDc5D+REk7mDbAdpx;|7^PvUS%2g23v0M#Z zC*~IKan@2!LTFFMJ$$CEuYL=Kk;Vlr-{?3EWmzbZW3m~LDTnPQzTDb_i+2$Y5Mzz#Qe>Gi=IhG z7UN1Nlq};Ci@6ddQRpGY@fUj_zYb+$D2yz|@%MWm-}|$~P-rv8l~7pCGLFCPlaA~; zXldGmTSmR-(^g_!v^*^t7t%J}i^N_^TZwUrC%-B$6GNHAj3gJo$B4!^lu*C5MYp7)y*xknWH-h#`&P5~Mrpzr>J^1(zV*gKrW; zS`U{XUFo+V6zaLR84C&}bGQg;o4At2%DChmYo$U-q-%@gFFd6^bQ43mM!3Y2pTT#D zA?*Q|pwPQ}j~LQ6afv7Y{Cgiln!^VW(wX>>dPw`jWj{(w=5WzE>kk)wEHS?$aS77i zKcOB9Bg^3;q$P1B6iSwHiI4f+`Y9zpPy35;B~O0cg}zAJEaMWS`=tj$y3)N6()_-p z&9BmWF)l$m&aa6f-DkM$`m|&Y7a<)rEV+1v^|sv>ilJ61d5};WRFv2pp?0W4 zXn{~CRFaZQgvwBt#9k6w2~{NasZcl6BeeNMs2BQLO71N54YXe9D4`8dA|?H(-DPBZ z85FXG>V-nKQ1&wFg>0ciB22CMYiya);(zmgfFT=o~9~E`)SmnjxgUw?Olx zKR-*qfg+2=(C>u&oc$hZwOAWelzL$Xp~XUe2A4n`R^?eq2WS)K1V<`m(!-7vkX)(6lNY; zF638b1@wZ|dl7oc>b(s8%X;`X)Fm;$Mk}FLtj+&GNOO1_W3sjdT;yZ2nlY~EN%m4ZE~!X8KiA!qL}4akT=Id% zK7^2F`w@injID)`?yPkX(%JnOLOS!GKuG8MQwZtoeg+|(-OnMU>+l8CBQd{wdZ90c z{4W0r`dUi*-SZ9ft&rbY-$Cnz{GQkV{UGGC{SiXCVn0EN#oQIp6@0p0CikGb61vjp zchK*Qeh>ZLXg)OGXaTgq=ql(cqpP8-jjn;NF}fDI*62FuI-~2M>y28WR--mP_qUPX zd2-in1Py3QV*{ZfHqW$znG{Xp9>tv4s%QdbdDmjN4aYx3<+rXuNy-V}5T2mw0*-a*LoPR`0KE zwOKUY&7xio7kRoAig6`Rw?k!I5=i#My-=sM@(_f^yTc{-hasdRdjxvScI4yG8y5R7 zgfgyI>b(ge-Sclj?^vt?C05V<0s2GQgS!E`!RSWlMx&c}qTj^GmdU)tP$J~V>Hf%* z@{cK1Lp4HcrDO)G7xMKQpsdiq?TO`}Mxh;rnxMQ;lTZO_7P>&F1-jsmxr4s{6aFVqQ@{>W{5rcfE`lGrsuE1`8MYWPe47so=`882>CW$k^YKl$qbYg3jINOq0k=` z3Hg!5P_f9oY+X;A?NDMRT|4K!JuO)c)d`?(U$?J0O4Cv_mB!|I8^v$sM%m zZ*O-ebf?i>WONrL{SznyWrh5cD+lF;d@BVg67o;37%B>d{-BbOe{z+f%3Wy>-B6Fj z{IjYTN`(9~$KB2B-VOO@Ts2f9#)zaytZjrY*I-!Dzj zOo{omJsz6tTag_y4~nc_3>AG$_Ix{367oHip~PZtF=JUw%RT6)V{dtrhawu7i54UN5xXde{IZR?jVAoJ*(| z&I2?}D9jnuDCEc41kDuk866Mhg?zRJC=&9m#86SlpQCoD!^dQHJE78&G}|(?+*(-y zbqV?WRzekDPiDRwN~{(4SH9QyYueuksOGO}n;B@b#C-2lpsbM3C!x6%M*g?uYHC@h5XDf zgo;9b=G&n=t>j(MQX#*7%b+eHztStAN@u#Wx}mQv_6?L+jL)@6%|mG`87M2{cUBH+ z6!K$ff@TW&u^bOQ?(0cLPeAKENk$u>#7eq{>EU7O`MsNgvO+%F95hYH_b?r56!N_{ zL3trRg9WHr$oJj?MGvR>#ZX0JVJAa9LVhNCp~PBokMIn5gqZK48X7L-dmjPSJd(DV zfodh@`>TWMh5Q+7fEtDTYBoW6t5<-UtzHWhS-lvVFXa1M04)~s^Rfh5F68HB1+-Sk zXS5DlFXU%p1C$8)vA9Q>&qtxZ$o|Yhc_DvR3Q#2EcWVqKLjJ6{zY+Tz6wVlw2>G4t z9;402(pV0Pg#4+Bp+qQ@e4N@FXP_`LC=v>-KqVnR zmNJxBNmrs?iCDM?p-9O05JM#)zYb++sgQ4T8I%ZxySxmQYj<*$EVqVx7JAm`Ip{f~ zrO;BNWzaIC=b`6~{t5llXgRdpXa%&w=mqEnqZgqUjb4IYGI|+$+2~);zl{D3{oAMu z>M~jhtu%TCdd28J(0`0xgI3yL>I?NX>Id~R+8El{XcK4?qyA8T zqbjJ%XaF?8XdpDuXj5oYqe0Lhqs^erj5dchHyR8LHmZiIjkbWcFxnE@(r5@Y#Aqlq z)MzVcE2Cl1Fr(qnaHA2>2&0kENTaQxt&K)Oql`vFqm9NuV~oZ^V~uK{8lw!9F&YPr zGuj5)#%Me=-e>|e!Dw4(Tce54M59`$)~F7uGujT?&S-mRd!rqo9gHSHlZO=I848n-*7d8W%u`#NPf`&gOoU z+^=psH+BJ(gQES?k}*`;uWp#TOG=iZWIwC7e_c(V{gpDftfvAcbGSU3=q|ph$`x>t zk4b+qt`&`UPeN_Dnts*_F7hO;#JB{7l5S%N4ROD2%0m%Z^Rk z%;6FgdUrL%P$-$hB}nHp17-KG8{z&nj$1!R4k*k^23LzhtPWR?Cb~nJvj*H`#P{Xf z@b`srSrqnC4mS-AaYquHj%!2{-S#{YnsBpG7|R@7vyY9OU*%eGbI~Z*#2U@RMJVIi z=r6|2_q08<0N0AfxDOe18}4QlcI!f15pCzTrT2E+Voz_<<`P^7n&|q`N+<4d6xQJh zTnUAD{-qmbsC?$`9 zj!oyq9S6-!SIiv`osh1WI}w_d=HO0(P?%kJGK7Y>2c`E@puCN<0G%os?JxbE2Aytg zo&lXHBa`)Wzkp`T49c9jInXbq&7$P~E9fj6_1Vw`HkJ#aU)#C82ts-<&4thq*Jo!w z|ILFU8(GZmL18sxT!QpQxr`VJyFA7vXoNdUMs_(dq$eDgps>r`6%f)^u7pr1>3#>H z(5Cx6g!JB>523Ip+yV%N{o=0T3H7>E3yP6mZJ2J*4D4Z2{4KXy* zEj@&KxWv=Fkh_)`(%S}?AU*HbK`5M5cReLhh{d=B>5bV+42^SNNe{RLY3^;rke*ar zg0zP}KuCMIfs#mP7ndNty>BFjLVqzXK|0qrK}gr=j}X$>pP-x5Hr+y6L1A8ETyje~ zXYN+!8Vz?_v0rky2!*>K#wAGi=ORj?Z~}6;2!-{Fafy$~d3U!_5{2`Z!$m0EbulhM zx<-GdBno>shl`NTC$5BsxKlX~Wn6-EEPsKJu0xS}NLLz{Abp0nLr8bs?UY0z7UL46 zr|}MANHfAEo@Dp9JBcA(2V8zhCZwkltX6p_1g_pVMVpK_R0Ummu9Q z&q7G=m**&nv?PvyftR-84&a0zkk*?AMF-Gdi_Cls6)n~dl@3VvL>a0akj_~*lpK(* zgF6s9FpcrsGfDoSG**D3gLM5|43!SDHld1@?1qwqY@7!}2dCq7)1Yaz@`JoHxaqVx zol#GcTPlWr7CKxgfsn@BA9SPgOHw%-$O`G_^o~wuCYRZwW98yX+2zmG^1;Xp-?ZzB}h-> zwNTMoX(tC1Vi{ca_B5j$E<)M^F0q)q147#7oe)wtlq0nX-S3zMc-MAhU#?p&REao~P6nb|LL64+8xJRL<(%#+E zP{~S`p`~f>ZW)BMmFFR(Gx1L{LSfW7T!eH_;1Z;3w44|US ztmF?6(*AygeoA|AKQn5iqsHY?IE@8dguD{viLVB0K0U_=EzYx+B z_a=n&3G^0(bdBDIknYxZAfzi@fsnpmcSCEfm32^$#d@K{dUuViLu1-Q4BgzwKJ%X` z7ed9xbS&*qM`QZV*9kq|NPhB3(LDhzZzKo*S!M-EAD!c!vmeyIDcyrr(56l4Tn~Z^~N34fWWld!fX}>5gJvj!Ijp zhH8#VcTWbYKZ^e3y~i~`*`w0klY<&1=5L}VC@-zZ_Y7R{Q^xtQ%Wi0*#v_mDy*6;Z;RI&N&hI*twe=GJv>utq0K#8^Kj%Ln|PFFey zMMtM&iJ|1^w0Cz5v18I*5JQPj_{4B5C+Aq^+P?|3LB(U!dhJl@*mM_^p+v}!+8sxm z$ECf;Q1Q65&334CT-r(*N{*x6_Wq|rGpRQ-Z8L_7Gt=JNp*v@$_3nZeTgfHReKXS> z?uR-g=1*KF^oYcKMvp>|&rC=51oYI*blsnZO4eT)S~@eGvt`iondvTH0lhSnJ>k#T z%TSk)-ytiZS7)XtU=_4l$bY-F26|IQ=Fj_EP(?EG=e-+xU&x=-51_R()APO#>XDJ% zc(vT#P%@KU;Gbjec7CpS zC05U!SXWbhqPCL3Ws$!3;35=GYK$xTn9OxMuH<7fOJ!VwG>0u{6NOWp!DUf+Qs!`Z z6i#CS7ol)gVq7Z<`L*GSo@Cdx<4P#BQpQzK*t^}h#K-&@+ww%dpFdI8s2W#;LPi-} z7KPQ!;UXWCjAC2~g|U<+wpBXL7#9soXDP--!_$&6EH7`PDF=DBQvsTpoqlE#M*)zOjyR3DWx|14SpM`#NTg zP`FX5aW!a|yM(ud46Yu9HXCq_DD1%|T;7xHx&p2lg?-k7i%`ff#T zxDFIbcH&B&WcQSDT`1i8D{&PR=A|3ggTlP@;u0V8&!fxe9feWHxDpC;R>oD3?p9pm z<>_iR;qoY~Ljl)}LcJDTgu+`wjB7<<)NQyT(u{B=6nZb?x=`3JD{&PRdg#XWpfHwR zT!QqTxPo;+;nPG0mqp=B=WuxxdN1H2q~9dsiasVY*p4fq@CI1MRZv)mZd`OjnqQ17 zqHq_q;}WEwLT+R%D9l6#mqp=CAcxB%oikj7LPjyJf~FajhtnY{L~%Slf17359tn<0>fZf^J-b^gU}KZKBXhj4OJQcd&L`3F&#q zReVg&ST`;~`bKsOqei+XaCxMA0vDk$XE828dirmr9ttIMxCn(DVq6J@buZ%*q;FP> zsE2gLa1qkG6<6{xS+O!MLHb^B8}*Rh^SC?;?_dR7gu+uM#+6VQbs3i+y=ni)_H_;yp|F}Uu7vdb;VLMcj&59n^mFQ8Xa$A0iySUOIup1Ag;rbvo^PbkjA!$cCwMxLr8mX zfRN_CGlX=+2?`W*`){ake%gmjH&LP&4S;~_M}&5+OiCqPIuIuSy;nzJCJ z<2(sM`uY8228vr4l&w&t1Z%in$ zHr*-QpQoff41$pMuo;B3-sTX}u?&WgmaK-5-dS5fNbjsIA*3aTKtoSS$GH`hJtbYu z9JK!_=~+1dLb{g@gplr~gCL}*>flrAqS>b~pYrKC#wAF1$Q)w7Ok0U@NyTE_#86oG z7?&We_bxFMRyxKdNMr92L*X37xCCkJePSrAbc{fF{Ga&aS76TA3`6=So}_2 z3$06ga34cRch4sf(w+P%gmfo=1|i+apF>D@@)r=&o!kSV^xG|{SF-iroO}r(?cpm3 zrRNCx+WPwjLK^!PLb_kRgOIke9zr^@4G_|j-$OrGwm(8hPsdLX(p~p6lvsZ*&s#}8 z-Q^oWC_P6|ABp)lq`nZ+^Ux1Mx+6D+Ph7&OpIZVDl7 za}b1dWSc=KJyp==*5+UcX`9s$(*Cx9khZxcgtX)k2x-Zo&{h^31|f|NhmfAs5fIXo zIub%UFIz)M>y3hHtj!FBG&T-Gn$b28(%5(i>8K|_NJqUbgtXp72x+}q2FK}hRu z2kl@bCqX+}D?33*Td9YT)@y)}_O~;H^m()kg!H!A6+(L3>;@ry#_bLv-Sc}uNS~>b zA*6eE3WW5%XHN*}Gj%U$f9vl62x)%@LMVOnfDW>f2SZ3pPJ>YT<^fH&l7~Pjee-}0 zwUUQHNV7d0Lg||abcE#KzpFeFLg||a)F?6k9ZM60^e1UYK}d6-0U@22qambob_|5{ z&N>!CIxojTNZ&PPLP*~=j)#zDdjfc}bthlN zx68N^n&?jCx6RAAE);h1N?g>hp!+PwRW_D-@~uNRZvHOm$QIxx@0!M@;Ib&RlEYOz z$xlhUaceEM4%dT1o4vTiVs1BjN0Z#y{N`AUyBUQx7vdJ9FfU7R({`6uWL~D@8c`_O zgv%qHL0tbm(l)DbkE5{ePvB-x7Ls+JgKI{il@?rtbOv#WCmETW!m6MUtHupSq237G zv^~=vrsEosjttj~LVhi{;d`aA5xBWig=Eg=;cE9zbEw0uMq!7n!QH%1n)^ar5rrPw zaU~Q=mT|N9O=EL#!?S6dBXGrBTCyECdB3#e6x>V{X7_kp-eLt@^0VwSfBq5(=~K;} z%5&^gU0audb~rUX#gm|2Pfbhi22GZdvLoFTD0?b9(toRxgQiK$zguwEnWvJi|7p_kQ1hwj$XcKatoI9{xmI!>bg}ht2^3kIF?5C1yAqmj^%g+aSPs`h ztrlxLwXWyJv|cYRK{`t}K{s2yh0r4F?>4Atv3BTAYx6E>aoVO^0+p;@`P90}D9qp# zTq6qoHQ|<9Yz40MS()84`MG@?Zsv1Ba{7auaJgkH6gt%BaSdLKY*Ew&CytOs`*bXwYq4R4N?Zkndfm7l6zcWj5>HakZA5-3)T_qTAk9zyI|}t0 za9K}MFNbSHpUH8u7AxbrJjt8KN?Zknr%X4l2ZegQ zxCH4fwJ~Esp}%Te4GQ%#xOx=oHQ=%+)XU)-JxPB}xI7B=3bb2k^6zaveR!>r| z4Oc{=UOTP>X@0m83iZmkE~IA#*Mq`Y>BS{Tzq#Cmj8G_9jcZ1sWD71rp=69}MS5;= z9VnFS#FbDeS;loC?H$*Hw0B&BwDj&we8H7I11!PTR%(hayQ()q+Sdh+v$%cD@Q zfNMsfUJEWlI-j^!PkugeMHK3_<2q2N*NH13oljhsC#km*S3#j(H?9YTdcC*=g?g@v z{E+5{t3jF{t{!QAxGd8AaE+dPez-i+{BX@k^TS0*^TV}z^7-M4Nb|#WAk7a~LYg10 z%ahL!S3#N|t_Nv;xCCi_1IQ0)ez+Q>`Qhr3=7-B7%@5b;$>)d5Bh3%jj5I%7{lK(d z11^g~EQibcn6y&BHG7iN--3%!C>i65J|-pGaUGtdWGAkKLdi0&vT2%8H?GH%l&kvU%&2Mw^Lz*A125Ek{dLQ%o;j;2yLVmbLAM^R)@<{W;HT#&) z4;LZL57&yqS!u%+k)BOl2MRl@6IVidHgR2^{Mp1+ke*Fk4+`~qaS771Ihg#Ao=sd0 z(zA)HN1Y6uMJm3njfwMX@0m8()@5; zo_v0|3ex;=JxKGzB}ntDCO@S4;cAfPhpR`LA1;eDKU||HpC2w7n%0YPtw`4hS48@} z#dRRf0arqyzcQ{1g?cM-6%^`q<9d+hfJ;!Q=e8mTq&eVfP^g!|)g#RTmqnpo4%di6 zy(U~9g?a^CGYa)ua1jdiVq7cI{BT92`QbW{=7%dG%@5awG(TJgX@0mKr1{|zr1=da zKNLNVi9 zNb|!rB3(aR9_jkwnvt#_E<(C~xK^a=hpQl62V4)*b-*P^*I@+XM4?_at_FpA8C>hg zv|byoh(gJBTn7rRbmB@rCQrjMt_y|!R^swor}YZBW)$kR;36NBdNHmQg?eqcqL0aG zY{x~T(t0tj6=}A(A_}8!$9164N++&_LVso4;L&NlYFrJ{Y;pA{)N8 zgR4iOUIQ+RLcJWW5ruha!sSuOsDNumI`g;$h1}g(`a`-4a5X5@%i!u!sMmnYqEIh~ zYel*axFQPm+HoBy)a%5RP^eeNb)isiC9Y;dS}%jEN7^4Qi}Z%ZHG1+lG%k-cKU@dW z{%|FvPZC^$^!uf4$rkDJ9an?GNzI5u;WRejvMAh3Ib0(O^_p;b6zUal%_!7s!9^&X z_ZZjeNzQv4u82atc3cMv^*V7S6zY|6U7n=gN?Zkndfm7l6zcWj5)|sWiR6bgKU@vc z{KO&650^!nAFdH;ez-i+{BX@k^TS0*^TV}z^7-M4Nb|#WAk7a~LYg10%ahL!S3#N| zt_Nv;xCCi_wd991KU@vc{KO&650^!uUJlpjN&0KT<&oxxYet$ME<&0gt`&v7+lDKm zuy@;W9VqOn(ai1p$U6zaLX z$OwgTHsSIp+mG?I&mcw>XmVW_f6|n<7$xhhpYGG`@>~XsF%Yv zqR?Lxu7X0nZd{KipC2wknqL-5(phqD4m2mdv)nJCUmE=i`jydH&{;-j^HYVhQ;MMl zXVbg%=dOaTK0Ez+$2Cyv+3DK0K{r^*8=*g0$(x~tR`M1I=}$Cog^>QX!6FFhZz9|V zA^l0{pP}2&W?uYnwB7+B{n^W%5YnGI-v!-mCGUZd{7s_6>wI_AP|8hwmVyV_6R&U9k-i(*C}Okk0l0Afz|S4-nG+ zeuR+r_Y;J|@7=qfA*5@RKuGJkb9h%gC+&SKwBI>t5Boz$oRhYBBy_ZuJO;|2leSWT z&a!%ELtjcse=mJ?PF)Fw{K~lK>ogYQlH{E9K6B?n=NdIb%|rKk+{6~%o?3Kd8C(|WJ&!9|tQ}XfSQ(cfoxxgSDE!OB zGA=>7wsjEFnb;26p@s7xf920jf_6%Kcl8j`wQYd1Qqun`>>QL&Gjatevfg7Tk>369 zqPp`ad0x6=1*mjhI?ghboR{|C&Zm|0(;i|dIX^AwE}-`dQp!R33)0Quyt`lXbNgSjV*a;|Gf?vDv_E$dtz4A$5JSmDmcv|npPSBE4mw~iCH-Gc zIuM#Rm)`xqZk`S`&ZT$%dpb?f(Q`Rt{@)%S1I-kAQGU1LcxcvKdiOJb5|p1yfBs)Z z7ogLnr2h@6GoabhyL<|9bD*>4vYP&1MV}2d&rN^tsRf!lm;5f2f6qJ*x>&~fp7eJK z6iF-ouSv#GQDXkzPq#xQ8+92%I`ux$Xxc-N_q6NPE}>LV7y-Lr9-1 zRS*hq;%)$hbglF%Z%_ax8>&HESTGyC(x7UE6UG(zms3Af#_P;~}J{Y666G z{kDaWKKmy^NT2q#5Yp#u9fb6`xgCV`tz>%$>H6&eA$<>-1R*WCBZTx>yc2|U{pum4 z|Ej$KLi#&`J3~m{x^{t(uHUW@(s%FOAf)TJJG6(y{Jk_8LV7Dsfsp~m>d7KJ-9hby7*uawIY+gI9@dp?JYP`F!TT#}WT|Glmp6iJ)@ zZ@9%!DQ(4-p=YhX=OCn`UJ4<-%a=h&cir<4(!2bh5YoGRIfS(23J7VoFF;7MeGx+X ze0T|Z*=F!x5Yn6d-w@K9y$eEm)2@UnmP0p`*j{oGJ2^^k;S7Ydm2pruvRQ)iQM&62 zP;-=?fEFmSk};H6%*EV_F?;aPTe(rHpaC)89kvP$jO#|Bk?xTb2Ds627uLcQPNMC07SM_0Mu;}%%#Dx7GX8?mU$U5yik-mk%lhPl)C9N@0Si9+l;T&pJ; zOB?PM6#BarCmQLxhYoOyaH7%f)F}hpZMelgCiRx!M4{eaaiZ;A_pt-qy*N?m?>?L; z#O}vEfI>zO;vPX^ULM6gjz+j$n3pGTe@A28su@-8N!(K?)O#BD4;1P>gA;{OmvEvG zE8~`WI_-ffw+t6;n#N*Wf%PGy7IXD+-F!66oy3YQz+Hnv57**~o@6!KaY=)eT%E8+J43sq_1vy; z-TNrSKEQqIN!t7jmsrg0#y&$~KFhfElchhI>kYUBjdS1LPY+XAvAu-0hcdWF&@eZZ zefB8sZx(wD_ruh5EI;BB6zaLXp?%Vf+`g<43iFx6MMzf~m-v{!d$JG;G5%llb^Xy; zx5X|4T@`LK6xMcg++a^K>S|mK8s>JGHNa(XS&QXxc^|usU0%S=w%8n8vyVx=7F^_G zGL{&ZSj_E5429lfToHwNX~#Wou_tgP6jrm0Tk2yn&SkiYkIBq;VX-Z7HE5)}<7svlE<&SS z%`Z6NxXV!J?{eIHPjdPf;I8r{JzR}z^(3dN4Oc{=m3G|iJ|-paz?D$AG0V7TE%qF) zg2GI6#j;C}fT`vvzb3i&;U>q22qti-+IN$ULvS3$#EPllb0OMFb8x9(7O0SaTO z#?_!O>I^Q6!o1{g5zSMjlnj%Q@J zcTs5NJzS5E^*fH=*1~;>LbhMw5+9TI5O)~4qtNC`+-gsdoY8` zqA=GvT;5^@Tx78rcbUa5$F-s`yKT543Uk(u>p-L3n63E92<{;iM)ol75fpaoqqq_p z?M6%=;L5lz6rMRNaTOHW?8YUY{QJw1j2eYm%Hi@Tv{}Fxo4FlTMJn^73`LfmbhWGsKi6;YV^cHH9@djeNNVJ6DBtmANI$YvO`g2XpAPVn2Rk*<@v{H@R!jsh75?6!tnTpGLlGBmHwR)0wjW%2ng;v^e zB@}jR8MhXNQLn@GpmFXQZkt}*m!4!~U*Qs@&%dJ}q~~D}*DD2PsaSx!deh=avLKEDcoPdXMPg(3~ z+&@rQ+h=ekPm)m?w-kjom*JlGF&WuEaj$xklB;m9S?qP(YKyJGy@^6AZ{gmy*gLoi z3ghg?y=$@eaPNDP{yxBcXt9rQpQ6y_XSmNT_66=+Pg3tY+*aqLvpWp;z`1F*58|dZ zr?Kg{S3OC+Rk$~ijvDu|k4d(l;Cehs$zI&oD3tsL_pOgf$?tH9Cn@P#pi9zh-ETQ@ zXq>x-Js9JX$YL>ch3uX!59cnw5}GeP$Xl0Nz}_m!X|;N7(2Z8|Cg^5sWg)ak zV*XRsZBS8S{=K{%LfXUa5DMqP-2ru2tP^@9{j}sBg&w!wpWx0yq235wEei8ehpYE7 znTZD6T#L=al`K}q^;oPI_k)kgeEx__Eav{s45H9qj4PsW;@WX{qL9&DxVur<<@ewg zTWksLJ`_GX-H&?&g_(a8SMnt9*JWJ)C)13oaLH3?5AJDbsnF}ZPq<~!a%n~0F5C)k zn-|l1ZMfv$HfLQB+TPu8G9_0+Xtev_MCcXhRk?{S=eg-tL94Ca8tBb*obD}V0u6DO zHnEp*Nk#g5{t@&a&9qxMmdE zY{AV%q2xT=#VE9L2`>3EE$P03zLA)JL;4m%p%wQXg!II%hfvtNZbMwRF$x)Nf)j;r zNBZMLVP2|m15hYA5Vxtt2H`eC;X9Viaf5wKc2+fR2nzLv;>P$`^}z$&SX>PX=RJcH zg`GT3oW-`mjYr{3Pr&WqNqU%st4E=g2HdV_tQ*-@<#xmEjwZNzKBw-1o9t-|?xiWX zsc4vc=YuM@H%=60a37o~{D1uR#fie6$l^rd8=f4l5rwnagq!I}J}DlLo8@T+C*UO9 z$tcXkDY!feJF=^@6=_p$nMd^*Ej z?PD@a*Wg5<Zbe~ai*TaQ%5AvCXo4HgnO=gs&&Q;{ z`*9s8?4?fJQxiMB(Ioj1z@Z^$G4%6wbqExX&&21x^%l=)sA`xg(C_x2|yCqR{(y zxb;3J-=c27B|awaVD5X)5enbJRpB;8;d8(s++a_VZ8fgOVi}w$^e_&$oyE4t?SR7i zO~Tdtn4JCw+^%S(lU=YIP853I9XA<;^_zkdg&nykPBhj{Wi|K0Wl`uMhuaT@vFwjK zz+wmD4zk$6xM@BnPqpc|!%#RYhvOPi$h`@7Gzz=t7~HWwCNpsyZWan}Pbc9-;ryM9 z6NQXU!R1j{zXI-b6!JR*CmQD#^JG2~_X`xVosAQP^EU_gOB7c6S2$6~{Vbe_x0yQz zxU+H1D74aon~TE!oQJ#EVwd24>tnJGm*OH%@;N-lU4cR?SK{WQaMLcpwR(~>-G;l- zliVmb;ciCZE?9_Las1b06Qx8eghQJBvMah;x;S@(xP|7Te=QVQ1}t6NT6$Ts;aqs{xlJX`9Yn#@~&(O!IRGL(_!(`_lBw>Y7pb_NN86 z7U?H2+^1-mlV{^+xF0O`Bd+fuX}x~9vrxFf&c-#PVNSj|X~A7!u?um@VQD>g_+@on zr(C9Uwi5R$3azZdt+m)X+@~n4=4ZIR`Ltd?+*B0av-ZYiQD`NH%Ui5~i%?jj7*|4} zWEq!O%oQkkszpiqf|f`TRrmJ_^yLJJC-D6~*P zL#r*c&?X9BK|w*G1q*r(f(E$=URqG7iI)~CRPfS*f}W$GprDCD1qDqMDzs1&1x;$A z^F3>=nf1r}`@N5A-)HYVd*)2=yc?t~ay~mBcL}oCrMR@mGPnk0`x7K6paL*x|FVExF zBP(SCZllJezD>9+vUcWhTal&QhO6>eH7;75#KIEje(90CH3|xhe%OYQm;vI0W!b^jBD;*cZDxIRj?%^&Or6(a|`(XGpv{GiBoT0-i=qYKn?4MyZ zgyOZb210h94u6AC_aNu%@HDj6W9y)2WIXkK!L!hFQlCEeKM$>!cIqCv0ov&KHbE~X zYdX9Pp}6)egyN^c9Q{L9W(F52$*-j06=KNt@(ivV*$SA4Tc9ynn+tJ^mE>K*65OAW z-6{WqJM6I|xc_?WGhCg=>T#bVJB@yUJBqB7W4H!nZz~#cO~{^XnsMKF>|5M%uO|6g z;ASG*duHL%DDEw;d~?#jdANI#rJRpjtTEZamf)5mYs)g+ZjbH3z3;IPa19!hUlkg0 z5$YZ;V-<%j(AH!G!#3zm>8-qH58EMR=lQS$s`6Mhv@>aS*hL?9ODXaMmcd2ntZ*xL z->?TlcE!T`q}-eID1(d8*+G733j3h_K3@(%2U(H&z91Ze4*PsL0@X>G`ktsBIx1!A zGxIU%OK;g%+|`i1S1iU|i)^f~!=;hU`3!EU#^lbm47XfK=Jg6(g~uv!>y>2vZNO!b z{Z^jC9Yj{3hP^&P=Q9t#b#Q~Aior2sb!Ss#Yuu2qufj_YtEk)<4ki!`RaZG^s- ze6rVtCJ4pXuNjIYruT<%7bAEVF072j$NppZ1|(PHHr=R*%8WK~~>f zT$9I|aS@7ZKYLE91li6$0#}Nxlrr2zk4?f&R+8T>rr@R`JD*L%-JvmgD!3Du)|iZE z23L-(E%R_$)H58E+``{*A zq%plJ#n2T=kHVF6Qmv7-r48;&WHo$+Yd}_eBd*C~&A4wo_AM?Nl#~(%L&)}!@WVN& zmdLJOD_jM#y{8hlT4S;cufeTFwkxi~J)<$X?$6@ZD@n=?xEGMs@FH#_vORJW?wcPa z^?i$rP<$dA0-?BNL!loh`NCDu)ybHJYakTw1tnZRbas&MQ8Ks)#jz34wNi@gQsFx2 zXI{h4Ar#Nukz7q=XT<}!!%EV>Be=+8VHETW843C2F8mTg_DgRV4WT$ThSB|%)Gkkh zt#AX7-NOgs26=2SZV0lco}swS9@~Q3itLGY8*aPDcHpW!R*l<AJmPPU<*hr{5O1)5j(we+I4}vRHrIWgfd6SLCr`Tr}Rx zoB&Ohmg!w)3WUxM%eZ3U);XzsWM79B;G(ILPw)NHpy`rQe%T5$pqUcW-@|7?X&>th zl$Dh7brJt-SMGR{QcwO{sNpq<$$O0gT(nhUx@T>Jw$I^hb{^kZg&ojaUf;USiOIW!a0vQOGM?e^oKy|6ouL*Nq4hq-_1J09g>#cR-)n9vjclLK;ASHm`MYox8k2imB`&KZ zt0RYd1=&@36<34olu(O{E)vp_zj$t{6S7^SGwyU`HJpLVSCZ330j>zy-LM!JDe0TN zKBPo;Z!Ex-AnV@<+(^_Te4loX!d;K7_8V}Mk*(q>xLY+QC*#|2X%zojjmv6GTAjl+ zX-w{a&A14~bFlB+RC{D)=HWUa>w9PU?@BUv&%os)>rnwN(wLs5zCV|LA5&s7o@KZQ z#p~e*5Q=|C=tn92llls9MJWDm2A9^Dtd0yWLh-K=m&{EKKz2GDh$~W({uSd&m1O;u z;U**7iKgIg)tL0*He4E6A2PT|W4dlHottVsFzG`ZT!iA^oi2kePhz2n(M1-^;UW~@ zJBn%971BSMt;M)fC0Q$FxHPhzD1%#zY+TmioYv?#^l=8;yy<9wDbw?FtR(s5!`<@CS&#)?sH^ieu0aW^w-rZnG?v`l84Jj*3JT4 ziN{9ZN|DW;GTikZy8)L*Hp?@(a*xf!Wi=*yat>F8tcGgbZe-8sdvNb-OlH;xxEdvS zUssEZl=K(4L32~bA?r~KTt2e5#09tr#i!B1>;=fKLnZDpWW9YHw-(u6w+{D=$DYMy zH6~Xxhue#6$J~dDkkuZ3$SgqChhkibl8pQaTpHO7$>5?NCAEhk#84c=%|_O;yKsv= zwgh*-#~#2{c&rlln8zN+Wj&U|z2dP~aR)ti2={M~{Rda)v3lI+9{U299_sDH%|>xM zaaoV$aIbjmRa}+Fs&Vgl>|I=q$7*pOd+ZZj^y8$ya1}Hxxqe|dRN~im1az%j2mKZQ zI_3njcm6GK1CX6H2I7j8zQgy5#kgo>5(}fC(Nc;&F^qvqlPev{pc|9khMVT5I!sKi zdq>=<$VTEc+&fD97x8@^?tPDafZOY_eYhHAv!@mpX-wuzm^3%_V`QsAcRjL{H{ix=OlIo@-0jF(_Iq3!*&dm}l_SeH54TujvNo6C)*{Qd4)=FtDgS|6 zk8F)?z`fwH7jYXswh8wNvQl2fRUsSQYFrJnKGfnOB|Tl-JU3N{Y;Wy}>!T!hyuP?g zJa#GWGLK!3D?;{Ws2Dd^V{#7|hx^A>igZ-p{*X<25t9zJ7^2Cvs4*wEVA$5 z$KmQcR*#E37T%hhIv&~S@H@EHO7f+B8(hAU+_?&H{g9Q}AD8x62Dd;-@-4(IMwW63 zuEArCxF(M^<052xU3iU%&a6^#g8;UE@ znB*IQ+o~jI+HJThWGSn0JCU_z7j8GQw(P+jM3(Xp?y$zB%pa-s-YSw zMQ3>}v|s3>=U6KTpo4O4b?zRbx5(b*72s+P&yAlxYjGbV`{v{mT!iAcen+6XWY&dx z=%|pcxMR?lK105O8ie#Yyb=1^^EGk*L#Kt)Z{ci%i%|UR)C@&lX1JU8Qg@S5e|;=~ zdfZLRdIvfm>UDS0mfld2=PQN=-A!BcP21qRQ_GQ!(hA&qCD}tZ;5H)L z^*7<#|4>p&EDzTK+0M`r*U4j@aa}YfE2k?i-(v;19!hdmQn(_I72`@hR)!mktoCuZ zUN6l2ZEOijcLX7?<`~23PK}dAMkzq|`M%3A#DS7jA)2JYOc$BV=WE#^o!? z6Kw%*fX4>n1|iEg821Z}Ne#cmjnYq-?8)G=$nN$zT;#E^joxmTn4DPZaS^gFCc+NrEid!!yHhQZ z-SJxC4kByIAzYou>Tyw35)0K(B%@^S%kSaeIk_jH&+kb!ARDtrT!iA3U)+;A1=-Ht z0VlFpM_ealdsb)p@5r7-&%hPQf6@Aiaf6iPZQ5Ym4?XrH+z@2Ha16zjAS-hOZX~i& zM&U}4wWSO<9$Bj=;6zsYjX06L&$7JaK3%K01qa{8yI$}^xY<>ZsUzYSd> z6vxgiPYpr#Ze}PhLh&p=3(7C2OnqBe0Cf}68oHOKzUfg;Z{@E2EiOXwT{H!qCn@#q z>-puWmdN(jR=Bpv)@?gnzLKn?0$ewbb;tEUc1K9z3Xzr46L+4+&c}^KR^~X|cw{Lj z;3gs)vq`wA$Wl(jRUxaP8h6-ZM{tqH!Ug52CS+wc<02IAOBd2VWHq$HRb7(QP>qW& zP4a~S<*ALAC$UYqsK{f*(3MiAJ}nJ`P&}T4p&v;~eVP~oq4;THC^Ss+=`|V-l}Jo~ zM;`%|mM7=PGH9%nqH|&#t^SRWeDl=`*BaR@Z-eXPvCglUTrXs$^u|T$Bo;E| zsr7S(WKD0tWszOm94;!CGWEBDdC>eMWmo{Acor;#7J2MGXtC6%vu6qPVA8Vi5VTbC z>DL>}pob-&?mdr`r)nxBrTk7(i;Ixmi$i63s^JNV$*&)cxCq6+#5_r>k?q1cTzaLX z)OW_X2w5zwg4RlXdP-l%YJ5gg$`0L(`v%#2ly7knire`tDWCI{xCq53i07fd%goX* ztNsC@xbN$kXUOJmJ6r{_t5S*E?y()Xy&l_#t3fu;YH^2=m2w0ZAuAzjreFmX8WgW8z*$m0xvMBz`SzP3?P*2J)k{)Gn5wh<%!%+yu zy*fq(`s9o-xhj3Amkp9A03=Q%!2hZc}vc%+Wk-Rr;7TD#__-9PXgU4&ja>%XbXdpfRbh5f>@x>3$f5;xpKAs3aMsFajDW zq-$&xgia6g&N%#HUMe5i94x>^qdn!Ad8w(tk}~CrO~cJZHYaA`j{Z7{9m9Qz>^JDI za1F@L42`(_(j;X8t{bwpbjS5jk`<7`ormoHem*Yku?%jG#-uHCaTUngQi)sbu{F50 zN^-JVhg*-Ve;aUB$nsU=c6w|VF7j9?gC@>P&V!Sno26wslW&1gyrw75OBErD72`^f z#YW&tk?o3QxbYsFfQwLE=9GDw`h1W&@X z*O=U^@^B)%4kzP8_Iu7LxDLp!ZAV-ujmZ}UopEO(`&-$waQVpYG6lGEk?l+0!}ZXZ z+?`Um3y|GqF2wah@!uxl`e{sNd4JphjmZihh`S8g4t6=N2-$vJj1$=iUV#%?-><|C zQj*_q2jfI`|N9|sh{uNF#wtnw#^L@?N#CC1#w*D$6%%kXk?ml!aCacyeeY z0T-d<3zT`O7Plt(TH-`DuUp|-Bm4cK4X&+{^r0QDJ+j*KaQihT?L2@xs4hj2$V zCjC2xYtWe78yj&EiqAH;Q9Fuj$F)|HZ`0b~+9PXq9p%g({|@>p+N zA7pz-U)*S9S91)m6j=>rxa*Ovu^VtATa9CJBHIVY;l?XTk0#)5MmEoG!A(ZizbUw> z$aaQlxar8g-I{^B9oZAZ?{PDc&5&8RJCN;5cjD40{?Y{45BA}i%J+*V}! z#5P=&$EtC=J+=oYvOc_z6WKNT0Jj&}Y~6=Di0nEX!qp*r&Z);mD89c>Wo98uc?0f7 zWNY&#+(cwwqEEv87Fo*Q;ieY()V4s-RUH@2Nxk5mym(x zNSXS!X)aVQDeb*5v_MEsL17`ZSV-T{ErA}CQuI9k5VTamXz+AUp%1_;mj)wBBPIpcg#$B81{+ zgpCl2^KF8%oMG%+_Z;+!v{RouUWHKnM7tS6@srIK2*oL1gHYVE*P%L()kBewQn;7@ zQ{cVv+rk{K2HEaii<{g!iA}-HL^krXaA}Waa9NGXnLUTw?6EDlts0X)Y{P9=lHUV% z;NDV_8S*x+N=Z^yUReVj&Mgb~+3v(?4YYnn4B^AzNeN6sVJ@>`Y2z^D~2+gX}Id7gz4F zdAOCxuF5LhQ%dsmvKqG*St;vq&mo)1&*Qcu%eMnph3q<12q?Bgd#x6;| zO}OpI^6kJ?d8`_@Q)9BmcHs_s>=3R_W3uAvagoPDR|wfXE}VI9sx>+-v>tOpXoG8y zY<}k9Iv`8g5!cCMopD_}))kkJY)u#7W+E$P7A`tVNWX5#hfrL50n{z&d*}}Jkd*qK zAq5pui+<%Cdg73+v97p$Wbg6{aFNHtd5kBre9gEB+3(Zge5hBFFZ6~klIyNtBwq~m zk=peO;l5BmpFREWO&y19_illkiL9NoaC1<6cfv&jl2{nXOukI=$vvPL7hUeLqI**n z$YPbaEV43lxX5Fn_})~@5z=Z|E3I&Ck?p(fa2=4ngXoCM_gDe$Tx7qQeh=3RS<2qH zi;$g#FUIvjR%Tz^0Awi#;);-sbusSXPm+{}aEFn7gM0*c6j{DwxCUfpHsT_Wg`d*S zYm+hya08H~9Ecl)tdzmHXk-!#qo6TDdPn#bEkky?D8@yl9xH>!OAUILnZO95cm#26 zk=?7>;o56VQs&{xH74h*dARJ2Ny;29Lh(J|Cdx!sLo3{PWMxjk%|w=R7B20v3~mmx zd~r+Ze+c^2e-gu3vmxBN&gS%A>3wUDYxLZBAYMUa6P6au@vq+CD}vH#|=UDY%vr!OiAuz z!*L}_a{n8FyVhgZ;eL+n-Z2sOwin!h6Is5oIFaq; z<8b4V{Q@unH&J8KvPrmGk@faA+*FTE!_8EZR?otvmE{2tt1WHs!= z)gbFpEp9)uwj98TEcP#)$V&MXchF;paEFoQJA$i2_PkP$JL<7xxCV{Mm^I>>Jl2eh zkkuDvFyE1#y-&w=QIcK1E3OB!6H*Fy9wJw5%1bJ+fBk;W{8|Sw~zajmeWnXIvMJ z$@_w?xO`;&E5LPAk`r!sTrXs$^u~!SkKo2H6W_ak{)E$46^(SAv z+=)vg8?y{9S}G(vdsqf77n1tI3eJPb=2;tD8pU5e;G##p_6i8azaCezIvz_(IfOfm zY?O}R>Xh^}iaUy|zGJusjmfw);+l~4uNfC@bvt79T9y@?L=&?h%!%8v>j^OH$ z)liQ+imVUEa1F>tq7fG%OBtSE9U)8E3Kwnk*d{0|`DV*^)HzzcIqB^tTozd=Ioxg~ zc|O{M+ly>m_TeIAHH0nDRw4a&=(a)Ir4;>DXa|JidHoiI;@SE(ROPX1Xs^%teNc_W z^vqYw^+WM{MO=j98a`$(K-TKcxIW10>x+v#7CvEIkd@L37b)qC+z+96S3Ce6^d238 zz7*2GlKhINa%3Yv1veGh*=8DUIFpQIY0NMd>x4)giH zzsx6}_PrH^tiEtO)OtSo^e-seKq$TrC(KU;WY0&(;Y4<)Y=LWu>>W!hT!i8`87Gnw z+5XZ3*HTH|iMPTX@3HUT@{!%A3*^6h>>OOAB>6(y`KgnUUFlPB9gsb9b(H_EB>UMJ zxGo;+itC1~W!-V-A}i&4xQmdLaxw1v9{U0A5|3SqyG%)HxEwdgV}o&}$jU6kUGK3Q zaAQ3-4mVy&-=5=c^w>?fG_tm2aEm>*1b4s2r1l4Jk&=!}JE*<%Pxhsd2c0Z!(W`mN z{M5z{5|h=x3717S%X7E}k2T_&G$#9eGcH2$Ea(Vzl6-oe?+kVE8oEOH(rW!;sbGF; zDzf^f;ie-iWd<(ou?+4gvirj^T+}T|8M;F~g!Dd^g3c4twQ@ex%X`}!8sN1Lgf8=Z zmor0UY}y^yLrF?W;rbxkrTXIfAsdPQ zxBk=35V zi7e$SxXntEatrP?WVOGJ6WQ5xD^6r*+HJV)O0sM0z*Q;9NL1r?DoL5UaQ{TMW4?!b z-(w%(_9ClqAFjz`&A7;8p@@9~+1zb~YmID|YJ>Z(lFagxaJ@X%8`lR}zP`AAN-~rC z<1Y2s0G!Cy%0OHZvYAzk8|1OUxKd=}S%#bJu_?HjN^(A%gbxk;tmC}rhlyn~~h6YLP`n}X( zXo#fL``=Iq*_;nQW)>iu^ToI{vfqI+xagWB7D^x#|BdtrW-GF*(h66EY^;lMLy+D7 zhT?`H+ZBi7N|f|z7dH}FzEQZ*O7fgD2KOsuZ=QdRD@AtqEyImhl09+)ZZfh`rr>T> zlDo`pxT(nAgH6Lt*O;u1890%R;O)4X$W9)!a3YJ{ffHHmPFxz<)y&{#BWu}RxVw?% zy9ZZ}?5;Ksw*c9xWFc-bvT<30TaK&`D{z1E*rT`#WMf^4dji=ns88bltR(lwzu^9g z?AktsTdgE>Vh!$Z$Y$izIFXgP7ALaUI$VUTE#W8gQ$Io0&Y$A0MHagbHy&9j6L2>o z>-$Z(iAu7HC*f{Zk}o%J!QG0il-qE>MfME)JDkYIY&uS4-$Bg4iEK{XjuTnz_qdtJ zYM+Ihqa?M@#g%((9&RzRtGNXC2V{NtBTi&BEX9eeoy%|{OZhNPWP8>lxaCUn?fDAa zW5}+<td1?X*O1llI&Q1Rq=s#{H#8=r^d{~tWF!AJt_s<|FIA0u2U!j8;zZV#oj8%5 zF?ZoE`KgeUc_~h0Z}JD=M0Qpjh$}+z-h(UFn2gdDIFaq9SK@|fOrFYz;;vGX6+R3n zvKoftuJ+h9xDsTeGy*qLV^YH?+%G)#OPt95a%eP8WPklM2KOsu>-N{UQe-1hh8vG; zE=|BK@Yq7!Vq_z~1b4rZti}g$4dk`xgGaUWIOG9IFZG6 z<3twQgWHR2FW83@+3ficC$g3L5$;oDBYzMlvN?DN_dkuv*9~9eB4nr0a4k=D$m(l> zJ6=ip_Z{2`$Y#NbxKlMI{|fVIxC@lzTdE6j1CaG$AWmdYk(c2_R>S4ED>Noo|$0~7IWU(AB@>nQ?P<%$Y9zu3b4mU8?$abO@ zxM*y0HN!aQ|AgfH8Gggl3yPnhaYah99*S|%_#_r4aAHB$mR7jqkzKLx;94V_6K!zO z&5}~iS>YDif^3vp;94RZ`Bu2r9&3X;!DA=l+9F#=?QngRn zijeJ}#kk00VKOIwWU=13KFHeA7x#T+_5A?XPh;}ds6Xy9Wce<~rIDq~;EsCi7_LD{ zPU(%fCXY4aB4qW2DYO&W2o~e6K=y9tN?aP*T*}}gjp@JScPlYuUsh#s707nzO5ER& zy~TMNw-#BM>u}E?yGGCAvdC)B;a>6BtGKt2y;M)li1J-eWi5CTmQ-A((=@6X<#Jqpt}mHako%QD<@C292vT(93Ht?rF$Jv~X;1{W!5|7P$;30eP6!9_EZ zmW5f+9nvFxVz?7RmNKLv6u;ZfKyxIe%$G2i-Slp$PiEaA++k$9@DW_(v2YK4M>b{| zq4K1L3@$Sl<^-}YEn48x$aa(rZZWdE_7Yr^$C_~wiodjY7uxG(?t^Ni zK7EH&3!%7wAG3!b+w0olCL^06Q*i(D*w?sde-aA^xbB}ynetT71{WQaGW9o&L(pL% z{r3xwKy^}sJdcHXb~j{KvlT8vwoio5DHGXPx52ell3lnRu0676!#rFEWIJX@To;dZ z#pNR_vjEo*S$+L+1CZ?+195{iCf9Z_ZUM3y7UC9rYzgiOk3ET7iEIQ{;Z}QW4Q@TM zK5W3%AWK<`i;gC-a13gYn9i3*T8->$m;zk%KZ)trFkeGWNh~x&k>?8w_&<~{pgvtk z$3ZCmF0TdDazXMu-3n?gF*%!tHVaatk<~B;_bVl7%dc^z$gXV}Zmh=SjpsPrcqM7s z1l*0tQr?7{s3d31Nw}Ml&52uZ)0HIO4BYJ=`#o-^lGHv6cL%aE@5H5*q|6NNZe(TN zgDdygJlq0g=j(;IMaWX#hg+;9H7vnpk)_PxUO{#econx5*;UzwdqYX)^_#dVWV=Q+ z?j2Ol)NcWzLA!PjveIOLa`qGwuLUMP?;36gcGOj;#iPWI`{H3&I zfRrL9>UvxQvOYB8nvkVz#zh*_Zzl&XNVU9NVsfu)h3lmxW%kDPL3Zcri)&ewl+p_K z1hUwZxH^y3<06lRVrY=$)4xw03=NTddfkU|ZIQLC1uhyUF< z(AcE*Fb*2;Wln%5N=m)^PJ$-;ESLgK^%*h^n(p<@fM$AZ7L@j#C<85!GW9&a5JK_4 z`?`p0glyJjaM67pTMVIiMlOMtdVR~F#W#xj@>L-({xdyiu*;uc`{o7;z z!9^O=G5hm^RO`PaKC^0}{eJxpKnEqI-VF~yhlRAAN1!@SSr2_KG2IKkfR0LAWH$}RpayBR zKGiisO`fkA`bJ`U7XEfYsw7HUHUc*qS!@ihR7vJ;8SbFR4&e?X8>J(-$YXqXpBjej zK0O>a9a%eP;L;w;;35>a`Z%biq}0B*f?6+3RzMr5lf-l+IzuS_{|=lEUFa!$K`35h zy&)8rc@c!-6U4<3ier5irizfYvlurBS<1ne?hh4bc$nx#M z)o4so*5X9g+mCVkH6~>qz#UYQl!tJU$3kDIpZC2#gyNZX354QrmoJ6TX+i#fyI}x? z;*>3kUs4Uv@6vM`i!LGifYCM(Hxn1V|yN#8TL3MJ`7 zCGIg~ckRbknI!IxEf^7N42=|eitej+jX zi`o}{DKk*HkUnqDgXSlr6c#{F$O_Oq z!jsS{kNp)|EmvCpFJEB|v{vfVE4>a{?<;2ml=b>@&}N|<*xADtXshJYvEBx~DWrF` z?F&Sl9tod41K;PD!b2dKa`?sGZEZJfZ`ALQPVpUWaDr8;^YpMP6oD#9p_EGPUompw^3$-nN0-E|N0k z-=A-{DAm+nNbZ!)xCq7fzdQ)Vcc+t~4w{eWj?fW0Rm#-1oCbCBSZC<;MR6a(8BiBV zso(c>h0c;1be88sC?2zdMSOR$C|;WxTy&1~K~I(47Nx3?&9iFUPGqZi7cTNx=nnOe ze7eR`(0NjWp1RJ5E?kuCS-l_>?{2*prCK7}vs&RI6z^vj(K6IMtl{LJ!9^&JT@0ak zuj>P$IM$bZ{gS?Ca1n~{EB&ECl2T{gV6MXuA=%xUaS@7lqM^_*>4UDS;ZTXB)Dy%A z@?9$=qtuLxQ2h4iI%uTi(^WMJDoxrF%Ao6|2K}3n8z2F$%x$=x8k72V z;UbTP*^BtUQc24swh6ZrS-xGk8jscDj(Y4EF7jBI3oTCi7nVTx%Sh<1@c@M49qd5} z#j%H&yGtdXoVr@!h9FBh6gSahlW>v8!ZK*N&xsXKh2)bt7%HKxl&SZe9JE)|u#sL!)wP=n9NMyN^hX{(!|NUq=hMT|ta zFO_a_U%Z25a1n}EaZ6$-e#*i{C_Wvwx-WGcvfZ==t}U_}+Tq$O$=laF+{wsR)hW2B zZIUvyyDv2vS!@cf3Rx-DxNki6Ew1f%lUO@kdt|le;kqDu=IV+|dn_Y#Qc^<(mqnH@ zhdbOpi5@0HyCs_N5F|?(LihRY*Ra*Ok!PWR$`>C@Z9EB?nb`%4(>_>#Kz#uOTev zKE0UH)z#4kYQH!c!91wb;$(C?L-`&nfO-k(c=m?+ElzsdA1ac3I-bSQkj2S(4uyWa zIO*S2&@iuIICQm_at&0nIQhkI1T=CncRc+UX-7fhz4i&vWUp@ugyJXYTcPRRzZp>4 z^JSpLUf&X^!b_=y)_TfyP}bX-gQ~nO)zDtAVITCN*ZvVya+}6=JYW1u+t;ZBTmbqRN+(!RXRNeJ?8W{^tjU# z&=XEiLQgvV8Tzx+U!cD@t%O!Ot%6oL{T2GF(^Jq>POG8SPHUhwPJe^`=JYi5w9{H> zto%b3D5~nCqgGWwT0R`wS(F@eHZ$!(@D@tPVJ%g zPI*wC)5*}uPNzVpICX$JICX?NI-Lrg>U0`(no}pJlT&A?v(xF&=}u=rXE=3%x;S-( zx;mW+o#}KIbe2;-lUu+F$LuWgk1D)g44eI999qR6ME_ANb_n_}N^?-Uf zrJ$5kAynwp6YA-79(115`Ox`J7eE&{T?k$1)C=n6)Envz+5Ef+y2$Bb=whcnP#>qh zP+zC-L*IA$0rUf>eo#NB{!o9XOA4c+OA;!EN-inv7(S7)9s!jKeI--|O}?b?H?-JOE`k0yu&`73nUuK{dd*Y5 z4sG?6+n_2hvl@D5U@~U!LOVU>E(o0*>ZN7>gizeh_aGFvY&V2DhDlQU9td>|8KL(f z6!+)@Xup?o0IKs?J@mQs;UUTQ1%%?cbQD5y?Z+S#$G(KV@z}Rea`VzYfdMJs7KR^{8tAw&r z`*HWvmK+onG4kJ+e4!XBrY*MHTmfC-bY)=_t}Hw+{8&;R2c39jVSbn<)D}YD3l9pl zgNi&>3}vrO`j>+uAzdqB5Ge;iFG#*(C_RX_Y!%8tSs`6VIVc)LtM^JQ42A|5o)(TM z{Sf-0(~l_SN2F}=eJKTsgtWI|2(ckaTQX2IB&i_`r7c5~l*Lf#P*UnhltF2U>59uh z6+@HjUJ2DmOxIs66iL1gQhWGuVbuP|Ney{WzL55>04n-%(w1VVRARcq%b>Kx&elFa zfAUfug(@VbBUlO5c)nUF@|58!YQKtp=;W(t^<_e*K%K5i=1XVj%&X$L6wZS3 zJyrnql2XP?zTQyLRkTyrZ821G6@A~ymlk0JR4OU8Wo6K0NvXY^0;Pqte;H`8iKb0xFf1I_t`y$&ylAHU&ycN}E~GVvj9>DkP?}pc2Xot&%cxP?eWi4b==Q zEC?@1tQM;CQtF{d^65Ma!waL5w`SPGnLjMu!4CN0`dQ<@Q5Ykqs zpk7i+lcekoT`c*u4}BmMe|OjyLOsL#@*eE_P`}~H{h>cpvhn2FLMKw^;izt z>ZNRhYCL5vbWnP$yZ0fe&TFWL8iaI~H$st@60R*#>ok!^soQp&f-Q)Uj@Dk*jDZiA}4%xdVL z-lO-RyTnHx(7moB)JgK`y{a?RMPj--xZljjM8b+>i*Cr*CaFYQfPpbsk_ENsOXx)bHfFavKSiVeHaW4@iK=(!zABNCFO9a zL}GTnhOQOT{#^%+6dEfjM?s@KHU=sc(!Q5LW2Fx|U&cY>J>>*wqNkh$O_r2;x|jk@ zmA2?god!)8nlAOtfM!a{c|x0GGXYnTTukk}t3-$H1yr(6O(=qVq9 zmP$;o(K2Yc$5ucULOK$a&=Vee5?bkHu7XxeOlRvFXszd42d(#f8=#jxUlu~0!jsa@ z9JE#XuBY^E&~{I`1FDjkuHtHFr`NCx+U+U#Kzk*n&WU|cjmK)C{hsdtbl77@pgM17 zJ#C?V$FaG7suhl3dNs zP!~zLQO2b!lrJfDeilI8gmee%4xQ)u&WCzQzMMSg^oA}fVL#g;rCbd4DPgSZh5ADM zq!c}0_lE{}4FjPfDW!FPQWislB&K~B459dx@Iz>b#Pqrkg|79sTnCMmn68dd&}b=B zugVyx)Kiv0V}*3}kAud0z6sD|PdNpe>al6ibRlio3}|Kv_qfi|>RC`)>eG{42AU(J zvu7?;F8Opk=RpgEbUYVAi@k;=(1TvXL(o!h%Q9%W$5ucqJ+=y3E%oV@UIRVtv9%ED z9OlpkTLXrsh*bT>g+A#HUI+UzN}KwG6g-Osi`+lBN_vIDB}8mgh4 zl26zBE@-!xvIp8Lr04Q|P>siGq5V>(&a4B_LGRxo=&+Ek+apk&*Io}D^-_*O4JACC zT`1S65&F8M@bqxCP!ojW{j3>^yoN9W8bQk6NXqaN=qFA;g?`Goq&0Rebgk2M%!2EZ zGBZ%cb+l8jN+p!Nj+8n-b5M=M^vqBTMH16HW%wESewLI{31x+JlyXqb&q%3Pr51`j zW%xPk_UFX(O-%t*^mA&kRRyJgo|Kt^DkP=tv`|(^uX_%v@szbtB&4;6k<>7fGIdoI zKt&_Tr#&i$N`O6bKmX8Uk8==AU)V^)jXkIo2dpaZxFS;}w&G7>>87xn@6%z_ev?ZK$DZU zgelOiUi)p()RC-}vGRUm8uVMw_d961)S&N)WoqBAh6mbs1>(Nap7Wmr%-GP{l}2NGYL8C@bTs`$P_kJYN_EjiMA?RpA%VFQ{GT zS@og7;=k$Nj|2h2z`i;|gXuQ(| zXoAy?(2Y(vK{q)~geE#of+jiL4BhN>3v`RqWN5O}6lh9v{lcx#txmT=w>eFPraDc7 zraAo<`mNLNpx-%7ho(EtfMz(|4&Cnbd+7I0GohJIv!Gc{cR+VI-3i_4l!nqy87SlP zWi~Y1=`QFlr#a9Zr@7EvFXe9NZl`;odz{Lla;JIFJm!)BcPFzQdi?BXtbo%9c&C#>iNo`^n9+Zp7k?Oh0ttW ze^6G+)cq_6RSD@HSq;^A%W9!IiRpT%hmOuqc7|h6lcdzO*$hSV+4+7hZx+G=dbEI+ z>8dJ*(hDd>chd|MEnsx*4zv(jNGUchi=ahL_d)kLEru35ErFIKqZICk?ss|sdcf&H z=s~B4pog6P0R6$~kI)~TmO@LNmO;xP+m{}O9(H;Jdc;#Mr?<-??Ry4_mit^<0j=Ce!go&Ey-#c3t9(rFd6 z%IUArU!9(Uo^o0Zt#(=it#SGr^f#xcp{JeJvI5p}HT7Ao0O}^BbE!Lo;ycN?(1mN0 z`PmD)$YU2nC_Xdvf%YvuCG5dz*7!{ioAwmsKU#vgtDG52W|C~+n{f~ zh6qB*y=on_jy~9Yc?NnW=}~wVde-N}bI^01@_FcaPx*J~?@s@K{y}f;P5ydly~j2{ z8;I$e4lh72BAi3P-47A+KTme-G**O`ik(AooT4=w-^o() zA$tyh>RwLX?j%@_xQvuTc)lC8HExq0Cp3=YV=Bdc|X}@?PWBgt|g!3T>CW?^zIvdz%lTc;pM9 zvn6)YrL4_!pwz2{XNG*CLa3*wJP$(gbvPeFahVrDC@%9t2*qDk^@30w>kXl}Wfwsx zF6Ckf#rgU`D9+axy5UvEO!u>~P~@?&nd`8bF&ikYu7&D0^KRysLiJFzxv)#PQz&eK zwj}Ezyav5S?Y0lTUKkzsdh$fn0&4$yQhOd$^g6Zc>Mw>$JT?L?WA z@|0mKw3RaL9TA${jx43&DU3`)O6Z*?wZpo+IBMdxQFloitJ zmxFe{l{^XWf!=?M{+&61{(S(|c&rwRB%kg};ceRTcG9u}sOasaf5lL##I%Mo2*o3D zJ%r+sxB9-48BYXeZ1{b0DT}cM2c$@LmU;is1baE(?K0F4Y zGr|a=$05`$+$8h_gyPtf(4XICPArnxU!aw5b2a}WvBr(9w>~1(jLn|6;)hmy>C`R*(!RZznbKrtzP>!=uL0+cIYjSy$zx6;WBA; z6;$J~T4=x2pmXp5bWm#7wQ>lmllt^@Q4cjpN?oaqP?M0ZxMnDll)BS~YDS_u$yW@e zt0_~zb;v;3YVzsZ&>R$b%J2^P-XW!~{$eQo4yEXhnSrwJB<;*Wk;LpC@Gkk@_1y+a zzsq&dlRyT_zDt?9x8|To$i8*h$@SZrw7LK)+DQ$%gB3%iLOSwgPz{EDR)3SJ>@Pa@>tl-F9*AmmMwuQc2l4Bwh~&qI~j>} zP*zgvy*LL|NlNWsHB=)p{oSG#I_R-OP@R-w^94G(o9m#zK_7z}B&E)mMyN?j(KACc z6bb3MJnZ4B>`Ce?fQt4|rk;0;q0&7`%gUgcLR!i!C@uN)teAloOFnJO5~yMiZP7bd zCA3yjYFpMp|JcLcs$XxchtSF4^3hzC4G@a|Qt1T<#lJ|u2qFH3CpjH%git(pH$f;a z^Cbwy`Cf*yQs)2Ujdc#H@^)53yQO7%GTsB#23_R7pf7`{j3%`=wo&Ws*{-ROZ8BLk6>3jvRSZ?6a2oU zoq16HK4QAY3ZPy>`b66sD%wZwdJiauN+qUcmO+!H6g?|Wfzm=cE*WUCq|_O*1ghA_ zbi|GH9}p&VngWTFTV%%s`8!EqaZXKot_x@vMZ_3faB{ zWhJGa#B)%Ul&Sk}HH6~dDc^zWynppjBq{A4_YtR=kCIlmf!Yh{$mch6?wj5Xo#mA3JsGQ^gDs!(A6KYj`Z8?YoHQO zIRY9fq@y$nD)lnUpz$9u)-TA;FabjG)w~fx@vnL}K@&YT37RamXXGo(DbRE&MZYhY z0i``<1}YcQt1=H-?6D=#AH1D^gwW}sTH3M{LUE6lK^2~_5?bkFy$V|EHLQcyd;c~- zSxKq$B?oPmlzPY80=+IVy*F-!Q0MS(seK!?U1EBrcR*D_y2h%Zo!;tQ&~9(p9%!$Z zxeq$%u|v>dAzj5spgOOi9y%(d^W_-SAf$KSMkw->p@y$aYLctj3TiE+Gph~MwkElL z?V$FaG7swDDLX=)YLe%b&QKT6*A>dIp?1BG6+k^amV(Zcn2yW&P%kOd<}TDnV*0kJ zFVwGwIiY8i{?Gs+{VIMSRODqALxa4O!O#$I^-yS-klwY2LnTs*&ejpowGz{v_Bv>! z)ULb!C@ zO%RIb%R~spu}RQmA^j#}3N%$ncZO-ubRoUcGoYD160@MR^ho!}3^YebM|Un%E~L-C z^PmMDTL>){(otFhJ?OEApru~>GHAKPw1yQ>g~YUfmCzH?7QGuj39a)e zJD@6Qi>*IsrR8sJ@o7>2a5_qkImb{BWkw7Z8eLN1^}Ka(Wsl zv9BQ%$C@A%$C@btbq`}mS%8aBobnq8SuA`Dp*UXzp*Usu82Xrzu)iSr1p35jKeXTJ zKw;GC0IkkQ%Z`Wg4shu+%3>|hl0v&PsFZ5rh&!Eqo>YzHOdZ<2` zk>PXbbEhwOGy4TC(-qzVLh)YL5^C+SHc;Cyl3CXd%9ofvzZXC~B&Mq)1@-!Z+Vy1B z8!C~Q?gb;D$YbFsyT(!KJ0RnkfwD)ri#7@6py+6_ONC>F(Z|QAPxrb{py*g(Vd!`{ zDZ`hP@?}zc28x8VzVH?FRnnHw05v!@LXDJZzlZ-1`k&L+(AQ2)P?J+L)C_6g!#B`3 zPTxY`Iz>?A6naK2L(gvEyyMutTS2Wu&y&OB@#4VDt7%G+6 zLqcUxh0uDf0m=$}Ae4h@gtWd|C=$|^h3`P5Tlohg3f@*}cWwlTwq+=G^ zKy7-S6b`hL>vsZlg42o6iB4^ywodJ!c23`gzUy=nbdpnhsJ&Ajl-Kjr@Y%!seLy%F zI@#$I=oF_8PzR@uP)Dazp;MhsgHChm1a)%i40U!o9Xj3V4CoA}E>IVzu25H}Godq` z&VtTz%7^lu3ZMd~v!Sz{&VkNx>IQXl>JD{xIu|3h)koO(b#oKjHAsSql3>IwC9 zIuAO}>3ryXrwgD9oGyeebm|55a_Y^#)SKB;P|WIR1GNE8=G}2R! zg8rYUI}eYlIv+TGs#d95i9e~JC>ccqBxvFip=zB`R8Y{QqJoOfR8VN4CJG7)I-^iQ zMH80_t}}`i>Xs-fSkM_)aH&bF6)e~p)}UY$w}OJ+-}im*x$htE^SqzuJ@=e*?>%>! z0fvi2w@U3|bgAgCqRUXyYAN(Pt6h%9igbi==z5Wkegm3b!xiv>^fCjbZMzJ5Nc6ST z9!3kS_5^y`YR{l&MZY?gmd~LjR$Gc*vF-kb2;XuUBKo?)vfXl&x9tk3D7r*yB@|do zcMMnfF;Oou6c_1nNuZ%3-P>X4tYf16I~$#2wR2IONRLE4N{Vih@lq%)dQ6l-w~F2p z%|=bOT{Fsxbi5pD5$RcJMN5u}&gN3I%xcTgtG3;1C~qwbsLf{Gj*8NqbyT3YIzJ%*B^@QHvL zM0zi8L`}6(FU_b$YI=umMR`$pFGqpsDBar=7~upIj)3AK?K6RrB7NqhP+FwNC4-to zIzltbo)C?mLoHI%@mf*dY6aA0y|klFk&f4e0^80FVrGM=g->4;7wJ3`s7|DFsYl5{ z(da3Zwps=?iNblJ7LktEirPfFf9O zMUjqILY*Srhb|O|bRXQw=;X?B_nf|){s#TVbP6M!!u{+MdHc6#=QRxol z{}*-7qZdx8ta0^njV(rmuf2$t$aeakycFfFmjVh-sT|}odTqulgZ<)+w?xz(5iz$) zRECI{+b-H49T}(3o(M3FatylGT3&~)7wLF6 zpqs6B3%b`vzYi_6+9LF{)t*6%t(O;cIP(sBjm{acM`Zq^2XRMr*9)TGU~^Y(byfJikC&t>re8iIzHPDiJk&Om3F&O~RLhN7XSVQ84?EOeIX zY;-oC&aPP?Z}7v>aMK7h!djk#&M}>f&NbDcI#WHW=Xl<8n7pO`7X8+A9y-t7=SOmd zk7T@zoQD{SkE}e%jktmB5~yxubdA-cks`fv&PSt0R`zg<<@4=ml(v=`bn{5Y%gB{_ z3;NT@=xKi|B7AK&BK&93If(GT&UqUm{I7f5j_w%Ate^XsnaxFn@8wQJ_}X14u$ns` zozJZG4(2Xko)@6|Wp6X6NfbVvP*$XOsT|5*5Z&DhD3F@oKi!2KvkQ^l-D0R(q`lOj zxJd7A36vD+9W#X*M0!tdL`@>S?>3{XC>$N-MS52(pg3DNc-f9I@6zR-LD6pEl1YHu1;Kpzy#vnZ|36vD+kw~Eik&e)a(jqV*bbZ7_;kErEtgP4{RRs=G8g zF7;@X)bt3BMoE#*I)xfUx*{74MawaCnL)wQA} zQVZ8S%8PXW3TTB$=du#D*&el{Ro3TfR21oWCDbX>J?cV%?XA0vE8sF}dgNoMuSmx` z0`(K=T>7K9NY7XT)!BCSXp~6D8;z18JrXH2UZlNDKn)@tp%JA;x?VD9wrw{DHCe40 z%@gT7=cBAh_b7)JigbiUsKr{gqP$4YQ2`aLRze*j?PUuh{H#AmTSdA@+fb)%*M+uU z7Oj^ZD6n2!lDkck?R1{q&^{vFzkN}6k8 zue8+b;Rw`Eq-Ui+suJlQ4MayJqj?^Us->oTRD+Ha=~_A-4HD@Y8;sH--G>aCEz+6I zL0PNi&_a=pw+OX}w3k+tx0VIeW_#3*R!L2d%WAa7_HQjJTFVmZv_88~Akw{cDOO~P zx02WY%lk_LjZab2PZ|@@#8mWtHVHLIP2Y(cQQBH&P)CZRbo4v)vIT8VRSs~k$eYIw z6j;su4*iZ^uKP%yE0?3o?K}9foZGRSBmLA86%^q8MxZCq|GzZ-_w(=;~Uv&G}%J|%7VEPW z5huA`XHa_{5q{P$Ai|Hn7%j2dQq*RnwRx6?P*5`(?mFb_NT84~&BOQ(4 z#<3>GvCh&mLINd4i$p1u9>r`5VpV12p?Y&U^F)f;ykSAPNhYXVom z-h)`XpP*m@-+5em0&>4+*1wO`h$jD@75Ou%O+nZGKHBa&G}UU;(D%084``>g+=T+G zxj$3}M78_nQ1uvJCy7s8(7Y zfC%4b4@CGre}#x+UANw}JP;ASWluy5b~zcn7dl94+ULROQ0t`+BK$s-qrjUpogiS}#|l$yS?! zrdw?WBK&qYBEpY%6C!*qjR=3_GbsCqX!Yb!UTT5NrGVa)?ewg?g$RFi-$pA$dNx<0 z_icm^(BG})hiIKhds&Z&f$j`hpBoV2Ya7vK+pYr<{^)K&TSa=zwxR9Tat8`TI)a@S`8N^a?*(t6RdOWsp0yf%YPEl(&uqK@ zAi{4~M1bdGpu$aB7845p|sU9Xl4UPca~f$v(W4YR>8%hIf(H4 zcN@CbTHc2OYv~#*)9FU`_Q^ioT!t(le&bFn=O|^+&5f+V1%2h1A)?kzJp#={M6GK( z9L++rqh8z`beq&hRZzPf-66U{TFym8ty?F(+=+-!WHTIA)?k@Eq(S!gg+|-5aD~NM1-tH{L5T3<4dxsX96^p!hAdF&O8zx9A{-$@7N}mAb#@{;Nor|1x+kOG zh;)^of)XM2b|xBXvmSp!X@N42ibcsm!b_`l+>vR>A*mok_Ey3Y*`=+tmf7uqSk#a$7};4{2AMb2!8}O zA)?kjA$Nw&i161@2ik5eccAa3rgyg=&`yzFx4Y0!)-pguts5z$yKA_ET@#Jh4gKO8 zYR|~sZEsX#wPO(B?^(wp!XNqL5aF->;}PNKQi}+GU)u;LBEs+ANr8U4muo`MK}Z;d0u&n1Bfe@&l?2!D?}4Gj_LGxc;t`1{Klh^TdU%Xnv^p*G$y zbe4^9HX1H1_4*ru&av9Li16pH4iSD0)}!Bw^j>!!y1=%(5D~SmkDQfJi159PMueaB zMTqd%%Ejmsk>0b$Afndkv+7br_}XPCDbiJ!Lcg2vdTMAW+d&SsD9 zK!iVkbJ5+l@An|WKY{K=gulk_Lw^?O+tdAs@Ym)%^pwrzX+-#WK7$DVHu@|gd@s+T z78|`4J#W3dfQVXGBX6UN5#f*gi|8eho|Tso;g8@FMELQRqGeWFj$XCeYl!e?ERP7k zT>%mPeeiYEX1%l{!mpQqAi^JseQ)yH63}ueg7sH6p!M)?QPYULW<6AzMX+ zy{sk+Hdrqk5#d+eCPer<*k;sWeQrTt*mheHQS1IJZ&uq7;olOzM1()q|3#hA=&lQW zB{jWnzed}w&mCx|)pj9*yN$d7{&-DgoCxoc39__G)@Oz+Pz{k4`4b}iCyf9R{%pF* z+^;87naZlgkO<=LPV{bD{p|e zqS?0H9MmK=c}H>0lPl9iIO_~qUL`BCKo+RD6JakIvVibM zX(2Vj*T@3GubxHJh;Sb=WC2m@K9e){S87j*^i7;BQ0WusX=;Sujw~SjK0JdKM>Y2% zBK%YBB}Dl3`7&A}(j&hVEwk;GqgSoZ*AU@XT^AE77}B)9dy< zMELiY_YvWrRUe?g+jt+MHfgDM=yvp(_4yw}`1hA0B7C1EMEE1I4iWxsW<4VOYTbYc z|0#JRBK&rnCRfIZaP20@(kl5BnIX%ojtGAb`2rDs z?QTVcKZ4s3;qTsGBEsJX|BE_BdY|t?-&il-qVH@U{>Q9|aJ6R0vMTxPnXDRO5QkQWYsD;4>e?ih_IKzWJ#^b`b?3HSIKt} z6UZ7=^1V$XSz0A$EJKzh!kOjBTC^tL7qpV)RWe?ItSuC^cCw;MMktYWs^nhRMYdff zv)(}#sPt@Bv$sU}6zxXVUG*SO^fIz?mAsY2$odiC%=(j6YfVO|A&V2?z9-1)h_Gcn z*(f4>6BtdFROxXcORMBaWXPISGF~&;Je3@m`D9s@T+=zSg+w^QBC-~($)~zjvLz~+ z*;2B+N{<=Y3L@NYC0U!+q-8rcN;je&U=q#!IPoA)6Bf{VLh9km%h8=6TApT}Av z!fU#WtfxxWU@x+AA{;M97T215+D(upRdSz5ku|9PZ}{G>k!-3;Mwmu6n+SWEL)N4< zIn&K#S(UWRku6los#`?XqLT4i$(E?(6ZBHDyh_$Yfoz3JTCOB(Q^`KGldU4c(N~id ziEw3=$U3aHg{+eZ@2y>A+f~x%4zfU{&#{plGa~$CQ$|* zt&_Wk8-)me-yMxE5$Si^V^GqzOCiEPr+uy7Yzp8FWgnys7 z0}=l7(OlG|EoC2?(cL21cXtoESEQfc??Xg*{@kAt;h!@1qj^@Fj~&VM zAw>AkjSnNjUvZD1tnE<_Jt-~q&1xYc{CoK#MEJS<6%qdEK7|PX9{Kc?%H4_ZJnTUh z5dMk&3~CX{xpl4Rd6BFM_X2t)+RptA5q^Yai144XmLtNSqgT;uwp|_(wXRA&uM`mB zXZ<=N{0eyk5x$o<5#d|Dg;q#SpXe*mKdkmo^pVu`JL8WL;m`EH*moj4W_yqYt8Bd0 zi17E%Pth8Yp7*t=Xe~=y2oW)O7UCXsgX^8|t!_Um?QZlfOpai1g28 zzD0z;9==2WvtG6%!k?8L=zEbKi60Q*``n2Lzg~7B!vERJk0`LYxNCVLTpR5}0ug>6 zPDR5+`c8BfBK+Q-jfP(vos|)Y@U?Rg;m_E)sLuMVzqT?>gtZJ=K=^(5Eh7AQ=OMzM zzmbUWca-z#g$VBx8L~VP_E{hcw5BWh0z~+kU5E((J=iEj`0r6hBf{_dMTqcc`eH=* z6?q9F!cRSJ3`&ahewIRGMY_tzp(~=9xhoOj`y7u5f8|U-zqfh*0TKQPPDF&?he?R= z&*DF#Mv<=8s}bS%;TlBv?It6_f5$lm5&j+eS~S&KPD9t*=rSRnkk2Y@tg29Ci^| zi%LHKwUXsk(y~BSR7o!-vfxfT5_ciOufZln_;uZk?iJ}#x(^Zl{QVgbe)Zgs2>(2q zhaQaf$UTGzzum*=5oxJ^it#AQS}n(sAi|ZLAJ1?h1B$$jFpJ+NAO)l z`19}{BK)29efFIQzk|q-1s~X4{*DO04?=MN~b@!WO% z{gCUT)w(|-{QmtPIzXh)jvnY&B0bXwURRkW!tpYqo+4ST8L~j7N1_)Z{Pl1UBK&zj z81REY?G ztgEi8EGNRV86%6UWM&DnBoS_xB1>ycR!@d3tCCNAIkLP;?)n9?A`xy^A`7&p@7@Cu z;YU9T5q@SzqdTlN7u|VXbmiQI2)|tuBK-S&Ga~#ob~hsY>bVCIeuR4w;qSutp?M|WC7u?fG5#krKPTyrx4+XTulJVv+vcm=_V@_x&YA_~Y_2S|TmOZ;(-5q~Bi`5aFLWuOq_u z@&k;A4!v;k7ecy-(f2=nl!oP8DMugvo4n+8wZ9#iy2`gA!uPoib;@?S3c9YV>`sKWGO~d1$MY*h_?7%M_X#50 zE7byw>Ecq(BxBe!OoOfe3pkBkQS>Zwq^o1y*z4(kJ0RTaX2WpUZdGRdy2L z=v`!iO27H~AKMY(cp0)hQR|lSdAdOMqe}MnCo+k!Wk4q3kHmI*A;MLVAq!M`tal*7 zKO4WNB@wteu(hzVExemk^X6XB_jN)t3rg|qk-rss~wH1Wjp;_Nj2yCWp^UHs>;aX zMA&D7EUh(pgUFBtD*gNyM}!|Qfe613ry|17>@+k)YWnTL>F5lReri1v4YOX(LWI8) zos9^8l!hZB`hFLU5b57iI0q5D>2Qs?a}nW3uS0}ib@gbZ)y_wwq^5rtXEY*w%Zm`< zSJuUd@O@r_2tTtii10_^QbhP(E<=Pre@R66>o0`}-{Y1Q z6C(UP(}?g_aR$v4>D%ZmG|xtuj~L;1?qG-DeS)K^bW`QhF>3aDqBK&jnDfE1_oqGWh{z}+?@#5 z;2vb%Rr0JVBRhZyd+9;O|L{pmeQLXD+_R?fFUDSen=AYjG-?|EV(gK(&}c;Xe`k6T zT4S}f=yQ?&tJ5z~r`5WqRhAP2+&lCVBU_=8K39@;|62O2{TnkQi+>w!mmnLfl0L_g z@qdp-b8*-6sqXq{o;}cU*GIMEQD8N91G<5K$r}Db$#k9p)1!a88bf`j^NS&m?7{vW zf%-{J|0QdG6t|WMl$_28SJlumh0;2W zh4L@ydZ50d2fvc>P?fbDh~hJ%xg^kd+in7Cuv#OUDz$f{muZOb<6Vz#u$I#iaiE(e zEoUIY@9m9<*w4-X8MT{GT3Qa}+ccL!fsO8NkF z_&vHC5&kIMgC4NjgXj^Fo|Q*Y_NHhba%iE{^jI%Kd8-xBO6%oa^mmcY>_gNhdf%}} z?I;lGJYAZ*TRPHGv`loM?9p=cW;z=EE%cGqK1Qo-yVa;DYL)FuXoI!fh&rs7EhtFa zvCg0jYxhz))9z+;v*{Lei)kjBX_|#*nf`?SWV#jIYMPB^o93W7rrXeMrrXi&raRCb zrnzXY=}vT~=`M7asR=cinmK>X(cb1zOLKH|TT#9_S_K7El-k#N1krYpp6MMZXpYW< zyPNfSccgA8xI5~_-GlBi-HYxu-N!r8eUa+XsQaQ`Mx*KXF`lmZ87OVF3@Y9i?L!Hz zla{)&)}sy9awGaiYI@y%i?&;B2ij@9>_UOG)b-;2jQ$+W#odqYH_b!yO!Luv{>4~E z*UJOw0n>x%LDNI%A=AU$X&>gS=&u>XP~V5?bD{Kd1nMWX@J@@;)-r>FhogJBdxX2` zBecx6a~jL|s^dtO!!ApA2LbIjiNB`q`n1h-giGIzX8D(X=kECS|<)!6j zQ30*6?N*||TDnKkqfsv|%XnF&zc!UYfk=NL!R1hnKEtc(G4z<}arC%p0a{>s0zF~+ z3;K)cN%W*?AzEl!gch0pivDVP3O!|d8a-`#20deX7Cmcv4n1dTK`o|M?#Zoaxct6w z87dd)FEzzbwMc()sRqSG`m0e1R3~~^ey_P6B}L&cDx$PVf9ooPvLgKzryOdr?OIV@ z6#fCBo@i%7?7MR{vkKy4x&uN@VikLFoIol?_Z1nWY9NcY{nz|nmn+O7_f=O-C<7)gs;Z8Wb1l z5lo<@NJmeh29b`?h|;3)_s>w1NaxawvLfAw9BL8id1ys>k@iwRMUjqPLY<4FHft~wMfUSLGc%(UJ@vfn(mQ%3B6=`8NF;;f|i(;(#ulrQsJ*( zprk1L^$XOnH2QUjMwDL4c;Rnhpe9lH3l}IW`o##jFQFFcMSs(x73HmE0Tr#!66&<^ zx=$}quSDaeP{S)WGnBTL8PsGgn^D$kIn*KwXNL09QunBUR#=}a zQJZbojygrvvVUDDu$ucD`Wwe{TpM?Gw~Tk%Wk{bE)6n!~Y^QJAGf;Y2^!&@9U>Wz_ z@O^$cTF$@C-ED8~`tDWqs_8ZKnkkR+rUEL^%V?RIdmX)QdV{C^8_`_4p?yTtq~*Sd z@V}DtOVs@hp40l~UxotP&b^7=uUXfaW2w!t=qqnJj zA}!rJ=p9tlURJQ`Rz$S~N{fDe4!vYhAgU6%m1w2?w$Hol(YwrApIqHg`Mc4qV<`SE zqmOA~yaYO3bj}oX21;AY3<}e&N1yM}^0zWxH`HA;MO21*isp%Wp>nImP+ask zsU=YIJ!buxD23A2GJ~?B-Or_#LwS*oS3pHkh15zY5S=A*?=#-}Xp$&~;-YXoG*qP9 z4MTO-vL1~RX)mKuQlxv7LgU|$<~ad1NKNO`h^C5kZ>OQONJq$^tf)!%0p+ci0xC*P zXI4Ui=mp(}56}mkv45%lj{a`?5PfKBLv5yZ)Q%4BlGXDM^bgZN(LYTep^r=-qmNDh zLjN*-f<7^=LaR)x(Q4DD=u^|b(Z5Y=&>GWPwAS<)`ponn^dD0Z6-^~n;wXi`;k}M? zyN6&m~vfYGC0TrdTMN~q8wRGRm%Qw{aIZt{)*>9rlawspgqor0rMXCK(R6>F1YLWYvnSIO5 z?iM9booI=u9tEOf50$rz@96WpNCouzcZ{IV%{RWQEGKx5v0aQTAbjmjL{zxlN7Cn8 z=xwXLgNO<@Kx!*co7LLUDyh{;ZS{ARaiYRqixOl>A{-$_me<-t)Cy#Q)!e7wRTkIC zc2X;m1qA=|l=Qh45q_SZq0LcC*MSH>>n&)j=;UKL65CLxXruJfg|>_KJ|FEsKT4l9 zqMuM8HQfjIKlDEo9?$KSakm|fq-BCEsk)pIQet;t$yCCjVitQ5%FRB~0dlND9cvP2fBN{r|BVJ<|4lUj@{u97=Z zf-J3)(KBROmE8GqWG$*aFJg4Eyh>UY$cn0g)JkNXs`F46S)iJR+`f!Xg!jl8S)2&> zFF{tPwMS`LPnOi$TPQ^~@0Ze2R>6F-ED>JQIkFa22Q6F4@|#}N}ms8X_f3zhAc~jz2wO9T9Xx7AS{mVWLcFQ!5mqh2>UFMwP|e>+qIJwRdUxTkp-$7WiCB9 zE<}Y}fXc|qRnljSEUuEZlpw29$@NfAmL&XBjclyerMIs!p zMAoU2w~{WhfCyKC>&0A%@a-^0R;`k!ObuCF^*wzi$m&#b52+_h5(8cNWnBGa4O)}) z&`6e6>64PINhRAglVw%%c9yqLv@ejg5#c&(CkqIFcRQGQ65&0$jI3NGZ(T96xJvE}39=*+_L3q? z6V>i>nG0D~Yx>Co5`AK5v%DwiDrVYzJAOHCcnM zH}fR;R+{a~$jXUugcw;|CGQ0ZvO1MqxAkO6!ap0y(pr=AkRfYQwbExZSyuH4%8|7Z z;eDc=tb+)9*+SN-lD+LB3siEibB8c9qQdQ>ml#=GCD(0&EJ=jdQHrcVYckJ9vb0Lx z`2-Cnsn5w4{@$o5jnJ+d3wK1BF=b6>LV zM0j5+Ba3TI-Ukz8bt*lAWTRAaHI61rs$|Wl$Qo3qF|$Upv`Y3iLzY!dpq3+RQOT9l zN|smMgbHLu)m^kKk#!Q`K6H@XB*5zZw>R;@L8#?_Dw(wbbGgURA5SrZAe2BN~r z@oXf^s$?bS$nvUxGtUB98`0N2%|5h~6}2YYmB>0(`YucsSk1+l8R6eb$l@v;ove-s zKiSliB~|Ws>^oTl5k6BJ$*7O?*vOsI{TyaOz z3sJ$ng*8Z)CBmmnjx103?~usahzcinqIR;PY91q$$U0T>#OoppRQkEGg8d`H^H)O_ zCo0^lY?mNw(3)Jujbv%9ZJ?GR%c|r)m?O)p_{DO5U~$WNj+BPqdR2Rk8|7WC0OA znO%S8LR2{EGe#Cy$>*E|SyCn6zof|0D!EH#$eL8gvPaEiS(P3MvLz~6Sxd?CDt-SX z3yAO;=LRr3!S|6eI$51c&U-ysk_g`fQe+KUlbJP=rHODwX2`NylXue`Sza}onH9*| zRC3p7Co8Hp$Q3{qsN|mIDwzure(Mk;i>u^pCdleka*pcBk}8>XiY%>?Dr}~hU1R~l|AH*5r;58G5q>LOMpmwpK4WBY z)nl|wkkzT=+}4vNReBF0ORMDi%aCP>@CwM0wPln9a){$uHwAclO8`beA>szs#S6|){w=Ca3v?ml3J6qnIcQ8 zx%d3{iksxbR$ysS9E2`w~T_Wow2Dr~;E@Xk$^lnBulII zyn^w_npE;xw3#fclILHJtc?iQOFLOnYdShvppwr=u7-Ul!nev8S)2&(g9);v)ly_x zmE6H{WOc_lD&1GGtkTw?mFZjx4XW3s8ZqsFD>@A`4V9Pj@V%6Mk>Ws#Vk3u7)hGdJHAV z>QwS=Q$1NyC9_VEHK=5}MzXZkGGuwx3YiO8o7LLMiYhr0CDHNGTuNk}sxR5Di!4yd zJA>JWCW_Smi1`V4o1+wQ!^SRrKTgKP+Fva$0dWBY`bPOPo#gVWj@M^bc7sw zL!`aDiB{MMD^Z)&bY|_SXxo)gU@hJEd=6j6f+%$4l^B&4mb5heN9K8BTPr4BTW^k!qgA-GxbOPO#{#X zQzfc2RiP@=Ks3;F6gtXuG&vM?I@WX?I?i-FI^I-^YE37g6HJ59 zAk$zp*z{}kYtxD7MAJ#=B-6?0WYcfZZ%n74Q%rFbHziQQbSgU4bQ(I%Gz1MXosLd7 zoq^6Uor%sg4Mjsu!_Y9(S?Da&+30N3a5UUB0*x@8gU&IXi_SIGp*mANsyF=>{nm6I zI?pr`jWnH)&Np3vE-+n)E;NloqfDdGXwyaLBGbj_V$&t)64Mwo#&jvV)N~oT%#=h) zQwpU_zeB$>U5+j{jYVTkr7M8RMRvx&2&Aw-gE=H z!89FBH_bpZOgEw%O*f&NOlg!hWl*N7hwCZ-QqbLuZZ_S5ZZXY7GflJ5EYqLRpG>!+ zTTQdkY||Vx$8;OI&2&4u-E;@K!!#GoHQkBsG~I>nGBu$lQ!{Ed-Hq-x-GlBi-HYxu z-G}Zo{Tcn)bU(V^G!M-)%}4W151FXYo-#d+o;E#$o-sX(o;5v( zo-?(e7E>!~H9e1>H@$#fFfB%lO)sJsO)sIBOfRFCO-s-c(^9n5^a^^#^f&Z3(=xQo zv>Yury^3Bny@p;hLKn2t5=ylT@=nd1G=uOjG=q=OR=xx(G=pEAvw8FF!tu(!h z-Zj04-ZQ|*7O-qNxleI9}c|1Yf%MMN+6^}e+1gUY4l!0%BE^|h8qpnj#QgWLw` zr9Y}FRr!CvIS^Hssw&(E^1m6^pyO=T$Cs+&wWX><-7dCEkOiuP#?#9QrK;TtKAoXG z$o3+7yNggavVBxj(7t5dRZXaj>;TnMs0Ue3m9*?dR<3#*#mM>*F}FAWex*NIwbn}1 zYRCo=z1&VTm@KZf112y}vY}QRMpj3}^xw*4zXt=G35vWeq=Q#Rw^=PE@qW_Bbd^F0| z(r9$C9GBgGMK6~i!uL4_C2eJ;&{*kn`Yu|IL*r$;=Vc8}Koh0Ujk2yMp{b>+{oUi8 zXd0R>dvv>K21=K>Ht&*|WzbBy8i(x0{>?(OWxS8iqvafQhv;<~Z!T)GnKh$(Wse@0 zUhYE=S?ytzwLWvKGh%>yo0bW(V1ekrvfUHZhyku;KmMeGEJ+;U?m#KB;K``w79!#> zck2GkWfA(TUHwm?r@0DT&p*)TGw50ArCj#tIn*Mx-v7sTt!S|wvlr14xodnU`?nM= zlk=zl(rh`(%XkCjm=(~QrK*G72hH^P7FuC@yAriYpZag$+R;a{>JF9l@-bQ^`eaXf zS&i0M%eAN|_m>ZTq*g*3_}xohzRa`mU?mod`$YgDfEY=Z7OuKbvQN zR3&?>Pp*Ne+G;iEn5Y+bEF$~}$04Gh`%g9F9ghatu^x<0vR+O`@pVWhgCE=yEwLi{%O*i-;p#|Gtbq4o$T2CZPt|_tihqvJp*| z@iGsgDQK!3dA)y5L(^?z#c* zdPuIS=j3X97-em{99m#&;t8}+*6v^AUbhH6E$4QV+_o3MYLoc z_j&zS%1hC*bybJEyW|`#M})uAzKZfT&%(N@G!fo4GGqa9m}`~oUPpw#qr9=MDocb< znH=3r^_fEptmPAEq12|z2#e4v+io>lBm1E5 zOKVYaJ@<*Dt$cmeFI1SLD`2gMC|V-%V(MW5#gV{ z|A!9Pz_~r=LiV8t>M1pSL+XWkZ;0k{2r8F8^_@0``bte#Sg9v{FCnLhQoPwrrh~9o@ zAi_T>Z$#-0(bG4BZr;F}*Ef$_5W!oJoZFd*@U>YRs)i7Uxo0`Lr;`mO4stbIx5LPi z#DQ)vuACHEnh4)fGGw;LsfSo+=nuLS+HFJ~avkX#?iRGwR{1v6DbhEOF7%DH{1$DG zj+xtmcG`BkP_QBTzQAqd8rvAnGlu$Zv}+9Y+ZeU%kA_;yVQBcq=(-(&>ZGRs`nMj9 z+Q`+hSkCllbjil(DjtI_+ZeqyCpT8*iGy8|BUm8g-`n%vbG;nT6dJpcv#Fm+#-Z^W zIr91rHUUlC$Xe3h%bSE6HgZ+zr^rS$*|wX4rrLJX&~%&243yp&9o-C?xsh}9sEj@f z&ECk0TrQe}?vUC)MRPY+rSID4S7e4PAdYnV$Wycl5#efe&FEem;Xd?`%te2t>S09q zJNqLWtA6%q)N(hnfbjS0EF%29Jcky@vDWX2oqY2k+4m3SP4yYnVzpMZ zSk9P!26_=Ku_Lh*E!)V|(Inp`EJu0S+b-GL0(w(sb_L&LxVJV|?e%uFT{p6T=;QXg zik9!76>`k<^TSH?zMPeV<(cyVYKyjW?dT)vQ{NmvMysNIcdOAFYq=I}kUr%N$!$c0 zUlW@UafG`{zIWSBEpZ5LSt>7 zLLr?+Z0{l?<2y0+w=iy+f+5!4Ze&0 zYeyg1YW*0kvRSW2pKjv*a;N-u-@nls(Q>)^*P^1$rGz$Y;_BZeZ{izKhxM`rZIzb# zuD=a++VSi{+oh(fZU@?FSH~_yRJzCIE$BxSh+dT=;WqObVRO~5T*HZsx5wtH?|;78 zUsXSl1w^^K^?tV76A}Bl4%u!mMEECjH*~<}=qm1kdTwT&eJ3q@p@TL@YxiJ8_`61L zMEIxLA*kH?jG@Cf^Q3(BZ_KPOA_lkv8|d>0)NeCqQ@`2kkE%BFwD{sj<~a~mZ;sAb z4LVL{ea0|)IUWt#%u&+sVh5v>WS$%Dq;@ij%RKc{PXZ0u%z4o7Mo&jWH&^+0lwoN2 zX3n3!hm1gVo4Jnkowgp0l=1X?+VjyU(Q_X&v(e~cJ4%-zqK{iHwJ|8UIa;kLG}eyU zICO<<7ngm%5{;K*HdemFoq#6FG1KpiCn3Tg-9IA2f8x6e5k1^_(n|v({C8H3XtHfL z1x>YiPD9gWF8W-VfzomW^%Fw|5l6Utvbndv&4g=TN&9(kpVJ_ix} z8IkP6ZRmCz?+!%x+FW$6&FnrjPmZU)@6JaLNuQf#T|bP7e(pVUfL1i@528;#Qtu&?E62_ zM|O@rMyq7sw;s!pSdG@$id>6|GTwR_uY}f#^iH%M1#(sC`27GB4pyCh z?%x-6m)fwy7`+S~U@d!~o*mJ8>4kcCa8~qt(?d{s2iNq^8yP)@syi6HN>qc6la{(d zjz=eUMC0&-aCPV> z_7SMg=2?%DG8g@ApA>2k>HWG9P43`$z9xN6LBwHhmb^V(i>7u&D{>l|-oYoZRnl?> zN=r-m%;hrZX4xbCEOQGY{1bg9BK+B$g=Tj|cd$9AsUtco%_u7~(?74up(V2K`hCGt zMEKA4uOPzT3;u=(f9G3<2;Xu!BK))CRg|}u1@xwz_dTv=kKRHnY`c}{eLJ@wpf;K3 zA=0uPeI$FNZ@3?KRHgqVdnDgTWXJ-QzBPY>Rz)?p8m-auw~e*C78Pw~CA6V~Z`zKP z5jLXDa&-0ktPVu@J=%h{+7aBw=tTI`&XDDa@SVLt7Ff-Fi8^gAUFaK;euMQb+HP~% zfrx|L<H@x^Lqy+$<_XJ#D*QsJFCyPHKmsa%uUND2Dop){FY1Dv`Se4MfL@ z`iPE4gG48b2BVWi7mH3tanUqU0u2$pemh70bTm|Szto1I;oDfBFN#K>I;n+!5`#vH zR!i-CG)nY8(P(suNI#2?K}k`M$<$J4tnA;hqH$=vNZ*<#po!9QnA9er2GNzGMl@M8 zOEd*dwO*#7=~8=KYBNw;j%PuXK{KVcMl=h}7JV<8gYJ<1>p2C@MfXadddBWU^Q4!P zq&6QtB->pmdKhJ;LRKL@;smPpIjq_!00 zMZfz4Dxfz-Yo+!US|PpsAX{@E==p)h5qL0xk(FoCMw8m;{QBm{< zsg=+MnVG)%Z$w+Arr(imL!F{I(y|MEBYINwE!ru1OSB6GqV*#8CD+53NPF1>?Im*8 zQR{~G5%m%6i@JZw{+%o;LkCFhJW&tSQ`9Kxg?fwb5*>odMbC(0sITZf(GjTMm+a9N zQGZk=wcV$pfv8$^n5YIFC(?Nyk4_Tl^>8wZi*(H=&=8qtT(&zM4HfB19)^aC^c;;q zb)pNTWjz{Yy^Kbei1f-CgObv6qO?q*=`w4*hs;1}X{pzH2F(=d^)L&~mhEnq?dG66 zMDs**(Y?|#{EidN`+uJ92CS>QeEj%Xv%uj5A13%P!RZDLzJpB{Y+6uSuxURiEhufU z>4MUN(gmdrN()L0N()LiC~Z($P+Cx0&`%3W3;t1Py zv(#DCE&74f-hliCnEg&XLZ4nS=l3TJ<-neF_TuFI&-8hogx9Qp0U2n}#AqT3-~JCDNmK zHk$w6U|bfUSSiy!#G%C^or6nIyez5vF99V=eL50JC|Q<_DWkp=w9IN)j#6bwJp$5D zy0tn3t+LuzqfA*+$2toYT75-misUI;q2nVT5;g z7*6IA{`;OZv|^Y$>O$$!N|Zj#+cdUlv+C;i^qbBORi&E13u z|C=P65#c{~JdX(fndC#La5#8cDnf++=J~^Di;S7RIoXPegHduyh7+@i2b^7uOAeV! z#5>=WQTjL{=+PcV=@W=p>YSCbZHQRv9F(%{s8sf!-d&WT9m6~+-jF4CqH@{h{7<0@ zw9EFxZuA9dr@jlSM1((rRcMcm;9gWMW4)!G5v)PP0_W|r&94q8mJ^GdXHW%MJMn;1 zjylL(BG&n~EcrFG&(_EW|BF?%i17c#sycK)>eF`+2T`N7r3oFeJ{(2OGP>`( z#7MNDW47dR)M|UW4V{qQUi&Y7IEmUt9n!Ln;l%XQ!CuKAb31MBcA+!U7JVmh7IoYF z>_O+OE$7jNpzqE_^jFzOKMbQ~ucChI-vGKKd+fZlG$C8jPgEw6Yg=(f_zv<2&&B$w z^Ihm}k@IP`G6fOI&SKFUM-s~k?q5&^nM*w6lt|f|5aGX@2}6YcZYCT>j8OYe<^9q$ zG;1U{-e)5s&iRIH{nPW=W>jc>C_*2WJvLwRP-@or+F^Dvr1=5CR)EGgTHibt5c z9}|_JZ6lm9^%Krof_tw=wO{~bDGHJnA=(v~CAhaS``+tmAzKGbhZ z4xqnTkN%E`CC)AR9>gUyXk|m_ij2H|&+#f6ws9Fju8pAc8pq#j!FWzXk*@`Pk3zFv z3+Cr+6m4ZOX#Q)|uHS}PfMP{|kUqqr#j>Q%x+RE6cCJa;gD75>)Z-xm5z)?~os=y_ z55LB|P7@^}!vAJY5=wq8I5VW6WzsS|6D>!CKd&E0sUkg!)6k07g88`;rCaS8Xw_@M z*?Tq0w6ZL;#_C&(vTc-dQ0{BNKFUM+R#t#Ey~bHs--d2Rg|?LP(lY%laxdB^eb8T!>_@e-SG;eFpnB_313DyoT92y3sL}R&6FOq^DjFX9ka6Is8vQn|815wbmBFRF}+hgiGE@G_Lr#rHQogOT&{5)h?wg%OOJkqPFbr@ zqfTpQ7dm6>okiWU|MdIUJ!shW-v}c7->AHX2!9`4L!;K}>*%JmQ}2+*5aEA@GL9ys z20dp@BG=mDT;nQwjgi++X{MmCYr&mkIEua&oL^#4?6u%%j6;jB1xLUV6n~AqqMuMD zprzM>Bj8~~_(wn@BK)`YNhsO2l7g09qknofU5*I_p|){O<+vZxV3jvTg20gn#6G9uW^Z|N0|Wpf8}xYs?q@m+h+19%-3=&b${@ z+g57OK50vu)V?3pUSnqI6J{McAZ5=;*+En<<0-%E;a^1H*p#x}Qg--S;zXm2=P&pd zMJLHz;(q7JFVU7JbVT~M`j;G4M^UrY-hz(VcpgWsQiJ^F$7w?+Z0?>!?J{@2FZFex zQ?~z3qfU`t|GLl_tKlr_mL7G;dOhfz936UII*)p<@mqzD%4g7hsNeSV02;g&oX>{P zux)b$jmqdQl+XLGqp@q8C-fVG<7mQ;he_m0%e;RVYLxrkQAR@VknTc+f8@LY5&lfR z8xj8Zy{DkCQN~4n8Rdkdh*5f^ZwRKL$Wiu#{^~6X&5|WwmEO)qgnymB4-x*;=NuG0 z%9;JH7g;g}EglVK>kHK%C@6Y8_zPd!^(D|7o~<$sl6N#{*hXNcFB1DxsJB%MwL>7-j`RQ zJvQ=tQT1qWG}fSfqs*R*^8B!WG_k){wj%$|)&QAH@HhXYtZp=Mg7EIMCdu3bwv~hE zrP0JVXP>m}Wkf7?j*03KLCZu9=#Y%3&d9^)d)B}2Bf|e)^ASY&=cS{lS!&n&tQK_4 z%8sK}sa<}x=(M2|GJ-#qZw;J8r)ZMKjdQ%O6IleMbk_W^BW?J60U4zg1!8JE*!orJfKy2#F`q(^7T zx(TljJ!I#!Oh)iLS-+OaHV4RDm2A`b$7o_0;k7KBEJ7tqP9uvXyxvBUZPGGXZ!_5z zmGo#U**+`VPgYBK?W`j^U}Xo%j%b;T(owQzmCT+NvSTV4v*TnZ2(R{&WTyzPou|pp z5MB*u$hwT4TbY8wZUpD~a1?PP zxOP|zEhopqGd_F!p5MvH#no{6WBa7U!>=k1?YZ} zKAA2=u~rs`7T@4HHYQi6B`8taqTez}LdnujyY~?TgkYQ*!+~#Cp~H*bF;1X9JJ1ASdVgL zD|(H~LqwAEKB@2hXoJ-LInhRxZ%Y=SO;-D6R4AjXPsv4S%MIR}_J4_XZbikmWC_}4 zZP|`WrA)7XWoX9@?oI2YzMZICdaF;S6=;|3>D`F%$Mf^3QtErJ{L6M#XwQw{NnyrEbV2kt(M8m6H4LD?S^xfy2)}=q5aAyW zmyv7jbZ#*?n*6=;{#r*C+6qsp7i>j~*m720!?R?nzFdr`Hl_dR(_RfE1JOX~OT z_o3RG9Mk$e&N}ohk={WZK!pFzxPyrBkB67gA!(WZI{h#r{P*79MTGxd{#Q`rP3{%- z4S5r4z8UPz7IaM3)8pYd`ia&4Q`BnxYePS?R{tCk{+u|0h*?gW^!6lbw zlSb+LY1C=k>_TT`5Bi_Y6N|AcyNKlGssw%$c_N&4sgk`DdjCRe95Qu{yA;LSw; zN-~75$bQgcK}7lmex1$XeXQM8rCp!s9WkdI4`7NFQM_PgHw#-Vsy zG65xyB`$Z~DrfH`lsv{*>nHUoXxSKj(CyAR?A{zX5*<5&j*-%c$PU8qgsbdHsy&Flw~go6z@cKYSk@ zk@cRCzXx#?HIMOjxl`(ELG98Oosk{rl#SqN)M@SPLTAP}s$P}#&Z2J7l+U6bbWZfJ z=sfzXjrFUDSnT9TSwABDtqh<`qHR)k8T~_+{HExiXi&6Io;Qck6&t0iXjoeIxcs(r z1dWRH-|4-M#$;ycX9eTPwYE6p9Gm08ESQ4A#;M_?)EAB-#)IQ_8j2hbWWf1UjC0n|H^_?-vB2q<+LxgC@nCNzphQ_u z?^Kge@_6umHw7&lr?)z;&k^Tu0{-;8JM!mMj z`p^Zd{UYig56+4M=+ZcIN#BWIMuXCKJ@*WuE4FX1qG8#lzIh%&qt@H&XiQp`F1;N` z6V|^;h6D8kC7p~zdoJxUacwX!(0_*QT(UxFUAvWHN- zt(SljMf%PDB$RxM^X_xf&J?svq`%x-j#6(0{YyiHf39JCPHd)zb zR48S7+!i6iZ}o@KHfz~-RC+5oj>^yu>AQZ~vJ;itHY;u=mJ{Aprb4tUsJ(*BCHyPU zZuEs)JehuOj3cfR5q!%;`d5Ya$VljY!Cq8-E0~`(Xy2{i$k~r-r3Srn)uF~)!4iCiOa5JcS8x)w%Qp3wARXwG)V})wWv9^@ z88iLv&so%MHT0l!GAH!;;XEREBQN#+2@(A3$?sw-e@4V2Cvh$#(Tj-rPF6Z)edvPh z2mQ9lMbvLK44_NW2mKp8m(i75!TIwl8kQQKk@`l^Sg;?QaYXocqqh*@KfT;Wgg+-H z5aGW!okXs!=iKIKydA7J1%=(FzRya1;V9yEP{TA7d7HlL@5iE0v`GK@Qw*AaJGi@8 zfMRbm*7_M(99k?(>Nk&;pm^J60!qA1Z}qRNB%$Qnv|9hlLke0ZOTL)H2rfsdx0$W_ z{E&vyr3O7`WuR5I&DAK=+LDFVNDcaqdM(Pn%@Lr#aLPgJr0;qsz8>YudU{Wkhc-xC z^oZMt2>&kVgD795cX9hK!;>2 z`hESwsL{%r&=KjoKD``8&9?tq&@mZ#Jr^8Dt+Jl}cDxOp5b54LiP~+w4s_}^$JoYg zjM-__d7C-^a~b(AbVkNSKOZ}b2!C(>9ufZMIe$Qe|E%4O2>;B`gU;D_o=3g1P5JA2 zP9M4;x*+vkME%x>0dz^)qMtBcMuRr9hS0yQ_A7|+`*#%$%U;pDpb<1GEz`dsa2<_F znf$drXB#=Q*@{<+}oi15o+A;LdyS0lndN4^6Q{$IX&CnEf} zG7CK=BcZeWX+-cnNBPui4I=!%&bb!7%eL}Ai14?PjR?PGIcVJk`{9nvko726){EVZ z^3VnuYkjA>5q)qXxQ~1W5f3?!%aZws@SlAP&?af;1}WQ&wpi_35y59?Qua|)Y$IQS zN^Qw9v_tl$9^pID=d4G&5aIV|H>$KfU4_0VHRyYkFCikv*(dewL4;qn7ggJOHE5sJ zupbeA4PQruzmL9wYHgm?p>Ikb^u5Nn5aA!G2N2;OsRvQLZLnCxl!t2NGXMEFm%KS6Cavwnt7*uFi9+9!fL#}0H#dh7j~1$A2AyU-cy z!&%fV{rj%`6_Orw&c^IK`twBauB#Uj-e1sh`p^Yy*+tYZ>*;JAK!euzA#_EKaQ#b~ zSJAMQji6B*iR*}1>dgBGZMlJnrOsFWhHj!US?}~^G>#^0E0f5T{h-%PXOi>FWbhm} z1%*x0YW=26IEt7I?xm)o$Vt8xaYW`z6q+^36MK(nHj17M_H7J`mG$(wBMvQ|WFP6b zfR~_$CWCnvj|l&bU;;{<3}#jm3fYp6Ai`fVd6M4^iay7;iz>)mmCoxFM0o$wj`JuY z{D1l8F+_O(+KjUd5#GO4?JP&Blgvo{Ykg^Gg^YxL({LqvtMoz7!s&?MSKsnY*bKC4 zlKH8h(XK|BvK75I%|e9#`|zg_;oqA+jfi+>P)2YKBK)$oC|k<(vzHvSZZgsTh3|Tl zD>Y32JnQA54U^1W{hj_slrQ7*kdzf5!r#iXi16=%oQFXBv}< zcNYaMn?mLi{=0+^BO=~;yVUoQ$;50T*7+`y;zI?_UKX{IbuYT{c^Hqe`1~RcKGp zYG*I1mfH3FQiHx~WnV-4q_>ZLm1Ar_sNvU{T4bf$(2OkZ68F0KPO&7_0lr^ zE4j55f|Z^@j$>kIcd6=W{ql{vT3L{R2TqSs{v_3t^}L4^Ok zLl+SXoJDMfKmCDT5AHRbJDmCMP=nsBO+jIIDAT*xa1?Qev#I_KzG*1(PH@JILbGH^ zed?KwqVEL9dkmUy+gyNR?{IADb3hzgd?z@EEJ1|-l>8uyx7riX!*_TSC4a%nNkoMI zS74J+vaF}?ds5J&w#~;7;eXd?86y09zU3(O4(CCA^Oc5H+~HjQ78#e7DE$uSI(=5l zK&z||t5N2i;JlQD)>y08qHGx#{cB7)Xq_#&9_3q)3ecuI9Jl)9wiy*lTlC($2z}Uk z^bxd0q;Jo+qT)O3KYiXTLECJb+fk{tvkdK!ZRW%?&vxEPOeef^PX?L$qHIO)y2=sZ ze?z|l?UMEM?;h+%m9jVW6Nf6aM{4lihoEYyUBAm%gAQ1$52AY6ivGQX26RaFgZ|Cb z!>I92a8+wUM`UK{w<(UIX6sQ4I%a$GIBK`D4s^aWr9NlgO2k(7)Q|xO@}O zMf%KjHzNFYPC;QVS6}_MT{wzxgX3=+iWF)8qR=dnj{IyC?FMrx1`+-f*jz-!IFHHS z9Gi#c%aVF!UVviVU_Zp6#cps#Sc2lMECKOf@TQwQ*_KQ}%d9QS(VK0(R7Ch!r!=&} zWftgFbR|lcmg&sOK&xykt5K$m#B*{qW}!7MGo(ti7G=wr>0Ze}@3EGxLxg`-TaR*O ztn~=bLmOPKIsbWrk=Tfyaf2f~9})hT6`)PBO+E8%MuoCXy=N~%#n!(Pv`zY_e<5f) z+F@loQMvWK0)5t6_Bpi64ekziqe?e84_2W)(iVN{*^8>JtOo6qaru#q#C}xkaz5)7 z)u97c-$7JwZE4_{^^lawexD?B3IC4lFe3aKt!CgRP;kc_`i2*Muh)+rxtWfq(|y;)N1>v4V`egTIlcpP9nmegTFxS zR(l6JC3{-mHl0SDZsH?Ozr4BZLT7Amo<-fVP5rF32c45LJ&w+!UfGJC`TEcWm#fTE z(ua%aRhM(5es1572!AFIpi9yRy>4DcgLZ@up)1nPb8FbjRW$5!9nl>4{QS7oK8ajeQqM1ZV$8qJmlW?j^gjCM+=cEky#c+!bT_)&GzCpDy%D|9 z^d|HsQy2;}g`;rOR5aCe54y(`fg()P&@|J%=w8!wG~E=5B27^!$}|JbFwI0WO|#G} z(`+=`bRW9UGzZNwMWbj_42m($MRQH_&^*(8G~cuUEim1W?l&z&3r(>o))a^0OpDMW z(*x)M(_*yPv;-|NJ%}DOJ%k=I#iMvr0!lC~MN3T&qlZn2DAAOJl1w2KGChJGF(sp9 zQwmBkJ&GPRJ%%1LEknyp%h7VvwP47bQGW`$wA5%8UHszok)4S2TP47YPF|9-EOzY8l(|gf-P47eRGv%UO zQy$7Qy&t{b^a1n%(+0G`v=MDIeGq-n^bC5&l#lXF1*pLEEPB@T9D2^Q32icMMw?B~ zqvuT@LLV{}qC!&WXmzHIA#1%1U-jjByGsK)eF^i|W>(AP}+&_2_C zwBPh~^mWrW&^JuAsMb`6>P+86-!y#-eamzJ9WWh42Td=bmrO6CmreDk-qe5^Oy5S| zHhl+uCuxQ=Z@rA!A#}*f4x_`S@1pOTUO}&z8d0OE2{oC%hrVa}KKj1t2s&aqijJCo zfPP^5A^M@I88w?)P>bnD=trg>qaT}&p<|}w=(y=8=qIM1qMw>tQLCv9wV8f~erEbP z`nl-@I$=7APMUs!eqs70`lYEIwVOInN77Q~T&e7%U!h-_{ulkP=@dF;I*m@7evN)@ z`VIPxsS|aYx=@$tx9GQ~-=W``&Y&}2{O2lbf#ANqf$KcYXH z&Y^Rr^XRb5A~V;g8pLqKlFd53+RIBBD!e$EBdSHRrIQ<%O+#qN^e^-;)4$QbO;^wr z(^YiU^dIye(|^%_O~Yu|G=fGk|gN7qd^&<)c~bkj73#!TaA+;j`w zGTlbEO%rIsG>IlnucOyZchDV^i(Hcv;%PACKNmZ9p}S0PKyL`~uU*LBq;&2Mxv_T# zibLslhx~UQ8L0g3&|K$$T%9VAE7HGZ=}ZZ^cTWjTcV3U;sx}3!6J3&jUWMQ$jPGoh3Y{79hf3?^#6nWzV5avfde4Z!;>ivLaM&>s6pVR^MJ! zZ)FXr(bj81N2E+!eH1lM3Hk5AT99k&Id4R73|i*A3BAb_7ILqI1-goc!-C$9AU7=N zgA>jug|p<=TE=AxiVT-B8P6z0`0v4HAi}>dn2Cr5&OP!pKMN7FoZr2V^=6~!aQ0ZA zY$XN}Go1Il2hBx<|EcplMEJ8{J|g`0U<=S@le?1uY8?MLVm%ftI7xa7J*Gyh%($E2J$W??x+8x~RDZWuR5zp*c>athX8we#_p0 zh#AgbrGM{4g#SJ=6A^Qr!uL{^h1SScrpS_OQMM@e{U`^mld;x+g=am=m94x@#w8CC zi=121hxa4merJmG_5+CU%QhflhI38U+lcb5z5+z>CSUsSEP77Xd#7x36C&<&3Pqby zp|nN+o_G=3BDznuxfK;#eI;m{jD&vHvmKSn=tgW{$uhLVwz3nI%l><}^t}S@lCm4J zk9MQV@DSfylVha{?U6p{Jll(^rPT}bSh5D~v#sn$UzhscSjBqZKm@<-6xAYv@4kxa z&;hAW?^F+>m#hyjqk5SytzV^v26RZ4jM#z>qeki9zhwV4p(E0k*Ja70s9DOMmp-(h zW3ry^zvHM?#`8I;y$!iK&twFhsUbIPs?0i3I3kuff0BPiX(}T8Z$;dL2)`@>5!0QY zN?WEOg1=NK_mTG^!vDKz)6uM{!Fg~tik=$Gi5N6@YG|GlzMu8xq4`#}0NpPtk!>zS zu~Qk{Pf5$-Q1VpzuE$skS~fM9JR-$x~ z&a(`(O7vx!k*iUrm1Uu)WGlKKo*b*hQ`uwsiNi*eZ+$30 zn`E1J$zIuv3T2yFQ|VC=+9F#yE^XP0il+u=sS>o!%C;k7j*}=O_yQ`M$}D)B=u?RB zkN!`i9k%37R4(;>Mp|8g>aEoc=#bRMk&d8D@ z`&jZU>b7y|LFZ)F{rEUng!8D^_E;afU_H8s`lU?Iy94NwZ1aaw!(}umTj@W_R))|O z>%&zvEbHmq9YLeg2R*l5M`PBOaYXpX-z{`o`tUKiR!tznKc**dGz_};n68`+W z3laXk#v9Px_XNko6clz3HKfZP3r7*Mp3a_WXx2T!Je!RO|G2#m5&khf2N6r0T}RnU zG$Qyr>!KJmU&{2`Qwvb+J)A#lzD`*jT6|A15=&6L)t-P7Wh**+k`OV=`I59VgdVXa zlM&&UrJ!Zf>VL_ZZ#hb}t)!t9QlDPKSE6)T?;dGO23mDbFke<9!oR-117%8?{!5lw zDBHG@gVx!4>rt+h=`(X4+Ft{fek?+Nuk zFSXZlG!Brt#Qn~-dzo1uLWMz@Q-rqM!&SRR?lHF_!k_aWMa9Q8&%qpRcMb$kJP=0@b}-BQML3|ul6-)pGc3K z{is%I(6e_PI$*Q)AR_!(_Yxxfx%4t3{G-1f5q_&1&>>keNv=SLQKR%-KZk8XM`TGo z?;b_Xwx?UrF)KTcT5bQep%c<0y$?Bwh}q6F`>Ej$i>h?wqV%iTpgBBne4 zkR>}%r}TD>935SV@Q>8rBEs+A@6Z{keTJON&mzJ^OPS7|9(2z7a31x_oY1F< zK6Jt6#6{FE>*@R)K!ktf{0$NQ(f@ZeXd^#_2!F|cA;Q1I{Wl`~`|B%+@Q<9UXjsbL zDkCw1My2mHG7{I(nC$7nO=uiVSUV@t9oe@Pay+<*h<4r}aw0f`MFi)wDJV>ILas;Q zC?X=*N7GPb1nZraSrCP0MFgWe8_kg=_52cz2>-~5K}5XsSDEv3(LAYP$`{dmMEEm# z0g8=a%=B1^LyN8UCFsG3VD3JI;%&(UL_|ArvdyK4nB%019!7-U+eAe8=d2_|_;*$z zMEFO?BPcl{82J>mOqSGh`ErzMElWcyB7);#B}$hi^|;MIt0I`mcguLLM(?mOdnY3N zU$SN*!tZ+)S|i#h*W$G(+uD+Y-fesAJ!qZuR__Pbqg+`}@2~UF`>jVGKpSiyZA2fm zwmgFfzwh~oh;h!yb8!LM6k*Q@s4#-#;SUXz6(PdErhXU^3!UfWp5Y^i@Q?5_!(>>jQ4$1!0yM)82QTqN9>3b78A~UN% z`fwCAOK8puTDQjg#QHHiir76ZzD^#p?2Hv9q5$w;cnT= zY1CXshq{dEsIXZ<^m{$z9Fe7*@yb2cD?@`K$m2jdt^>r zMugvoe;{IsGh+$Y;(wxl+5G%B8nziSf<~o2{VTWE5#i6>8)!^grbqucBK%h0LWIA^ zZbyX53Ga$mLFN+vdJ~A4?c5`GoRf%H=-gjMt6xX1)TjTVnlp_v-!!(OcaBp~*fd(E zXRvS-F)gTl8j2O^e2GJgZOJ7lUdoQky;K59oJQ^MlTk`S$x?$}nN!fRX~7wDIZB=gRMq{f5g255&kvqorv)FSSBL;;~@*Ju~x4| z*|uH|S|??CEnbgurPX?0mxne?3$70vQNCyfvsj)DkD_MTNBX4Lf{sZ$^;&!!wb}@_ zA;Q1D{|ue5aXE?FrPVr@I#8#LL>D?^OP)o)m$vAc{SS!n_iZ;K{5MfO=$yHlE{XLPkQbo0G_u^-|=mk#jFc>b=3pPeEb#21h_Riny2F z>YKf3X!^au%!))rtaC)R5`|{TlKPfvHj1_-W6*q&?v(}T{(FP7+d>q3Z*Yvop~d$G z`*sN;{NwgP^pI^c9ua<70!kF=dz2)UEKBNDCIv0KH#F0EzM6I}N2&KR^7>974H5p8 z`3Xe$$L5oW@Mrl7MELLdR-*KKnLYEQ?-}TA(g%H}d^;liZLUIufBsyJGG%l-rgA)F zp*8n1L-g)-x>7b5(##{VF~KQCn?!XM8Zv`$*B_sQ!~uCz?wmE@rfvR8f} z$KOUo_{Zi45i!Sk^)mH6gYs>33(zL%tzHc`-y6D{NODp?8SYFW3nRQYv*BcsRu)C( z76xTb5!zyVb1N#g9+jYNwvVjCwbq9^binq? zK~!%&YCwmiNBXa&A4ZL`AJV=@tDDdfSx?_&97WBxUJE*A^ZGbywffo+;qTj@A;P~W z`Z*%}Ie!8X{@L^-YL~w2wYUSFlC4~mbI)n?YZ+aAQ~Dc3_`eTV)NxH8kQRLY%_vJMS2~(j&9hJH_@2XuGg_~bjxPJZ8RZe`etYn zy)O0X+q*l+wYlU>=lnT6INqnAu<6vN@5IAV#B^qlzDb;hBBwJ8yk|TVJ)IuuwKxXF zigX{vp+zFSOLzbgtS3kDVnq1&pG#1@)Tf^&C7?u+KA|L`(DdLqdIS;v86_DJ{#82# zEwer>N2#)v8DC@M)6k0P!TEY6N}nEF$1>0=>7V{~d^IBc`SK1#`1c|2M1=pDMkXTs zzbur62!Fk&5aB-`J&o2#Z}mF87G+zjbI>~JkA_K4h4x50^%-F=s+J{nUe};~(iZnev}`}BmA+q) z`BH}tNPT*JIEd<{K7Dd)K!>JtpZx3Ja6f+-HA?^VeR2~zA~oo7bQCq)xU`^SvN!b| z^>Nf{Gph}qkTU%Q=Ok*E`J!(wJJ2bck*86ojYJnZBlYRrJ&U@f578f>M?L7ANY9w( zQSbDS|4XGlbir!Bi26l(Pc(oo$(ZTY@G|;`)c0H2fB!^-*1sWiMWpxMR}tYqf&B*! z%YM-3hY>U?OX?ZyIvTV3#?gfJXc7?%oR2+AeXk?e>T@DFYeaHPM}3cxpMt_7*{1Hz za76gOqMwQ)B7-Z*G!!XIYCEG)Y@{6F^5h+d7Dom%YYBQVGC0B?Lh-UqeF{lHiCUjL z10|v4NcyMmp;OSZNZu$_KTp|mlq%cQr>r!zA~KjgD{f!dS?$|Vsf}P6+F@JSiFWB&-^aM@jtrF(-u|l~bBTwXZh5Qn zc~lvcIaO$nZF4Uo{2qN7eZ{s}jR^l-SA+Ih*?v?jBdG6K>d*lzJBaG74-M!QD{DlA ze~;OO2)~B!p(D2BQPeE$?0+|XXhFxU>^N$b+Vu@`8#-ZSCsDhMlJ1obbV_EOeui=y zb;>rs@-o}(LT6-2{p9B?>K5r;dJpQieKdeB*?hT-24yR2-&Gv zu*_ZkJbVO=+Wxza#%!D8Xu{euiCo)?6UEUO#TEUtXQ?k7MMQ-jbH3SwrlEVIs9}w0 zI*PQiC=@Ma5C5667!(`DS?c0%Q5;H-;%VvMT_^)(%92y1EDNoXC7=I)Xf4W?vYEd} zIVe}!64j0JP`;FXLTWFF3bhlDJJ(PLnft7$;UZPF20#!CF<;6{`$+tj&RfC{Z;Md+ip&5t3%udf&p{+N}ZQqfcAsi6$*2x@nBqH?Rf z0##aB71|>sC`Ylg7u8z->QKFG)0^e!kkx(|HCpXWGeToO40<$9HbFe*yvi{)N#-^O zWljrfx7s_qD-s=gefj%nas4IEobMaT|s1n;Gn* zIWt4O(KG2^BUh3>GMDhr8Zju=){8@nrOc6$Sc2kZy>)WDC!l0mQm-8;DAj6DLn}nO zH&>!`*^2JX43sI-{$-&xw%%HlJ(E$=GeZu_wU*_fd|5IiZ7D#7BCWj$728%yP^r~d zhRS7|r{o$}fp*Pg|LGZQH>#W&N_KuFbFd11S+=5QqOYLpnV}WV^8=LCpjs)@@vK9Q zvK75fH=!fe>Z9lf)|MZlW-Dt!tyV)DYL~6(5#BK~G<+)9<_MYFDNE`IcA;)5(<^h& z%uxCN3u>q!b9=3ZKGZK|IxYj~(oBxj>wl*Ym(if~=yAD*520b%=1X#w}ahqSEoX`aq@qR&{_i>TbnD$p)#=WbLfEz>h*)vQoC;T^XXWcgp3!a@}AVoAa~;=;*J5Z?YfN#_18D09xt3XOFKW#eQn;UB3zsMlKE zH!D<1c<1XfvT`EDDPfjZkW~|D&Q#`74OuPWEm=oauadi`2C{Z5>mchVynC<#vN0BLrIyK(Rb;(Z)<@Q_WpYnDKsKn7C5Ony2yZLnWUiL! z_2~bo;X+Vf1zGD~gMHLS)=qeLza3=VD!Je7A?sD?-8Na|tHF9vWSN9FE?H#xR#re( zsAbZ!BC=vDDn7Qm8FrT6OTLF{=(foStj8vnMIbZWl}>9Sux>l zvxKbN$|}ez32(hBvTDLxvWBdd@LEG9o=`LOe@Pm*;1x6G6$`bmgyW^k8-7D za?Nq_P`<3E_xc5>@V?;MUxbRSoh7K$wpoTMWj)=SRj69ZwAD4JHW&$~4jqss^+-L4 z>Saki{u z&>36rEb5kR>JiX`dSyvHhxDNfQl`hlMbvL)18C67hS0For}ro$$hE#Zb2x*|VdV9m zJsd^O33?la=FAC3A{r5XZ(~rbEUBX#hZf6{x;K}gc#-b+1e7RC>YPYI$#a6Meaf6r z7~%CjoXjQs{qQJC4a%G}v_hnJNGnmg)S!LOK$*5)7Fr`qCS79otVP+rg5AEhqTZT0oc3HAR`YLMe$fXwZcmg$`DL;bRz&Yl4@ zDAJJ_LRVx-xiULf(Xh311dWPx|6NC8vZU_YapX#w&K@V4`4Sy0nSz#ybUc@%)aamw zG_)c*=)+2s9!=l%O-2SuDddqC*kc(VQbM@^)()Su^2XEn3K2D|2$t zd!B2;YsD?z2v!O>BMc1X+g zDzg)nTmLFhrSw711y!h8mel*!8dNJ=Im^A2Q-=;neforX5Y=1l4XDxD*@TV+BjFrH z%|ZX17Sw9(Y(pokEhkZXbTF?wP^T=ZGr0?$u_e!$Q-bva-`;V?>&Bk@d#O+}>b4UWhPTtq%ie&_-ei4U4>I zW;7;C>O31quC3?9FneMs)47|4vSV0J&u%$rT?~6gkN)*2SC-VR$LRnAm z6^l@@EUELeBqmh6Jy^1Y%zYsyn8}}v38fQ{IUnQ+GlMLX@Md`ySs~$#U=dlll~s`S zmIh^gWc`G<-T>L4l?{;%TiFQNn3au_xx{16T;AO~WvEd6jJv-sdafWRWU)>WL!DC%hU8$cnX0YA7KqRmqZNWaTRT#DQ#=N|xMB z<`Vv=3jNeTc;|vBvO$$>Wr)nRGG_n{#sv3LL+DB{F3wdnY~wP5#$*KboHdRnq&~er zn?$bdO=oV%edF92+E(JwVk=vM;-yTlBnc>SZZIR0P_mTieO3xemG$&~Fb$=P^qP}_N^P5Es9faD z^0}eNO4*7$cSMnO6J8&B$XqLPs!+A`LGL_j&_1hSKdKe!nV}BVi@ZBdbjX%Gj2fjr zJ)<q-*nv7_o8CDYotYb)ug{`xTd!wsD2(t%Fr3WomGyKV z_00`M|0P&5hAfuwo-N|Yl2vl8N+HW9{CQ1QZeUZ8LkIWO3oQ7BrZcVsarb{?(PnH7f?%X&KUOHlkg+WD-!B~C!evYtLoq@ZO| zCP#;}9HrWNX=uf~U>~hS>9VB0tIR;FtZX&Plx=#S%FYXgWl3A)v$JrrOu}nt7Fo8H z<&YI>nXFetR%vBbWbIbgA$mI4N(b2l;ngrn=4zSNz6QN3Skn2Qd7&}FYuPxNOZcBj zWzP#m5?;%q$f8xUl^8OY@R!U%xw52QtMX93NY4cYsBm6z_AWxjGJ<+$FJUAI@9ktb zS??$2afHho>prqU!rMnfWMSKENwQeND~ltGC%p9%$daurg)Egwac*)oOp~(h!FnlV z>4aB%23g|^L0J=7d1+8qK~_n4^;MBo6JC8aWVKdSM^;aG+iW0nt;{Jy4VO?a1=Q| z=wB3ypU;wdFPwl9=d%_0)Y3^p$+D!LiBjf=3Lg{6d9a8qEOkD!PR@1VWW9t})<@=A znUjXnrA$XD17!wFI$0=NTBiFU2d$Gbz4KU)a-|0Otj@_p8>D6WsnfWrHAL=K(<6(fzCH(rnh^m7!rv}wZnYOxaeyFoP*h&{!H{qRk zd&u0zpv-BSA1eR8l*v`Jg3N8Ut+dc8!n>-)k-3E5_aC7j2W8GN)M{mI=%h&J=P#&% z@ZP~!kh$$v)`2=jdZz6{XJniD-t8>vws!WQUK_za)Nf@2Xwb@r(6IGk1dU1U`l-n{ za;?l+z&u+J^e7yaFJJ`qI$eQwE#TZEpJh3_QKgktp*;)Ow|ZvZi>gK5^AW0*_4KpW zI@B)f>5S|^-Bw=@au)>q-MOE6eSffT6Hwy){vLCZP_mTioJc{bBE7z+-5&}gyyGaG zERyh!+bFVFEtBV*II?;xYanYU{Nt8v%*w{eT*ABhI!~bVV4F?`dYi1LpAEhpW!@iL z?Xyt!{lR$Vpj?s8f;^Njt=7>kK*hFX2`ZreQ(>Dv>Vtx|dR>H}~R8lrh<`Q08oCApPuNDVc^5vjxoGh$9C<`YmCcL>@ zLRPM2GRrH-T*BXG18S5#t@mzC=!ncLeOqx9HH-9KqXo6fDCzmL4Yk{tb)Zfw>q2K_ zoAQ+GoJHMMUk^Gb?Uc9f&Uw@;%3H&deW>5sGJpnUn|eG9p<(HfzEK)MqtY_H{#{37 zvZNmG<7h&*saL#7^tvpm*P}bgl_m9T<1FOtwlL^X_`=ZL(-%^MJOfQ3b0ZfrN_sC9 zg=Q@bMq)OKUP%A+{a_55FH7qE?gA9MklJ--#i4jvPscL>C5yBzDJWIe(<@#YN?#b9 z6*JH(X_=nSR-;U*Pxnz4T4QBvQMQ!nGgl7EwXNi#4c4-aDBoIEfHui`dM)0J3T2ym zoi0MfA|0g?R4QB1`-w7CZau0%l~zL)s#tyW(fYPa$1K%G`!7wQ)26J`(U73p~Pp$oR;Mbt0S-VPwbpYwlP z7z!i2{TEK=4qDj|8n%{=ps}FU&N#XyW%{&x8@bj_Cze?k8?+@HMZ^a6O+%5fe&3xa z6fI?Xo{vHEV}m1L0g9C+b=Jk9cv(`fBnh#hFv2SfCv%f!J$>t!f>Nb+-79G*Jy_Do zK$%vSh1OU**P`rLn`bCj%5=Wup?s0<+XA#nq{r=MR2UoFB^048(oQ`BwxVLGU1wGa z+9oyVEZ>ewt@bihF4BEefhuh)Rj69FqQ_AUs+A?RWp$|Dw$gwaZIqf&v-Q0N9kV5m zqgH8)-e2blYbuv(XT=46l0#qKyx2pB`aTTaKF8J+04XTX`zS&=g>ZMG-;oX3m z*;T=dQiVefBQIq2IGQX z&kUhq+sX(Uv#pFH*LvhEVkR#Nwi$=g7tv1rWm^WyUlfc)0V9!Faxm>K6sSf@whQi|Ek{+obR4SBv;I z{d37&tNs_AlKOsmm9o=_nCm1jqU_g*h;s_0hTou0SyF%R)P;U4ihqhFe}~R2V&wJj z#+*gOT<6bml>HtN{%=42fQUKHu#|Np!vC#h4|-L~KAA^ZKOz=7D?Wq<(6IGr1i7}J z^8k7v*ow25BW`h^GW4m%)UJE_)9ABS_BllOE!%|%zb(7b0bB1NddbROM)g+KfWB*G zub@UNYeGk?>?pcpWiBH8`kW5VY=X+Q$17#;Lm!8i= zIVfMsKD8bdpyCA1n5o&Q1by7v@(J{Um3%?p2NC{nf%l?n ztFHzT{=WSxBK)m<4b|F`b*O%6aMox*hpg-{Y8CzZ82hgc5q^C?Lxg{3|2aBgqjVDe z(#qNq;n&`QTv_t{a`Zb7^S#lB`3o~CvLB|Pu!q?z?-GTh=?}A{{`J^ML`-+i$XP!M zk|`a36C(n;hm!6gP`eHSA9bI%*l-HEKH=a4Cg@W-(kt+D9yI{1bQ-2uG3P(3iKAM;jM^BcHZ+&%F+?x zpI1nCKZV|Bea}Tiyfd(Yt>mG6Te1KZ%b3kvOIZmjPh^I?>lsvm z+C}=;hC5KFjae5um&g$pEyw$LG;C!f$d%gly6Ge_Ka*JU=>nFFLW@OTeiki3@kzA$ zN1_CjofLY|d9R!YbCN>k?~^4TV!aA7{@DoH@|O2fmX9_CWzJ^wysh^kM0o2tg{V0x zxIVO?)+Ab{M@}0e7CB#)e+T?$h*;#@Rg8X4?Syw0t{~&jvj^*)K&OH-=QKJaWqORA zMcvkx9^_h?6Jm5j!IBlo73ulYd4%KWkzfvnKN2dR{Rn52z1$a6kh$|8VGh6cC7ooH9MtEepp@V!b{^##@Mxd`WXXj0Z?&h8rE1yRm@jE$D~P$y)7;^%B+Dnfqqu;q+{!A*DhaRlDza+Tv(#Qg zR!ey6)sfX}nOw&j$eM{oPS1CyIxS?wgxBg3GMDhrb$8J}!tWnhB;mCziY!*k=G{k+ z$g&A-EAh`G)uxSLr|maApb_dK$ED=Q!?B)l4m$chQC)g@%5#9U|EfB4LstelwR z$W|)IY6)+>Id5Me zxsEK^K-R2fuTozNS-VR5-a*z$cvsjivTnlLW)Imp!rMpZ$p)=#h-{ef_cYlUvA{V> z|HjE&!duU|dwD3G@V1#jmPvS{n?;suWqD-zS|+o#fUJ=4_a<2};ccaateo)rS3y=y z#5?=AlGKpZ65bO-9oYfGtM4FLJ;5)#XEQEj%~~e)wUD)|WXw9qx(RPy_mK4x-YE5v zT_C*uevzzSB~K^=WP>VsmKh=&BfLJ0levU9lbtCX4}>>E!pUL@Z)U}jrK@DVWRPVO z-n~=~**YzgIk=u|1L2L!MzVau>umv9G2!i_60&l_Te5I%ZDj3QCgajU z)=hX@=^^XYGMS%!Wc^y!%fFsHKz2zbGwU+hAmR0Bh-_HPq83JD#EK_HCZm>Z#A*)wOI~&N_iFoJd%&ZQwZo(V09^Q_E!Jv&gblvSbcfF5zu6k1U_? zW@G_bIpN(8R*+Q^-ceOWHcWWyjgUo#1@%Rdr4wFnGsv>FOrBG7$npt)4w4lT-d2jp zN(ry_GO`_3wv()!@b+H?SvBE}QVm(XmP!8_$Qo60K5HUtRmtpWBWowT8al{2RnoF9 zvTnkk$z(w%;p^vPe@LD}UHb{8qmm#uY!fV+GS#@}@WDQv@;niM8cEHLG zlGPJleGO!-gxA|PvUV+#D?$fZC*jrJMRrEZ&bEnZ$IRb<*THB1!RSU z*RmqAV#1qeC1j;mRz_B?WpXB}Agd+(zLV8!nT&1&S)*!zD`gW|GePZ?wUD(E{z#CW zAiVKBN!Cty+w36g)-t)0^pO33p6)*`tMY#Q_^I4<(}GP)3JVMh3K=XdF)UClEG@+vTDH*-oE17NQbKleP!rIWrT{9_+zV3=u6o zmZc0LdQ@eyG?<9CJ&UCrm0a`XvXoCmYc`Ul0+p0k$WjRr&2v0UWkfWWa+W3&(LFtd zrA{KcW*18lB3iTHJBWzRw83}L1d+C(6cN#S1``qCdTNXbrld?svO$f6xPc?QY)_Uq?M{&0Z)*BqKS9MZNF#weExZ+M01FL2}V=KTJe9 zk|xJPkc$Y{+F^)rV}3XyTv{F?T#H5^!nO7RRAA*5qB1Q{#(X&nSuSDJU?nU@%_6-% zTZ7i?cJeKa8&Hc#pN?!n?^y}&Bf{PPZAFCZiEU`R<|3u zh~z92{1Oqa&stGLB(q`=+{50yhf$zU=&nLBB7LqAi-_dluk!5a8;Eev2fvAkTY?*# zs9A4BxOYT-P>!vciwY!{A)Hr&LKLz*!zgkOZI`>jAQ;TP9n6|PkT)ucC^FdZu^@{) zv(Q%>sY@;@$nv!=L}giQr*DkPQAHN*({G-uL^ZOeJ{_q=A=@sDUdp0$eY(5?5w0gz zBEmh3eHjt%S?nu_aL)i&A;RUd8WHZ>zF$Q||KPc+Y2RyzaKAL(j0pD)PiqhnA1u9# z_PvgX{y|p~+r5F-TL~Kw;kJ7l5w3mjAi_N{+lV$vpXs}$&4_Tv=DUcvJxITW@>&q# z+OP$^XFd2n+A6)P`)?Z}+;^t_6cMh3pCRJTphKS9Y)3n-p1V-1lpyzGK^r36bM{{$ z!nNo_M7aI88xih367I>&@t35X?i?#pop|j--`xAnE8g#c71MoHM(X9`%k|Uy&L+<5Y~MCT}t>W z>ORDei5}={L;P{{bwtDm^WS03o``UL-3whigu3WAb6Y2 z{hO$_r0KVk_d(YWVYKRRaNU6VN}B%SSRA@>h;QvpDBjBJhi;bgbZ)%`CCHlkhBy)R zAHvwx-z2{kCCPRRH&J>rx=re$zd&?5N|DxT@1`Qc%^G(g!nN;CM5F|N$sm_BM7Xp8 zC|yd|XTRS<8AJRC$wc2C!uK6MCT}Y4LW5+xPo=N#M#K$4yqrnyL4$|*QI~}XzV}?_ zkRgb0Yu<~pt@NRYaK9~gA0phE_oE!CnSLW*E*d7I;3u7wJ{%Eu26b0Z9wOX!BhUk~ zrXGzCqI_w)zEvNI9+EZn`*0sdqogkSrhYVf#BzBQ6%64G&cWv>p%9H3;=k{3EGm+* zq`yB_jK)blb?$i#m00`6qwh$X&Whhf6D+M1O_W^pd#ooR!p%fwi0B{ODf_(~J#MZ2 z9-1tDroS6F1$|$7Ugy^TK~rTr9ZS>D52Qu)%r7lFcIr&pWxHbQc zE?C-s&_!v3{u;|A6tU8SdpVBo^>w)p#oS9S4f5^xu_)nQo_So6HwTG`NC|d5OIm-F zbT9SPGf6VKO}5jy;C7T|YYspewq_>!_Px|a-)Y>12-gz>5#iDXA;PuqZbY~v=N?43 z?FJ*lt(k>}h;;wmiwKuzHX3T%-G>Ob-TjDgV=xC1u7q4POxDzKJsjoT>yO(JXr!e* zghpGQk08Pww~wL%TeA>VNSf}IN;KbkX#on!nmQ)JsP$eyx3-~(q-kFV*<7P!vmf;O z^D0ER_x4vK!d(SjgNR##A~}n8Lqzo1Vel11xHZ3u2zQ;;9T7JKV<$2SdZ6oU%@{M7^_pPxL{AYv1+g#%$l$HzC69>3BrAHT$7kESCgCxbhNFhOAk^ zy>F0-a-?+amt2%DX?i6$5*5gH`kt#0mC2g=S%-2|A#3VUT!{!*mzjugGy5z=xFfC# z&9gP@&;naCgb2SkQLB`$Z;0En(<}D+wpX%LL);lmqpxdOsw1N3m3o%e6VVa5fu+5c zwvVOhzx8P|SgIhR_Z^iig)A-1QUejqb1_R9`~7yAEDa>0r4M3hu%%_OltUy3&+y5d zT$YL~t(c_}BAVxTmO_>mW~o8bYwh~!`w zJ(0@NQA<0<(s50bYwr^*wG+{IXB{kECZfG`g{25_XYhiI-QQ=YClJxJM3$0>=r?mG zv(#*9Ygjt0X;QNzEFC4HB^+a^UDKo|I#@bQMDskuQYR5zvx}t&5$&_!V0QX7M6|Wt zSV|zGeU`{llBFfHRPnLTvy!D6BD!WROLasvmwJ|t64BC+v2>h>w)O-|r!DOaOPz$P z8B6Dh=v%}KEL~Jdi!QO0ci87Sf~9;STKY(qS}bh~OM8iE`}VPP(9#aEbeM>4cZ8+m zDj5YQSjsu#OUPv@pNQ6FBuiD6R?Sik;o8npYp36?jitRrv_<<^+D}Bs?g5tCyL?&) zOQ#99k61cOM30VhEQLPxX$E} zgr$>~c8aCbmUf1v%a(S9rE4xop0ZsxmMVy7T`F0s(lps))hyKz(YDvJw2+9dxrn6( zBHH%FEG@IN^hrTq*M?sHb#(XVXH4-w(&xf|`Vn*AF6#nRdl;XaAifi7AJ zmk{CZ!#_iWYtiQ@VrvHXan`<%Ht3Z{?0xA4MD)p5AxmW{`8-NFOCgm$?PaM|C1a_msLSNc;5k$Dw2KO`0?x!d8 zsY)WsxZl@06A`Xv-$n!PxBZ6($#!~ob~hs2CkF39_ulVERyMj%()23*enhx6a}eRi zU@j`VpZ%wwH!VjYD&#k-<5aH5xBElViyAa{V{4WsUj*edrbx&8KsvO3d{u*vIs*&yVi9{_5<@lp9 zj1F4qhtN@xd}bjyhT3!3?>Z}XpwqVI8Pq9hdXLbBB9<29avzsVPuwhT9J-elp@mcl8Ww>)?WJ-X=#YKB}jdi z?FOKXTs{H!@yDcPqJg>WTYWAy2o0t;qQ7dCg|a2jN3WtqL(%=VW)31a`$}tbQJ!r# z0_Dqg`n+x=dPw?OKaKq`8f9yaMg@}RR`x?sh>CIj;BiE_bN~0yR7;zNo{&5f)xMybm&S#uL=v0SzwA|Z(XCTZ`Xtuo60&L|JIq3t$Wcc7iN zA9kTuS@VFb*@kx8nBRl;`XeXUhX~h$AEEtHGaVBL&_P@C5IStR96?8|gk$Kqq&+C( z^8_N&fXrj-H*a<+C(D_`R1V14q zTtF9XPhUcprJgT8N!k?@k@50I6L|*1m|uo5OMOj_v2N%q!+iU`inN|`*{WrPRh(igYb{S`hC~26VX_HaPF!rrJnN3A$ zQoZE-`QI6F!7v&9O z*3jR7AAuf_TxKn2y9W{Bp7!P=!u>|hNHl5~Gs?sXtT`GL4D)A%LR2)2|0{S^YF&&< zWIO%Mq48*fNPiWl6qQ+v%F$$7a|)U&weA{23DZ!8ZdZgVQI(XZzrPyro@07o~K)s=S0QOWFt06IT%7%KHKlt}b7qi0p@bvSu)xF*uwx_196lq3*+3 zQ-6)62kJST7U?gJ^+GYj8Fl*WD6y!wY^T2q)d%&J?VgoBi$n3Uo&K6*Ka?P8`kOiBqm<$Ng48|on@XwZ4p~!wP5e%jHk@86c$>5Vh=>bz$=kAYMBEr;HKT7K z!i}Yj;px{9HwAy8ecf1!5bn2UG7;f^9qilaE-C$S`CaaTh;Y{-gV5c>{b;=h5pKT^ zMuhtfnJhF!TC2bCc`wSgT!x~Y;r=+vMR~HF{&v?0^nh*mAj+4v>)0KMMp=2I(Ib+k zzZvx?Dv&h&WxGODG@K(xe=n;Tl~`YoM-ybt^>U1rqB5&lIhrhW(ch1of~Hz7)6jI0 z{_fliR59Eiah0gb_Fpw3+%KEYMuhv_t2u~p`@IGc?)thG)yW>y-z}?0At~XKjL0xr zIGp)f-}Ej*Ps@JLw;#`-2C13;V%%c1#Co?8EtOpK4Pz4`T)#Yzmf2`sj#fwsdL6kE zt+IYujn-S98_-6P{^t57)FLg?qhkx&YTIo?+a*nZk7x(lX=8U6YL%Ml9MXn%%U;p< zlzY%#shR$w=svVx^3-3CJb(^btq&n0E-03Ft^bP%cW?1AI&8H*f{w~|I?j%vf9T(6=$wl8fUqYWr+x2%zKSxAT z&|J*ScNr0G4!MFNas-T$_jo}bGf|%3E8S4{JobCH$4KjedggJY*2~=53lZ*#%e5#b zkDk!qq>Dx0$n)dun}~3;RBuGMIiwHjD{1;mP;n?;w$tAc>xUAggy@`wlJb15lM!)8 z@UHw0_-%-A{c<}>v9wf_mgmRd0F;sE$7d!QDAIT3gAn0H`Q3F3xGT=Vh;Xw_ z7Rt7o4Mq3m`Qz_?lq3DA&z^Eoo|LC|QX^2l_4P9dlkAPxSBDv^qP>x3vWFP6-z7&<|n)18c<%n=sd5(F{>!!S8FZa^Dl&D`H` z#BD+?vZn68EoiHa)@^9JjHQ&9*=`5gDY@vZu?w|I>H4XUHncmB>#T0_zJCweYdyFR z?YA}@K);vr^vF4g2=}z&5IQX5THom(L4TAnp`YD4iU_wKj-fx<`1~_!w?6AYCne8g za;6~CC^w?^&q+E zwPH1@u{CQ^-Glu8^i;{E9)%vH&%U>cnuXCq+inqh=0Qf}Tv@XLEwQ>ZqPJ|#^@wnv z_St~mk-D54Po5hQabwUZxoko$w&oV}zSQMSN!yBAEzdTzThg{m+8(su+IIjQwCxU| z!;M3b@&E5;e4}X%LtTjxr{_5R^E6t!O}|6M9XConrv;Df_B@Q zd(c5E{SZ28X~)oU>7{Zh{RBE`X{XTXeD-Foq@6)$tuE(Kr>)tA&PyBgQ*0N|MN7Mc zF6aBB;|hvce+DBtsz&;K6h@Jee$EOWBJCl677e4N4^bC=g4~1%_pI`H)GTT8UM5(B z)<5Lu`3>l8S@RLz{{`=$&6f5qYLOE3b2?kl)`$H0eH;3zta)1QP=1C8ckbAZc1fQ4 zNts_D!lnHZ5pIrbMT9%UwxP&F905O(&lUy`^B(VE+OAJuuR>pW*yr+9M7U4zbw`9- zvj-wvd0#_>>xr+Up0eHja>w2a5$=lRT12={m|lkn_Y5!w5iTtj5w3)9pqn1%$kFqA zJR)3LKSa1(ZbpQw%PlBD(xOkgQR2gXZ}vxodq#aLBHVUKh;UbA$%wck7$WoTZHRDB zAa6&6YhMZ?+|^ntB3z%{fe3faa3>;MYtsNx-rwC_dAOGkvev-uVxTv`SqZVnRe z<><&ngc}pzMnu2hwJvhG3lT1DAR=7aAVj#j+>Hpg<~94@U_`jL z16hc0tsR00cinR@BHUd;HX_{q8;S_GAMQhhyKcQ75iZXhM7Zs85#g>bh9RPV@O~lV zdN_JOa=Avn-{wI?+!Czef6Ib=RAAc`qB4=baVSS2*=~@m8GblDL?j38D9lnT5q-DR z#!}=d+3uIJ=8ssDh?W-?E%a-KS!yNv2mQkII-n(6Bx25etzkZmz ziLxJ%_8YX<()LjT5$&ZgOA*4=>?6_$*Apy7_FLKkbkK4+g#OppGx!*_TUrM?`LO@? zgj49WT1dK5?av*$QE!YDGz zkF#JjGtp>&^v9xv(UhQ9@QEmGv_Ap{pp4NRn|fcBiP}eV9O?6k4s_bKJA*n!`o6IX zT^#Ml{3R5TG<`Q8Ji<)-2(8s8AU#mRBRZ;9`QXnuWh#v9ke_Tp`(xZ-aUreEv*BcwthK- zI;{u0P{c|P9%W{D)b~p{3O!1{JTKQFVYKv7`b9tW*n|i-FFlW5lQey1)r<(YH`gG- z9V@RRB09eWZ#CNxSt4j=y#2%}4zV_7+-ixokj0N-$UEiMPpRqfZO7 z6d_y*n-JmJu=&yS1mc#Uf$v92WGOa93-!C}eAfQEP$Uw{56h*3{#+ z1D&<<&Y@1rvkRTKnq5HuvF-kgE?S6F)6jI=ZU(9tLoY3n|AweURg$L9SgO(NF}{6sP>o2RQq`h5S@RE4 zUOfs~o?%3|n5~J^bNVw zUyfFc@pINnv`TW(=M1aSt7Djj^*Q@%s9CntzFva}cYXFcBHUB(HxTiSphf;eZ7m{P zp6k$hNz-TW8_-58eG_W2y|M*u9m9RxBzcm*4Q-dS|CFE|Xs4v<^Q>K{b&Q|&+t6Oi zWgpsa{c->ulv?YV>kv9>YaT<#McSVyP`l;XfliL$dRN~HokFL_q^Ab|(7Q8q*7of= z)M?+mC5zy7lJ_ zR3W+OD6d3SvS#!dE~>FyYEhl_OFar%J;R7__bX4Kg_5Rk(iWix>A~O1|LrbDOC+u5 zbkvBJT8o;{GD*|-o6FIPvCM;Y5k~S#L|h+S*TkIlG9vl}H@!qIub@>@YdykOqh{Go z=g&20t+jm}ded6;79!jg#Ck-y{k{Qhw0*k?wODJnpslv2x1sHl7JVm$c1l{7yieVQ zTBU@97f>78Ep^dZY7hF&SU-d9MTEPX--n34!De|X{Sn$PX}VVqpx;S7^}6-<=%5`{ zhtOdeXFA&)K}W~(*^!O%Wc?WWldP%p^`8;p`s_F&ZVGzH7(9XgBDs9ZH&_Mjh;Zk) z4s_DCJB3b5o*A>)?hHCBJ)tA{9O{%KPX9x#3w>%M+h;Z{#DJqi^^r$LFlSS?F>5VC9s+B$sRahG;QI$wXK{cwe@@i3?v`CNJdK8je zbj}JR!W{ump@p{HBGh1cE=EgiyGBH~qxe}wxMS=&wA6BGLdz_d<%n=|){AI`t+^7d zvNczuW|8irHK^6bOB>oPX*!bkpuN)9L*$9lKD6J`4xod!-63??(vF~`Hm;AM<3${e z&-Y{`pFr)hrjC~mbh5}F#i!6|Nz=LK3_2@mdWJoR{w4cX&pDrt z*+#(?^o6bYB_dod5k$D_!JwF@n#KP3y9yC*ez_W5Q|zyPyP@vI>?1vk_CP(0eV_G0 zF~#gL9cQtqw@AlCA9O>pKl=M3!tJp*6fe2xUg?K!v9tt4xT}&xlvK>Dp`U|DMk$i^ zguHo8MQK)-0Vt!`9}k&mU@ZLRO<@X@g!ttU+swxrf!y zL99dTWl!t5e*@YmE!rcWXV`>Via8tpNsg*5=shdpeMGo>wXKM7V}2XjZhf``?UeHL zGf}$`(Knd8krIA^2sd_riCQhK4Yf;8=qIZ>&`B%r6gq9A{0urPx#%_KIYhWV`xheI zmB1&c)Am>wB5nwV$ocP6bY5z$XNwEyqHL#EfS1r^>%l9CaO33*6p=M`uLR>bipOyT z=##c?sQWmdOApj@9NX#DS}znMX}Vo3>OIc)d>_fR{9ZiRLav&c^yN?t&)y+#QhW0XJUM`SnD{V~#Xe(8aFK1OT*`c3+} z7b30?h85E<*P@um$Wza1v8eB3{%jbB;ve&4updg0HFZ`@M7K%_x>u4A;ht+Gqm;+! zdHueNRFo!b>hUlDWk_AF$QaB-10VBq-5@mhF?wFln^`DZ>he$d1np2%@R;wHLR9n^ zBTGLwT8v6$JH59Uk0w|>OHrAWt|Pe|O|`mAL(?@+xzd?|DkM)Gt(B-s^3?0(YBXD< zGs7H2xP4oL2v_S`M7V4Hxu{Oc(<7%Ig&yQ(b%v_#sjSKEzf zsr0U11vQ}+mbMZRZf<=U5w2#hpjFlrt5LJ{;2K1@JYPqI8+C6W!d;`RMT9G19a?Wa zu>lb-?QKN3?cPB|eDI)tS_Ex;%%7b$p%!Va-mz~%ts>n=ZD_ZZum|mxG#$zN5aCMr z2oY}V{ub@Gx*R|UWjh^phtOe>?uR4js7S}|F?3v{J%0kVTkm$DlTw0y68#i9EiKYn z;|%(zmG}S9S=sOUX|!{wQ}WbLU3DSC?afco`N#Y{^96L#+HeV7mhH4BuAqoW&j>*Y zM|g>xkH#@tyP@tSq(zT#)U(82LG(f~CH|<2MZHCO<=6)it_QD2eM|fi9*5#fsApJ8 z=!X)d1g%RVN)l~{{YIDJhgoz z(WnyMPv^^1ztN~bO3-s^Au5s*PRR9cF)Fd`#-juoj9N?lany!(OI>uf*@O04Yxkl3QWt%qbO0Tc(wk%kJA^)# z5%OQD%V9*s2iHE2jv&GvseeF(yC(W0BHVktqlj=b*fI2H*_*#^q=e&$h!38Y@0&S+ z+HEX#pufsK($4|@4V{#p_`_QE<|%Yq#+lAwXV6*E0(t*_4*kn|_Y>4~wN7rkQ-zJtDF`Y!seX#$#HD&>ePr9AzVP&d@Q zlxy!)>CYahXQ>}cz0h?k*$**@aCf`0sCOx6!+hDU59(X$kCiwSU&@}=CocWa&9dEo zIWyma5=44*BqG9nTBJWB+?#`2QBtYjW69_?*-q!W+Y#ZOhom6Fox@WR;l{)rh;ZB8 zi3s=BD-C5>37Kf1)oc(NEYg`a3uQ|^^;|p@<&-jd^mD4YD6iDdHX~5Jtf`;<8Hq+o z%^Kus=x9`6H7i6#(%R8-r&x?iY`gJjLMdbSap~PsR3>%NS+N{Vw(X{%sio{&eV#Q9 zO}7@!Ko!=8N>pX-t44LQUG$hnA=~d^v{3e~Ug<1C4W%4^Bcu(B(Gtt05iPatnh@dM zF+7i!$(p*ymZKFi>U73jiB?$~RwKgA-mfCU9mTJqX6ao$#@3+qR^A4*(MH`SM7X14 zGa}qI?YoF@X)TDjDLD2jE!u*%N_l$z+lIDVpY1?9r35`w?n14ykMv%x4Si^J*^LM{ z2KS)7vZl`S`_O*N^8h+1Yko`m^AI{LYwG>V5p-0fbL25}Tx!-Wj!|#|wOfC7pp&xy z^yoi@h;Ia6k^AYtBf^cre;~sBZ|7-5xczVjot1GGeJ+JMOZ^$43q`Ef!9-?_iT+&N z4RxRBkFg#oW}@%iSk!x>pF{c}A|v>bjO6Q4--*;k&%SXeexk2uKa?PO4!NE-B%-8= zeoQ2zl!=T%ed?Zy(nR_se*ns`nq{JaR>B}O*wV64_C$X)4n;W={c)R%@4)R|!sYOwY#MoVNnJpvlhQmLn24>qA?BAqdpqZQIx9a$^UDoN9;i`A%k zqWst525Pnjt(7#r23v>LPn4%(L*?!oZIm>f`8J^z%X16bDrveOwxR8^kN&cWJa?d9 zT3Rb2+_Stkv|Dn~JCr?W??ivj*@r%oG<_5ETSU0)g8hhaGwlI%P|ABx#`Pg|Sfu|+ za0DHdHTCV|G4v?l2OJaf81uGfl{81kU?m$)LNe|XQ6CM8;Wu!`B9gP@+R?Z zgdfP~Mn|ChNq$}$iAG5YdObKA6-b^sdl#Z2>z86wVkL}66GYJ?9F@tM(dWWwvbApt znkpse`C%HGK8bUczW6!Q2zM>@V??-jia$YwyFOcj2se@&(b7pA#f@?e-Gr7&2|BYcM=K`rG^WoB z)NCap++6oEBHWmN1+B6YR-Y9fJt>6g3w0F7s#DKB%v)8HeJ_7!!BM{_BSl%KXuoh>~PY{XfiPM5G4~$eOpI z+hxsJq7+2j8N4h?MQLUJ3_Ad2NG^IWnu!KV8+7yxLW9fvD9=LKQfr-)hoT&7Z7#~Q zHjF4s&mp4E5^`Az5z#!uEJZX;uNfXd`93WeiAIU^>@*q`NDu1WOCc(fJoUfYi&2TJ zsmIZHG@;DT^QEXvw$o>8wXQEkF zUKJwTdAb_aSTEJ0I_V{y73)z*>Y_7y7!mHi>M69)wp)Z6WKF$aS&WugUpJzqmP->_ zCUuD(;b?`8iIr%TwP-aWTzRjeX6Yrpo>+s{O6hvtvktA75_BHifHsyfzv%y6Z$d31 zz53mPw%T^v(01#I9f)wx#D9)<%GlMX=etm=mC%NMC3)&y$cJdRtf|*hd(d8KyB?|g z&_~wV-=h82F9*;;>%l|lu(kaNI%*{xL&vSwCs4cfLYs>k?fZX-aA$p)JxLzdxoy9OmB^;NN-E)gZhf{taTAPdVDw)0Yn}tT8{7V0wG9%F_X}f+a&uCO&JyD3p z$abGdE@M$qB}ZKJ8;MY{NZ;{|LnTtWUg?ZSQ)N4S4m}OkNMGwW{nVm5saa_iXXbho zs^pxb-&qw#3#C8r>p|Kg)F2AWH^nSQODZ|yzPgOMG@_-_yZ7~`2b<8cNmb%# zj$RaX8;D*)E2L&RCRU>LR+kNEqt$a0YOyh~1#PWl_U8HeIUpNsmT z1knq#sC6PD-1CC|h;SqMRz$ebnuG}VTq7B!SY1+4+Dxug@BV-u9Dp)r`f;6!23jtI z(BPTuqdl{!OBTwOTI<#QP;{Scr{CUnKgyZOSehtfDHjp$yNiY)!p%~{XQn3*Hw3TW z-6Ke3DMCcQr!B~vnchm=5cFg8w6PQ+;)9!)Q~C&$FSX7RjYOlQW+$rAXhb9jzm}Qk z5k$BV@+c~>v_dpyrXMe3QIWN_7?p^0ei@G@*mk9;Y^J|ED@T*9MN`mJTXPzELQ2NNnOxiGx6*}Yrk4@XQCH4V zD-m6@jisEYeEV`)3K7xsLztxq;r8Z_&_cgmun0BCnmU^y`5P z@-1kq)p{G+E_-wMTyoigcG_s&g$Ot5et}x84Q=RGQtNst{X?`{dg%qx9z?h~s9n@0J<)+qT2Gupr)T=}!x?l|O3?4%JBK>0 zue;EvQo`?~gny&+l4qU>5VzEVlbt>e3B$ zpXK&@&;!NH@?$9$^`1pf?3XKuKB(_3MtPT%9*5#>&3-6h7Ny@L&nFU5|5=>X{wQa) zTT#+1zsHhirH5{x<&MTMOOcdW{%A}^8J1@z8YrdftT6};wp_AM_AI~uhN2wFQ(K#h z@+@rxRT;nQht86w>G`jw-}R_iIK!rE7fsw7R%2-S#iBWpIQvAt4@2=_c>E~=CE>65m4 z6tX($I+v_$s%<4>^v8qrc~Z4+81Yp$0WdO2DlYsxoi1}hQaX5p97 zE3#Mgdo@=f!XLNjwORfw+KdS1T{)+&L2IQ&pY|cob*RN^y#>AR$3(Cd5pIUwhPGSJ z??5}P&vv0!>!midTS|YXp7!lQ`>p2>po7x8dQ=@khizORK}V&v`=ss1&~Y0*Cs4cX zl@4@LTC4wgataY{l>Z%_w(`!PvsUYKh;TFXzfh;G*@eza>H6J$7tnuaao<=b$L2*u zxa;dnv(j5XlU|Z5<2IHepU?7F#+MP{=GH4HA|>Q@(4wGiFXe$Aj8>R!csroVO51NE%(M`|w= zQ{|7NSkzm#(_^d;>RaV!usB4x^X-j@a6NbvBHVRdJR)3LKa?Qt(~+Er2zQM2M@b^R zMoC5~wp}W^!}7cnrCHhllu^Z=KF~;iW+K9U59_xP;m)^rp@CNGLG_*9OD!L^E)OFjF8G#c6e3*OXjCBEg=E$!L`AaS zFE&tKF)FdPk4F^A=RI=7RiY{z6V+(8r0I9b&OwB` zj;uk1>z7(oCq1E80`(|ldn}Beva$3dw9v-jBGh2pEk;YM&l=HE89n+vvrTB3?f2zq zg^Uoro>+-qmOS;2?-jI4dQk5MSEJ@Cj%hvHuR&|AX6w*;*_+>$UfO`(kzUf-bR#0% zOuGpYZiH+`Et2OyZSDr@RJ>@h^#5Ui-7eSbnkeDI!} z8~==mTY_OSyB$ZwEx~;0i4&;3D*dkDu9>IN*K5&r)s(K! z!D0~MdN3CCuBNqjRZ&78)VG==NAIBHP`u>%q13Yv|xnj_ID z$wi+?j7CMaT`?-LT8~E)MEVS=6qVU_XI+p9YIH}4aX31M^Gtge?mlh@UrO7=(wewK<%<0^n1@c&`If+=u>HQ zTE>pTpCNQM09Vqu@teipd0Ewn=v1qvry03^oyPid!g%Q({{Zli9v+BCy7P9 zWzFA6U-v zA}Re>qGD7sn?0>}!sF2dkv6Xh3M7S3H zkUd63kH0WW5yG9dpF|ZtEvQ6Qv)#-WRHK^NjGm+NWW5#BIMzmB~tJeihh;XCld9=(% z&vHg25p8W7OOY3?wJ)I+J}p>@R!Ob(Y`7XV+a6nkUY9j@KE<*523k9t`;xp6T8FmU zn%mI!+5Y^n1MQSHOs%DTyHKm|m!J*p_Wi;`N#?BC+&eYM_}qsM%65O1F@FdhmXWMa zCy$^%$T4<6^86zr+_T@Kh;V7g&~eG-MS0eB0ugRc{~sdUnD`57x1Q)gC#9Zx?mvb8 zA-U)i^3#au7o43*o@dZm+bic#r>)tAE=sNSx%VY>*-E&ABC_2V(!O90Gs7HTUJulB z4trC72cQ=s`UUYTSo2y$xS9PrM7XpVMBEtsUakORQE$t$59&L|pDp50ylgjE^6ZBa z=Fk&GqC}J=X*%kX(QT4vjilX=TfoR#+&DzOsABf|C4cM!p6ZKVz0MHA-m zM(k}-DJqjSwI|BaWGi6`nmUK8^pHG{oQ8fd$IoX^py|?QdJdm~Dy;NMR3&Mj$y<|Z zRA+hCqmZnrZimo>a_B@(0SRLdTd@m7bQ(c@+B0JJ*M|0K@FdNtMU3O>R#hVK@UW@ z_I(Wz?ppZksHaGN`6lRvVrqOFVo~oJKWp?seQW5?cp0s6C|*j?n)O2owp}7hvh9-5 zZL;QL(i69%6p_xDsVGfqt=|f)MSC~QQiO25n~4Tm34_pJYkL;T zmNoV2VkpX~;Yue>?yGW9p6G*rQkM}ZzlM9m|J2f=k!Vy6vyEOajYb8Mr(Qu6qLLba zJd8&ZtUpW9WGj6N`hE@1>-0B4{s&E!dg||?OheOaxUbTu;4@H#l~9SQBu~B4sYW%H zXDzCeTy($JqmZSA(ZU*LOnob~h(3F|#$6qTS&BR(J)zH#8W7>`0~VttHU8LaL`$V~ zeR|M@meu%Y7t0ahp4z@h2`eNQc`q7fsZ}NKOxsv$x3mtHB81y+C2E#_>G3jsy#}q7 zQJ`nMb!dGJN29(~*nl=lUG&J=gj!@2=v~MbwAJ?eHnd&V)T40+BKijpJV^;ZM}(Vq zKR`QW%>k0O3;j~k^zNk<5iXZDwA)J9gZ4`4ugjR>0&jobRO1M;$o)NtG^G#b$sNST1I51 zKCM9AYiW_r`aMw3THj~AP)sfRAy0Zf7WJ;B1ii=WgZj#LdKAZ@c-yWYN~rZSN+L?C z_2;T&MD!1)N}jhN!d>0pj#6s@>^_+%a#4P* zAA=)n(?dl6;B9gVi$+O{ek)=rLPXPo(dZG$MelPSMTC0_SAYojZmCxv5C{*i@u`pUFeXVzci%>(Y zAM=Y5;pVI#qo2r{`kAjKh;TDsBU&nboh$D}o9Kz>Wsk`jD9loXNDjUy?>k;V%Y0g} z9IcRA>u6nxR$JR&MT8sIuOY&nSDF#w?t0dsH)_)d1^2(eK3a>2#NZRzx9iY)SySiF z4QQj~vI!AzH|MajzjTtnW3jjdHoQP7(6Aq84*dr z>!Mo_F(}w0Nl2)N)lp>|;JfDiv=5l46EolQ#hP5FR-8GjzrcZPS zBEpTqL1?hlB`j}`vrx9B4MjO~{b!I+rg(4Rd*3p#KA~7%h<&={cYg zHCgG;qh+=qmZKHYz8U|fKUbo)vZjvYb!ffpKYeep0d17+^zLO7YLOlc&d{G*&{o+_ zdv_Zm-2VG1BHVX;{tOZB6Rq13acA&4Z!Loz=;zkgAE2GGrapDpg<7Srqi-tEL2J<= zM7U>w|BDEB|L`$7EYfGta%2VwUK-dbxMo$y>=HmFC*kmS@QzAEXUC}c^h#BMI`NK*U_KBJdWvk zr2S<+Yj#5kB3&~PCC&5WEE%O)+5nU>kGkmF)lBs5d5o9ZACb#l^V0hcoX6gj&nU&Q z6d`&CS@HyA5XzSAv?qq5vU&8p&feu{vgrN4QL`y%>O79BF3~hpV{6u;x_N#d)uYfn zdQhK3hta}$eqLIHmPqM(ztV`FoyUD#q4djhXz4u0i(b1mp=Htrou!temqcM%a|I&$ z2QQ0OqL-ySy_MHWU2rR`JXN$DcA#MW#?OY8hKN)uWp<>|ATXVHYCY z|6ct95pG2O5)tnI6kE|=OWTL`OI>Elk$M0fwEjGV4$F3WPk96#mA!JSd?M=@BHVue z6C&Jx|1&ynX(v#-bxX4MjOp7riRU zMR`{15h!0;^bh$w-$*pd(ng~KYf&L8k~Q^Hxy7iYo)NOW8#Nn`CdhU=GnAq-ODji@ zOFi`y^WQ_0>)D%nZ!rZ;wS6=VO|R!T()-jIh;V0wA0onCRX&Lb_i2g>M7UY15>;6n zs!@%Ur}tyEsIJ~W)vT{iZx2cD%I9A?Sn4Erj>0*oi=~LA1!1(%a#@5Ltc1mANj;FV`z}W-WDM%F^p$9pjtRLZS&f>d zwff9z4O(lZuS4r?6l_2nZ6t3(EwUf<-Srl<)%NB#wB5?vfp$t=^o+L)?UvT+o8>*| z*V6NHZy)>y?XCB-_dc}0-e0>NKnHD{9YTkt^zY7MyCdjmy`Srjq2p4zo`FuFcG*tf zNp+x;*4k6(tkg`OiJwECi1gZ~6A|v2Y8N_hYhFPAl^*<#^!!Cc+!)LkT|$?oJpJ^> z6-3+;ERg@v`~ndio1!lfkr9l25JeC%Ah=x=%x9jM@8^&{sPBAc=$E8~I212wdc5~T zNw#J(N}2Cll#0^k`+YkAWz6RoTeON2GSNWWZV<|z&(S}jH{)z5x_>?`(q~;ch;YYB zE+X6vJq!(>PhabQlIJ19rHw%O^Zke%iAG5-dMz~?6-Zt5)BA;}Xuj{WVl>s#rlIMU z=L}RK?b9pxN>pWO)rfFc0<#g}j_En{0uh}VQd!C%qQ^=mOM{8%QI*9~zNL+1sep(+ z1utZ&L?xfk8qZRs#^)K-qPf!7I=9Y4gqwxy5aDK&dK9v>Fj_d@U4I6PP=mB#qwML$ zXvus=kA4=X5iPZS+k}?cn#<7&Y3*LwZY5eJEz+~^YSe7?T!YqHpRGfL>(4h4;qLq2 zLhEhK4QQjJ={a>1+9_?&Sz{M!mHnspByDJ~<+2a$pYQLP51@ndnIq3gJrAM(wX~1X zVM{xLj!Mn+uKXA}Zgn|<+NA_Nr*@!|BK< zMDPok-8mjQ5ph@W)-9~rh0a@xE+E4F7t?kpg^0|^&)O!IX==|9S5$@Q$9ue;O;0>s+rNyE6 z1->r*P{IOwDf(F|lw^4(qm%{y+AS5ONiKR;8-Ow-7aiA`Xy5|cuJ=HL(BK91XWM-C zW){j`;E(X3C`ZcEr)jw;Ps-E1Ibs3-;VY7vHkYMHzLcPEuSTL#vYqbR(WpSy)Z@Jn z6qeT~w`p%ST?UWbfF6J$GmLtKg`OJD01#1u5udVU(3E~V>T>kL#OEy|QU zD^b+~e|=VsY9#FzIp@@(Iw@V>n$)9^%VgjFEW(=0(TkS$5?W!~twgI9Fg~w+p7F98HA@fb`ELzcD?Ooa@zAiqZE73(`YdBu$?nvlJoR6Xf^MR-YDZL))z`I}kA-cvO1u=ja1j zbDn4?A~J&4M7z*0ZPc|Q!que>?Owo<^LzOO?H)uV2fvf;evS4@i}Z=hKD1w?BkKS< zDAF_fA#_-3t-k?u1Rb|}o+4hKwB)H{@C@pdqeJh!yU=;t z?gAp*GoAk+!hJ)+e-Yv4kc)_L`{5G0Y&~%W5$-(p1tQ$+_9Y_R6>bC(uFrxH_aq@d zqjW>vL+s5@rO$exo*{qS_Chfsf855R-XY(EeNf+!f9ep2;zPc*{ZK;4kE}$LB+?^1 z8Kqe1sVGfKxJsVE4?sk65ZjE>QHGS)@;T!=6AhGH^mreH2FsfIIfyKDZ^(a^KRc8j zLqz|NHkPFb;Xda-6y=1N!SvH9xoDWwTHj?2M|swU5h&kUI}(kOUeYl?8Wl+CGUkIq zR21@8qs6F1w$o?E^~4nPKUUAFh;Z*Pry;`q2jvgYbjx!F zs*v*Zxke?LDQQ1AN1x3?RaQbZs*yEyeAb52L&P1yAo@DYQbaXTdVVe<+|z@3h;VN^ z>ktv$E~rN#DR?$(Hw%07BV>e7T( zg#6sP60MT$^oihV)NG@54O(mMTZi71?UqX}Zy_Qzcu0O3bUk~LNDQ`-mdH{LkrW(3 zxh#cL7g3m{2oYT~*nl=#tv8{T5ceHlSxRfSpsm&u+tANK{+zlU5$@c$1ASm??nH!p zgR={@+J0|CyF;7<^mG1u&~K!D@5=u;>_voo*Rv0OB;!->>VAv%+Xy*;4vO?ME{D)z z$y48`9HF&;kUV9M46_tb>Dc`vI%>zmF?3w|TF;v&&|f4?Kljv*2=`w;9q6Q#pwG`v zq0_RaUL~ACXKhcPL!DB(Ua@qc^H$ykbXodZkM}DmA|>b*SrA5H+OFSC5j=&SGW`hs z$g~hGG%Z4lOi!byP0yfbObw{Pv=}Wm{TTh&^b_sFwA@92!JFt!(_4(|x4f>P$XmV*!FtZA>%HPo{Cd{Z-&yX5 z@nr&#eNcYhmv{(A+= z5~iT3?=W6wKTYpWL(?S}od;*2A6lMIq6*2gL+VnA>MWOf6p}POro(8V<+2D7F70VV zxbIkc1~pjC7NZu+a|_xk{h~9`Hnd&x)R}e%+G#c0g<54zJqNU*)0WE_bXH2&9z2IS zEv*Zkx0+o*pG(d3j_)!e+?DYaM7R>ZK*a4qs5d?FC5l*Du#r2Wjebw}L47w;`bW>P zW*o|~v|N<8krFOQe~v);8~wRzBr4d*@u0s&Ux>zT^y8%nO|V=_(d3POPM(6MTiOg% zA*Jgms6n60!a#@a6Z1VlG60H)AmH)9_jhZ+4wy!~dvhx0n2sf6FqyMuK{({;! z(VwZ(h7NSn(oUh%A|2Of&{>g=rE{orlbB zS}5()akdCGSo;>ECDK0Kw~c72ZP$dBiFC9sM_X;XZHRCqVwg>MrTHp0~c0=9Y^?lX@5w1mFLox6A5gChm z+jf0Wylkge+x<|It(lBc-nG$+(%$u>d;rRjG@Y+AQI6%3i}I{4BT#{@S%`|>^&_ho zO}4ZtXzIKE3St^MD0R^hatIxj?eseG7&>lkIDy*V^;ZcU=%l3SdF&L5SP4N3eb(Z~ zXE)Tn#pl@r^=$FSN-q@C;@cjJddr%6JoG_?E8%)X^bM|wWfa_i2-ov{5#g>b;!u2x zp9}h-1j{oKC0TjND8+I~MQJU59}PemQl8$EWTL?>ey?Pq9BWZ7`u{wge_Yqq^~Z1J zCZ#0`3JMAe38ez14NDtrT2k7eY2l^?rA?b|u<52tzvEN7X=&l6g-bUm-Lz=~m4qe* zOE=wY(@mRh+Mu)`HDPHJg?fLV=f3Yff4m-#^W@y0=bn4t@6Y?g=ku8$t=M~wiO?h= zdqU?y^K@kMp%UrA?zmEDk&b0Cv_#{w6e`pHBG5AJZ#lF=TUiNh(ot`Qwn*rugg{WkLDsD}bif_?(yqJy64pF35}?gbJmey>3p2X4LpIYbG?S z#^=OrXikmpKWLtmw6DD9LnS)0QfPtpz7Sd@WOwRfs7!qkXqnbq4y{n%N@!J$-(#zx za-G8(sG`R2#`VyK8ozTkLX{FT`)aTX+9czQd70U6hPLQfwnE#qzwJ=9_E!V#QeQ1p zr_rs4_Gk|cP-BgMuHOV9H`_lz$ejgmL3?$!`=I?ghXc?-?eA^qkVf|~bWHlQ=gV;@ z(31QoK`?KJ&zJd7$qwINDYR$@_k6|jyVZ-KC0cSRR3;_uj*CFcc5odVDN$Mutz#wzb(|g01s%&p=#q}Q6Y5f5Hx!7^ zo;GGD*N2^cwmqPpJBj@62Y6i-1@%&2Z>Y~suJ+%QIYdJ-JNNIr9&fj`go3nGPPtDG+J8uq4YNf%9h^kvxKqGIPHBrl(W-EFc+F2 zJ=m+^#GQPz*G{+lBUl0Ae;K9CyCCH5X6}Y2X%BhOWGQJ+z9~??#Kk_dDuAZ$)UyXF z)F@4dW@x>c&>XEd7n-Lf=R+mxD}@$l1Q$YO>We_jBrbNZEQeNz&z?Oip;a0ynX5@Bgm)5I=>SR@HUe`l=bkq&R^G&}-5v-t5 z>oq}pwd6i%zxeE3MMm7h|gZt7D9`5`BP)@u2GjzxPF~j%}@AP zw_vrS(AR+#Eb%_G6zbCXbwj}}R`XkSHEUVTS|95kP|sQ)r6{OZt>5pxp+2=fccY=0 zS|8n52)SJ951|3(z_YB;04T1OocOWy7Y`*!NxM@Mp`=1EuHFIY zpsvH)&>Qp*L+^bLUBSicjKXi*L+SS zLP@Xrotg{{d(Fos<+V{|D1477f)yYavs5Te>!m|CYwsf<mFR3sp#@rUA+$(Zv8VoGXo-$xDO9H8j6lmIg7)-Y4z19!tb|sH zFMJXOm22;7ptZ00ow^RH(6OwCHpu*J4sL`hr9XQQRt0U+`E7=_yymY*TcK?_hwV_c zj->|LrES(ib-F6`&>rm7!U=!}j+f%af_ zbA8zD=hqwRv)e~78j9KN&#YLezm&8qJphW^&9!5qtb06^u-iv45lYfllA)B{e*IFR zwB7#9N{5i!w>Lq^z594Ggxo$F0U`I!-AE`?+O+3q7BpH)+WWdOP`3E&2|X5?q`o|8 zvihb#`Mdr5aRt!S-G1jxg9^3YbZCam$Y#$>s7QM+hGuEW*-*)Dzr#zR1={992)TXq z0|>b@`G*j4*S|#&;`2EY-Nn$7-K?#>!Y+l%w9N>#Oh#tU&*ji6jpu6USy`heq?P9& z6l-1-DuBCl z9@Phh?@dHw1!$o8oy4UPLT-mQLHo7$1JIv!)PI2v>TKVJ4yo@jbVSzA-oYP*j!AUG zX9Ca(joC@4c{iuVe(AjhI;AB~L#;ygKC%rutNopW+BFg#&;=dmMd*^Y(g}5Gy>2Mb zahf`If1U1rD5lQuqgW`e&hP1XD51{pfJ7*%&d)CyO4EAj5OVMQ+yo(amAM%jA^q8R zk4Hiob^a{Kgiv4eoCbMrL0Z&xDFJO2yEuIx@uG<;{lX2-!QUxzOX<%D15sonI-m zKz$3LCECMMXoJRjBUGugt%5ey`TejN+NPu44(-&EyCCGwms+S%`)h*s>Kyh#`(@Pj zI(7g$Sm)3Ax1mEivcu4kI)5K>6gsByJPw`Ev7Ch7m36o8n*0Z9)>c}e);fPqv_WTe zEa#wh?Y#rKprgJBeJVZJv;2QhplzCZUiH-bHR=KNtoPX(1@)@;>)spcQ}55uXeg%M z=X@*_SMT>uLyy(7w)S1bZ$W6V`9yZr z90(mONB0l?EsuWtFy)T3o)%*Ro7`mjrcS2nnAeH? z>pl|EP|WLo_s2qUuXA{z60~F@H28JDwnHG~W|RaWm%GVOiq=bo(!^)?W;%4! z>wZ;khDK|fW1#HUIhXAJd5nd|X)EKQ30iU@G+BL9p#0Z;78F2J)i(_))ZV8T{4u?;LpKSD6PTgU^+CS!AE{1l&QWfXmkUSx6f3^K-og}+CLV$y@5UU zsATya5OQbaI0(6`%y=kAMrJc47n;!Evu7f7SA*Y2cSDo3ULG`AeN&)(9ZLZ;RejT- zLhWHXG(%gN2^DF*VrZ5|Vm35S>&=Hsv}7r?puz9Bh0vk~e@ZQekUKGd1Z~j%HbRx+ zvsqUKRqGnnK)ZC*wNRaor5@U&tu#Q5(w}{**#zyCdiFHk2kme0yZ-=mNb4Pjj%k0# zp?^uN?SBRS8$#~Y#0dzwygms9GHRPW<_)s^4NqI4ZEyHUY=^4f@L5*_?RvwHr537t z!>@ZibV5s>gqq**dDa4*(jHDjt#A1GwLu?gy)zJU`}Qn^63ve#>&`*#Z}|M|fG+4b zFG82Jhfb(Vda$pgx}iYpnK!xKzUk-C1M2yvpFi3iO?i1nFmdN z)9-t<@IlTfqzTA)^~*9M(c-#MsVd+&fQyh--FD6zf>UDA4;P?yxR8QBd5>NAb} zrbHtVv`X`cqhFP1sIU6^K?#jM zKNF!5>Kh4VHu`vGL8EmnW1#FtAKkIgIPHBrl+)-Vm}-lR4PCuI11Q?R_P*O6#qL>eN>c?UCN?Yvu;1QG0KK z_DT;sWX1MD`=yoe{T!%S+iZbOX}!}>tE`H>`nEx5b++fAcCFU|1yavmS4u-^%^2C8gg-4DoEs-Qqinm<5)@b%1F&|6A-p}k7`Se1Ru;r%t_OD+`b zQ{Nx){gKr?F_s>3p~^pcUlkOn&-@AclP_uZb6>FEms|%S_p52oLlygpuKo1kdMMb> z9PH{~H^f&Su4nuA2?pr;5F9MpPmLvMSZ`71N}tEXHj z_^Y=0H|TFlhoD1BhoQr?nJ}72n7>1RSNaF^52Yi}5v8NhQR33)OH%J0=pChhLjP1c z1|3s64jm^h>9iI5t-644v z1@#izdK>-qhWfng>qSE`LIh2mQ1 z&)&txLkTVZBua#mS{SE&29XS9YP~FIbPK1Hy>}Y}WoyZ?&^YaHJXEChilJHB-)yK{ z>#c#-N=bVzt%Ejb$&FB@maKv{X%CyBYVDy0+STIk$ZDZF8K=E}sfQZ1l_qGfkUg{Z zL4men-Xps2`6xx8W$*cueL3_K@!9LxPa))@v;sozJ8xG)tKRe1{?$wqr2=dVB)p+J4+eeUkx_i?!cLT>KkAmr{*#zQ&p(~7;{&4nhs&;0Bi=R|0dv|{I% z2ThiGHb198v$TiVP@p}SQ@lbs<@-yAMx6378wrg%#cb^>;S4DAlpkjnG+OJ8fdciJ z)6i*O(tNw8PL5ZRIF*OnSHb=s0vjeJ7!2X~jP8X@O3u z?=;jZ^=uZjL1(3;eb43`)cyf`#eQO}1G?~m-?tZ`Pj%G)gOH2$B?!5>cS32Mx5(UuLh3sqesSt8KOoIxw&FRn#?QbSj zBqeQr7DKbz{MBMMG^fq)hq=%^DQRKqeQTgCZGJVkLfh209jb2gJD>*IrTx`Hbvn*^Xpcss0cuoV6SPZQNSZJL3#zQ$D`Sr_%CVa%&hMx_DCQ0vhcjQ5n zKk_?u3Y4#H7C>MBNHYtX`jOAUX;9%u{(tYLLo;+nGohl7$lal`$BLm@(t~}HKO34O z6u#dG&C~woLnYcnDYQU+3!z0aKl`L@F|*g3JTU!|mjXUEbI3CK8diFDExzGgh+3V&+Xwn&f?&d+0wUsGQzLd04DuAY*@pG64 zJt*U}cg=+mav3=tnsJ7AH1Ck?nF$r?IE$fKI-}Xp9PMu|H1CYhg85L%8K0k}&;sdU znzXqPTB7|eg_h|!mqRPGhn3JOA^Tp)YN-5--vMi&wc7hSs6u159@?O@-3V1mNqf>( zK`)*0C+*A7CT(Riv_(d3-&5QQZPPZlLv=dpdT7rXzc(A8M(wW&+N*Qe2OZQN-i8jT z?=W;sqHFJ8jzcHT_*3H~)U2~@fli&_e~liq*G;HZ$I=F!l?<_;?m7pxYY!dJ1znYk z&?U{mPN+*G-wg#qHnYrG?(5F_eCYx8I_tB%H`M2>KSQFSn6rLWVxhRRKIh}1B(0YW zrJVH{k_x4X&#p>3G(y_6cPt~J%(H$hSX>-Wl1s7%IUPqzrPOlE6O#pTcn9p_4D zmGofmwpT;tXIWeOnd~)Cg+^&Tv_b1_geryXI#fZMq<4Ge+6-;ck!^*xNt^aRowh^O zI+hw}m(HjbsypkmwI14|y*EI6wd6i%zmDYqbV$c?7&@Y@9EFZ)79598XuXqAv$oj+ zy(e?9SG@NjoAwDo8+2A@dk$*X-aDWR(w|+2i_j%)vl9w*e&!tK z;5na>1<=%Uek{|V!gGEs)1etcHX~<3MOv~Lnsv_a)Y;G+Y14jQW-c`EoZk=gp%U${ z6k2f3=k7vik+fo8u`h;@JK28(Ezz+og_h}9mP0Gf`Sn`~t2qm3o zEcQPXlcALJe%(`{G-<_N@zSBu+TR!`TYDG_jgwaF9nyFxNBhf#CY|?ZT^=;~yuZdx zf%3J@0%+=ae|}Db3T2!&Tc<-sTCy0LrQ@6p%{lK+jk(Y~9oc-SMC+A83$)FJ&>|h# zVrYeyTnVi@@3Ut$R4((gSBo{!S|NMxu7fJH-g;<*kd5_5s8Ysh-<7R`HfiKHL)AL! z8fb^s+X*4J-*-XCwOI?*Y47#W9&NJ$YLx!$+1do{)ky4v_G`TZ&_N-4hx;~kNc%es z9nmNqg^p<}$DtEa&#v@Is9Ak2P^+#=8x&}prk!kU_cQ7N^=$V!7zHJ$FA+)-vJp&% z(%O9{r$ZyOClXJpA$2oBJ~wRr8=_jLCCG`_n~E4ayhg@TCw-LE1^|day3*gW3l&j zYoN92TL)E&&pvUff;LGjc8xYeTO`)@)!TUQqu0YS}4#SOb67# zeaJ-nzS@7G|0;a~eWG*$x}bCsx~TLi^r_PSp#Lddf-Wg_k}sY9m!Nt;Jv;H)@5_sV zdUf*oxZkeh&bT+!r;~WvU(br}@?ROGdB)r_3U@(r$ES!EET#@dbhusF&s+k zI(KM(~dzcQ*5Z?>(&9XD0hcuoKcaF+H zVdOKh0_1-AyQp(i4hkcfi&bVx)**t`tiBek0J-mFEQV%v^39#8H?a=0p%PhX`wh*d z&;p6B{nqD&&?4y}{JjF`M}92k322FC!BVJ9NRSVCkdfK%f!qjfl9fIr@!Sk;kv7i?ZH2aJmT!k%)m`;F z2&I@FTPRr#p=^^TR0HjjHtqNM*FtsDrv2XDdT5W3jYI>~C_Cq++lXKjv{!sKllMV? z?Bx4!AC@-%1nn1kPUrx1P}lu!=ujv31@`we4nu*)%v@&oU-l73CxwBv-lzG|joGb{r-=(<)La8QEzEkv82sz(q2)SQg z90O%vCI`1%L&>qw9hb=^`Rz(G4npo*ZpTA8m;FAR-Cqj2!=C>&R`4i%D z_vKNIDA`=~Q-1RjE67vdJI_W(_eT&)Gkqh>=m}_v`j$dv z5?!005onpLgZ*CO?C%Y3hpMGb`(4~M&@LHuv#eMxRHu=zhxTYZ z8=xb)1CBz+Wc`l(kKK42LN}Xhw$jSKAat`CCG>9yx&3egLM~fRLdgBz#Jdo3cd`G0 zkn=S|$gOk>bV}x6zq|G{^nu3ZLkPKVi*ALGTcbAUtmNmb^BLJWs9k*>&;{wip28QQ zOB#tzs7p%P-?{6C0-ce$!X3mFU#|z$^9m*H@7zT}y@bMFD2Mu7A!daVmuM*F3j6N~ zp;#zRTCuqs4<%gjSKmY^N$S~O`%Q+@gtjfAUOF^F$bRedNGS6P*=nz;S$4lXB%sq3!(6wFkgWtNGtY=J`tKEWbZ@r zpnIg=?ft2DFEsgzzsgL3@@1Sa%4`dusX}iEO@j)raAvg#O^0S&VQ*d#nh6z2$s1mQ zilJFTw+PLK=3Ma^G8Zb5aZVFoDO4^c7YeO`)~atERG~erhc*b=Yw^Y_qbgB&A5w)C zAoqKpzl4zcw&-6$mFla4kh_Y$1Z@(Zy|da3ZP6aKLdadmUV)IiOZY8>T)u3Bkh>ym zhpJ_b?0-7dKszKuwqrkSS~`Bq!oMR>bx?l5ryB0-h>rclJ8PCmm%c7FZ~LHZZh_p z)w>|%uG8I6pkpyz?BXsTr5;evE}vOZP_Hg>>7VO~b#JIommhUB6w@W={4@Asp|~#Q z@K5=L&v+=IixpcWkw}D+y8P8J8A9$VGZaecBD3tTU#CK8UF6c2^FLk z>>AYvh4~qcCA!)Ci~q#yizOOpHcX8&{jfw~$r!N^zwz+EwI&v;KMLy&z#53cUql#$ zl_Y<$tt89e)i(?)4TY_wV~s#zDvk0Oeh1b#^^M2MLE(GXxmXj_HxVlz zg>4pK6{>GKmMHA~8(1??IO>^LqR{scmMHAuVXRpw9LsF1MJOESVyp@j)?1GisLzb* z8dZkEdJ(Jug(EW=P^RzQWI>~C7Id@jW1wuwmmkP)wU33yu`=PwJ|4=E-tGCB3r+An zn2FFNS?Pyl-SeQy(qH&nOi+H8&+7tcs%FnLs8F(Qu>9imbZCb3Zhy6DCR8MJ(;bYZ z7@8#|t4~pKHZ(_lbD{6(SiTFD$o%YY8<#>0WPbJ=;1{xfD7>!ZVg-wIMvI{(Qu4UG zu38F}3E9^q5ono4VmY)zcAWjquayuAV{M*>R!Kek>$Iz(a-s0IqM-^I^=7#vTMwPm zeR~>e)gIcQvwocB9MrD+uLJt8_WlWUL3_UlU6L8ut9>Wb)#ZO-q8kdN2YZ)bx;Ztv z{aAWHJ-Zp_U*rUff<|`}>z~W7jgEn`yL}dng~kcld(-hyPPd{ z>1tY;0_95&t3GEn3!tgp{$!X26>5Lep;_JjJev*8QQur>o{YMFF5{dJEz**Up(RrC z`;tpbp=IK;*T&`0Ds5#ov`b6YLUrBbi~SAs`tDIlDEu9jWUQ~Cq2_MRrLSVmK;e^x znOF~@8_ZpKQRZQ+A{5?16k}boKKVc6POL5zw$hFDnWZh;qs-@6f%?qr%ngOt;#8~~ zQJ7i7wLpCfv3{VwA7U*+;T#rY{Rr{ujIlg{wZxLlb}82X zp>Re&#;QbN?^Rfh*0<#AQKkv&BTI7kbOuWlUhU6fiNaRSVSS9kR?cHxK;djJVqHez zY_DJidwfaL03r9<=?w_EJC-*g=C{0>vlbxCl-6ZtSKGeGz8lm+@ zLYbd2>J8Fg7Bu=ZB4}SXj)AhpXRrNZp>bMoJd`7Cesco#a-j+0yWzLcL}-%uuDhPO z=RuRj_lcazQy?_b+#)6KgOH2-*Pwi<_mKDsp!@&aa5$MU!IQgdRWSmby%Vh51SpZ$oRxU!pXZ{2;pR;10 z`?y3yeLrVa>^)IGDCTqDN-PxjIb*TE&lL|L_xfQVH0X1GzncIdcYR2N&~UR%A~6_B zl2*dsn1)h5=SpdRF)kIl@pB^aDSOilhmiZF&NK+QyYzHugtjsg%KV&PntMfh$bxPW zpMA}BE0itsvsbmT&^WC(9-1oivv=Fmph78Wvu8RqLnvv}7?fOIw)@Ezk%q zgcixDANiaNSqxQZ1lL0wwBAN&m-=d3wNp(TOOkfjiEt^6PKWZ>&P1tI5K z1|fHzEr)&@_`9_g5OUuUvJ$G$9@aw}0-v87p)U1xLqR|e+Fw>SCL;(;#vl{5j$ATV zWdv8Q%IIsp&gpHspx~;E0VYx?zN<5WwO3~hG;h^F>!6COGm^}kJ6XT=&@Zme7;Mhe zKrccYwB$yp^6HEs=3OaS1-&FCf4l>F`Ra_aO;=|OH8(;LtN^(lHbcLWk~^f$EtEu| zFM<^y=i3UsB0bzKZT=S8CiK8AXgl<(_V+ueT1LG=O4dMuZ_}7-GJ>3I7>iuvOfD2$ zlM!pKl1P{yP!DL`b3$K$zL1e>+AV!CBQRgg7->$(uDS}!`C>+*dF4;^kP8K0B%Vv9 zP17?YNa%_0;%}iuXmC%)*>fB9hCs;qk{~q1+$g?eXsGyZ?E?*i(t0xY3F1qKkgInS zga(;-DS0#W74>};n$R;N!4ylqi4b!2?t+jT%iYjD>bn<0apqka^K{Xh z|8)>w8HC*YBG8i>m#3g*;Tviv4g|ayK4A1I_4Xp@9%e zH0|Gn20_TRnE;_d=F7jpmk6Q0rc^#xHyA>$%^?sPY<{*LUlN1{nda^6oMZ^Ol0zXh z#2ot_zF`n@ZKgoT)k}p?g6WoD%()RluHzZDl(2kjC?2 zs7U)OhUP?NxU0-u2)Q;Nhf1_$DO47f;eOLB0#)e9)4cg&5aVXZV0)OpFzmw z+2;@%Xr^wYWB?)8in%r;xawNgcF8<^S3}nbePce<147RC1qdaY7o_ADp`O>0J&y=Q zL4B{~oWD<~AB0@V7^uIrvQvBmpdr^XmJXpL2)UBU(9mm{!$9#3gHoj4mt`!e5K1uD z3f%}H*WYjmxv``{$dycoZWppMx&s=o_3ni7wB$Vya=qUR-KQnL2IWgh8>IrMQ0q;H z&>)j4Bl`w4Lwz%$M|8H2LO;+}ehB?Y`+EXftF5eqexk(BHJ)A?P1c(#HA-gxqQ#g^uY;ABX;>zJEi=)jI(p zSMMbBuEz5}P>cBN9NvSF8_WC92U_n#=&X+H9E4ofeGDPD?&qO)ZKVUcp!F_7$kqE4 zLJ8()k}v;*E(zJ$c0yM)W?c|+bLfUX(>6bc0`-~exaM5P9JX#{#o{3pYaW(+(}7UZ zb&RvI7uUaJC|&43@_oNIU6)aYLSFLV>i|VDIF5LA^Nn@~)+x=?(SfjLesF z$y}dNcK!7k{mtecynloh#9W_oqj?=NvCshTGjR|aYOdXmFCH3rJ@dO+Xb_aBB?m)j zn7K=QL!ja6OQSzD$jreP!3t2=yGh5F?R$@41<3ivLMXvJDQ(^k-KoAWQx7HaLrAoV z6`(<8kCe=TkZUEEl9T)#B3Na4-WS0N&=B*E)Vl}zn)jJ}2ql>d;wykqvg!9KbU&?p zT}E~*6u}Cns&5*;8REN7d{_Y*Xy!`2nfMSh5+7E8hMSGzd+7R%95l$h1?6Iuq0kq> z3Q$^~+ex&hy z0xHv1BG8lC!&8jxr`{LA3Q)YcUwT-94~01q!3t2AFJ>h^6vixq6(HgwEB!QtT=qNz zt!;^5qb6kzd0~qcD4Nu>#~W>u1nL--`Jqgj~H} zL6t&w|5aU|(Om5#*n$xhTlVbBqxqZ?+s9+l$0G{nwafdK3{UN%21eD5v*Xp_HY3Dv&QT% z&_SK;+vN4%e90!PW)$|`f)$_yb5VLf1R*!F!_YAwC374);m2Z5Le0KS(*nJxCEth6 zYAffUkM(pr4_(rFolv0l%ni^DoV1Tjmy@;+)CcPObD?PN!J^5?*hio!s8=+xK34(t zhESquodsPFA$QMr1Jp;zzG9Dt;-Wd(2VBK$kvGGjlxX(D zEV&}2LTTzthccz!qH=s$(HUi5k~Y7Gmob%7hl_U?! zkLLVLksb=5LhWxlR20oqm3(=XRSeCMR_yDsxllsqPa&IA+Md5Lo1}sLV4A*5-OLH`+fzjfhwe=z4u-ZRYvpr!AQv}=%wfk_x|6@ z&?X({W(c`g8o!26nrV>Nf4_l{`>&QQ5OS?-g{q^;y2oUGHBgtl&WzORdbk5JIla>3uVLqA<&&u!5q#?4u>clq`nk^d&i;E`=5eJuAI0hRURe+hy(%XoZa0K1o~&l}iuDrQ{l@ zLVB=Sz8$)3CAoY)F&lOF8aZiil#Htm0){0^!XvhO$6Ky^~m?(ll3 zQR_89`-MK0j647#H`_l$f9cD9x3BaMLWk6M7((ux_&an=M*Zg^S~(6itFHxWm9bRI z_YAc`$c^PAs9oy4e=GCrfG$W$`#j_#bg8dDCpw`nq51Oqwi`ljoS#9+JyrP}Lhj@X zAmnzq=|?8_^F2gCy@dWO>)spc)6b7A8j9=3$i_)r;-RE|v~thy<@pPgCiUz-N{2GV z*H4~mWb06S>eNX>)@_A`i-!R_qyB03rAJ{`;ZAem=U>p*hlu&E&bz zA|2;qs7!qkXhlDtx2TKUh;a{#E>B|er=xAuU)br9!iSwtC9?*#ALX0J{3xf@uy-s zlo>-#*e8NnP`30iNKV?ZP)>{=buKh1hA18XCB8f;UrILL4;4U#LLEZWp`sX8di`%1 zOEEMjhSmJ|d3O+1hwkLBEb+#h#MP`b|tm&XyRm^#=aWI2S8G15Bm(wnE!9F597MY2^g@Vrrl| zjbJ_07~`MUH9`Ajw)UJq03Fh?9EScb{oN|L^bZKRbw2_j7p0>Ra{oj34s=Z7Vy|k) zp=N1w>?^d=0<}uL@@l9JY8PLRJw&1dx+LSY=VvDr*ip;X$;6T&vE;-YIlZHyq*z+9 zXI(Os8OvSzA$cz(3(AS56?;X6MBQzT-(UPT5SuCsay4*)bpz>Jm$Og*kwg#${9_;m~3aXZI z+S8^6YK+auFw5l5u?ad9OI)s&$RCE9W63&uMz%oh;*+z$bU+tk$)#82S;EEGj4~A7 z;YP56PhmOJxC|u3=utecG|2|eL3R`Kz3e;yVLCB4|6YAF9KZ62a z&zSzKSbv`{(NJ7}AM1E1slVR=$xvp0X;W4@3(D#5Pt05>zdw<;&-@FZA}MLlkYebO z{*raF8y|&ciO*iAXG6%H6OTb9>MMnoN$+-VE{Do(@Akd`dcMCuQC@(4A#;%b9WgIL z8~XeGz7az1PVSdbrIxIMw#lgHRZ?#|gxqd?6{=QW4b-S{X@U;v$PPozQqN{`3)J49 z?6FtF4(O7!VxL2ILdZRXzYHOF)xH9u0p_IK$#p@fujwnZ?S=v^X$G)j1NG6(36gK`G={&JzO2-)5LRS5CjH#Owv1PHm`6r2d58_a4M*a7%GYL{gpyxQm;hrg(Fb8_(n_bYoN+F zR>i(_3Z$N0TN9trjE0&IzY}Fzu*!PGlabqb zW*ot)M4_(=s}T(`Bl-V=Cah+}{SqZxumTj;Ghd(;G}K&8n-Q!=OVUFVR)B_?g)-YO z#%CmmZb5cFT%A4~xT&!PZjA)2?oF0;~GEwNu!U~X!+3%ph`*_2HcNGTu zRt7;R#q5=_BtXbzNFp?Bpl>Aw%2Zz#gj~Md0^O!1zci4iTLay?N3a4*c5SmEMX&-4RC=)cb{2%3Z#MMUK%eE`g661iE`(g~k3&zY?gm1iKt|N2Q>Rzb*}8mplfwd4i}C7VYj>o!7v*B<@>y`%O1 z2_4h^jzj;_R{jkk*TV@2xk#LZnk7p1yl#Qs(;2-FA$NwHf?Czr1|henKZ1~p#2M(U z_HYh5ufBE&xgFO5Avc!)LKoC`5&Beoa+NXvW8JTKUj!>a;T6GjLCE#q4Fy`y4C24T z2KgSYf{@F|tD$RzW!!jE4GZ5B(tIRwV{PuJ>37C7Y+D_x=!aB?my6I)^Oi7U{u8 z@K$KFwlW4nZf$Raveh>hx?SQT*IRQ3gj_4*AmrvZ9?H=uHLSZB# zSb-&b#k(IuZVq3Erpg@boxn8c0d4a^2)X@N2%+%$Zl*)u(0bp5kQ?<32!+=iGn26( zHx{hmAt8I6ei%Y-EJc(=VdNuN0dl@#2)VI50wK4@9))J$!_hWo0+&K}f0EOqgDT9#fJpv)u!;{cc>RSdO*XD8vx!v&-=%*Tq74(PPl>{q5 zZr`qip4PZL10gq-RSjl%9o<8|QQMfWp;`U>o5+nCD?o14>!9alWO6rZUVtjZXLDjb^b5_;7oiP8a`$UCLda#vFCpZj`zxqY zd#{3C(viIkZSwOoo1rZ_zpcqm+wPgu)0G zV+F|FB@BR&>md$8F4pnTKpoj2C_zfv^D_}buHIm1gq9o$jY{wgj}1mAmqj}o4KQ~UIZ&ZuH<77a;8ic6%G@V}g)q(q){$L`<`DH%#jWR2{z?Q|$p>e*+2 zSx|POe~L2}LhgCb?NCl4&rz;Umr4{=$ zdks{P$U8A#U4U;rR4HxRr*Ku!7CRPsF1!`0PV~=vYM?r4#lEjr4{BW;m^mtw!DQUBHCG@n6bHDrt?K2Q^YqSa~SKk__LRztV zY&}$|Jybz24Q9pc`v@;X$Ytav2)VIrhPG(Qtx&a$Wsf{_tbytzN)Jwh>Y+wm+a?I5 zm|wllIR5~>B|anXVeW-cl6hWMdLM*_nLkL$KSC(M)Qay<5K1+zyYcOZkZbb*bV$Zx z-_bk_9n(=Chngi4hoqGjs8u3i_huW^E@QEuTfi4WZ#XL z4}C{{--SxlR|+i}Lae)GEQ_HsY16(Z6@gX=*=O4;q18i(*}L*z7|%lG+WQ))LdZsb zJya<%vsqUKZ5iV8dMi{tgcYlhr~EZgoy0Rd&!9cFcX^kl0YdJ*kT;;lA%6EaLHo7$ z1JEIfnSCaH7&<1s&yj1uaj02(x7pJIwF=qyf!d&UskeOxB|D%%R>fYqOcFVfM`;xHX4dc@-d5tlC)kjlp;R6H&dasB>J=OnxsRSww}y33(6LSz4cI~# zT3iPqcYfAGdt^rTzX=MN=b?7-?UtBzK$m2k z_Rgvk3X;g=XQg+OOhzVC(%$zZLpjMr{!z)@T&O77U#p6tvSem-jl3Hlfhwh*JqxO! z#$WK#PN!II36jm?P-&}!uWA#B{E74dnDBASdb)AXDibF$8 z5)_Y>fWnfASOE%qFcXGmWTMcQg>?yqzD}$zlwj_pm2Rv6#hB@knaDU%e=`s2fz{KJ zj4TSP7m6_ve7&)fP}pWNRtoBG*5XUW%2Zz#)@a1H60Eq+jKLbGzVTQ&DC|8Ks|bao zF20vWgH459T!D>Wdn@w1I)wd7plKMKa0u+wgOoEWhx;*}lqD}8wRs|~!g>y*9 z8i8U=BEFGWnJCQ2EUeKetTzTL2ZbZc#hRtQ*;pm&E5%x%zJ*w2$i)n6oBFn6RjaQC zYnS?JvFcEmOZ8ZLP&mH^tN?{$G50_y9JRTZl}6#*qp%Vz$&O3JNtS%I`*^Lz-7m3Lb3QL+P5OTiz*wZN7V?D5XqOgZ3tRxh+ znT(YpvO6vn>qZpT8;+HR;>=9OnT|CAh4n^aWuh>bvaoJJVGp-rjYDC{@mM)1+?%;r z^VByVt3-XJSc}xR7;6a%XS5Wn423%&<9-^_5P&kKVtP~Wsl8Tjv!gWZ;8iB%AMq-UdVaYLA z*(lyT%~-}_O+sPGJgmtm%*ZKN`RXgcDnwx`)3Iit!R94enTa(=eRHwqq3~)kAFIUr zT9@P&mJ(SY_7tCT&Kr%2BHMo5T#O0)^vTkF^2uya3-utV;D&VQoU; zI5%T$L17PDv8t^v>b2`k4c0CcZ{nd^tUBwHk=0}EL1BLlSdA!br3q^<3VYaxwI78g z4`3Zq-(jpHmgF2figgTyYjhmz1PWL4BvvyD+ibx)Wl65#r?J}A*MW5jh5dD6b)j$< zcVh*}?Z$la1%+)UW2K<5UMg0;`UKMd8TSVO3b4tkHU`4Jd4LBUU8}&&VpQ zy~wQt)_xS`^#QDdDD3@htV1X)c^K;m3TJy1>lg}qIF8kf!m+eqokHRKPGhyA@Y>&o z)sDh8JFqTTDqz$XvAR&$W;a%V!h0K2zz#<#rkIjZSiR5?vkdBu)dvkXFG0~*aVQ*1 zJXV6G{rD2GGErDB3u`n=G4J6UgO!cKdSkK1q2Z>-?(58WtQ_l;R&uc>pcHd6zKK|q z)R%`f8Qo;=!#4#hABFuDU=^Yi^Fw^ov1VA38O_8hLSgU4SY;@z7r|O)ebW1KtQCm= zPFYD7V69T$YOHb;Z+<}!Yp^O%*vfjW4JeG#MyyJdY<5tx3TqPzBe5B43kt`w6>A#` z+uV+|%lc$2wODm197{b`BMSR#!uo^t{hc1(!rF`YOd7NgYrpl$+z((KL}3qaV;w?a z1?!acNvuy}wWIKUq66!K^(7L)i&$OOCo}5C z3Q(r$Q^)=F{hS3T%*bS{6ck>MQn5y(FiK;vvQfA@#$t_A-*~JX>yx|mT&xKwY;z*k zBowxihcy|6txUnnN6|+1bOF{36qcNcRfNLbQH(VU#hWiP>e*QHP*`$4RtXC4vr4f7 z^_j0jQ~g-Xw4oViANKXmVYQ=hEFD-E)OQi<5(>|WPOJchGcrZ&Mily@v0_l@i^YmV zJj-L8@mL8cye~+^5{084j3pXmWL1V>iQ-Ku^^&keVaa5y3CP70Ym)Uz4|!NqQ5dCZ zSTj&qawb+03R@}0nx($kSaVQ#6`hN<0EHzNVl6^pD~qv~pm4TJvC2?*4Ub?2C_D#E zF;ph6Ozl&u2vjLEvY$n)f*QqVU-vgb%|cNPJWFVS0)5qLhVhDan16kn4CM@?75hCd zxe#*CQN99A7{?{2U^yD!A1nVhi*)X03*-wcqhbq-q1vP5DCa8HB_pSC*i!D$v zj92>hxxPu^DSrwxN~~vOJyJ3nznH=tM)6s_Cal2HZQ|<*p*Zt^P!xpX&3vJ2Arxai zk;xs5C*%QqMl?YJq|qiQsz@c{7~dF+3yP43&4NqM)SVyd!pxP%@M= z-0$05sAxEC+V`u9p)&PFpl63OBfH<9gUW^M6XZ1za-TE(8HD1^v?_Y|Ika{-Id4Bz zv<}*!t!#vl`_%U@p-S~tL5D;YNW)5aCRHki4 zpvH7YmMTy3o1h?_2$stG%;qLWb`yKkzH^=nmEA<|OTLLO0yW;m8rkfKB$_H%r>P}$A+>}M(>P~*+4Us`X*(gX!J6J2|KFe9ipf_mSTPk!Y>Wh01$eJ&h< z8b=U`Fkhfx1UulD^1T3NB=tsWPC#WNeU?WcMMq3X)Cj#@2Ky)5OUXm5(v3(^C^WEWOC|nkZ~@AeysJ%Amp+&0ySn5Gkb^B z1O=HHqs*V={Q#3i$t-^jh(P68oFV&v&1$ZJf-J6MwKfvBQ1TXLv`5af2o&7n*Vf#M z?^a^=W%(>{1X?C!S8O@-lUpUa@;SwyLMv{i%?Uy)p|!ViVkQW!gDTXw9@?O-Y=kPc zWEF(mcbvThZM&5->%a2NLffHgZKVd!26L5cx0eq0I^ixt+Qm+NLG9L)F?} z4OBPApA7ZT>(WY<%;EPCa^G#Y2SRQ}4bU6Xiv9MIH=#!LH9>nNF81u%2klqi0qD;f zv%f&d^>7eEE)s7;f7OzIgOHoiA?S$4`Y413n*lQFcOc|i`6qNt<8mB2q4iEe$j#_o z2n{kX%3a=nAe3mX$T~Db$dzn?TE~#rFUmRJ2DOjzr&|Yv-1j8@7ea%~V)^9WC(s3% z!>X5{i%_6#n%l^%+vvewWg<{;8(Cog3(9;6-#mo6p)f2DtIq9 z2P;@}5S1usVg;1@CD=ew6BPyP*$N7Zk|-ctK@$Z93nVszf}UeRuqIZpCQ?ikFi}9F zl=M#M|sc?^$msc#pm9CDS$3Ya{iku zeSI-BOv@V%U9R~?LWNR#j$9!XL078pDrj_)_46U|je*9h?|NvwwASrYFP zO-5xdR4#S7Ov2TKh8ty{A#$hgJ&RCZ(@}J{EHA`vj`dn$<#IYR$faR!e#AKelV2?{%bq zfaY;Vo*XyAM?UzPqqeOXDdSVmQq$M;%TeL1)q3v4F7HEf#?M}#Q`^-S@1_u&z zlN=pwp#29jv)ukm2S9BH(mr=|w1YYf3_afw>NGHnduOPtrtAh~4GhO$Hk7L=lhB}n z%%1r&VrN2w2kM-Mh6uUq`Jqt0P(0_MizJ_$br(a!1~Trmq}Ics%e4(7p+YUa2)a`8 zxg+i>X!O8vJdA5^-!_IbjNK8bc;qA2aQ+X1n5p_t(ygRK`HUMIT%3`#pmYk zBxthK%&i(zpsCU#xAr^?l}ZV24Jm`BOG*4L$`zeiz?UpL*<&U0;<${RzWYT?-l4(?X%aQ*U#X3*X#xeBULUoC`u zl+U4GwSB)q8_!_HbbCi{f;Ow~cL@1;)&wEnpUuz~DbMXBy%pM~HTw%fzAoD#p>x38>s!DQ0s%C4&rk?*b(Y7C>$M!L0vUpH>kVj>j7nH3E5E3 zL7_#5L%k$lyc;&uTgV-MeV~623gg!o8l?HogwE2soDH3$<(&%+(Ue1>^Q3e)2hWF) zABTLXKvP}-A>U^gLKms;VrZDwb2xOl)@&q%e9Zqsw`v=1gT`yV3DE5t^A6}v&36}+ zQeOn!EBV|}bsscQeUl*M%ex;!zI_isQ*~4xhLBJB2!wq5rVZl%h9PByWv6QiGoYCo z^Kl6Ie6t|r$7nY6Kh5_9G$+I~bD>K0RY9-oELZ|nOO*I%gqCUvZ$R&83GY(R_ku6N zvX0ygc^^Xl=vVl+K3Erabp+gc%GCR?6dSk;f6xN}gFIp;R7MCGNRY z1Xboy-Ua3Os-ScpXFPXDl!2_|8!C7D<{XZ{a{^^U2_YA=AC!AexN}QF`R9ZZ3ZOzw zSp6f^mb5U73&dZG$S4-Y+ym-q(Uk8(@2hVGgnUnY0M)3k7OIo-mPN^QS>@%CjcQSPTHH?be!7=|D-j59V zBCJvr$1KAtM{&vuEPFxlnG2zdB;VcgU$2Xy5$d~)m?*AGgk>*R-$>}cA*T61s8D@H z(3RJ4Z-1}+7x5|x`E%UW5c0nbUIQWjzq--aV z(r<){)mH-DB7Nrm%Qg-ge+?sc(jnAr0#qtBb7NZuP1h(hpjq06*%0#kqW%v;{=0Ke zK*;aGHwQxgm(aP;lN$3Ws9gKH0$L!m;Ak1mh0ya_>ldIGuL<+)B?$R{R4sy#Z~I~h z`4&||$j_xJ2>JgIzYM)9HFHnMUxSd3@;Zd#H{;9_s9I{~zB|4YTBg0U9C}N~?`;VA zC}{}!(lbz`R`mFggTqEWcHi`p^oO##pF8|8lq8#LgxwnB>z%AAIg`QkITNV z1<(aTcSy_&p^L`SqNx()V(5~wyunu{b-5H8rZI;@$p7=LR^^Jv)Fa3I`Sld?ujn}@O0Nox+Fn2(A>ZsfWl}bwY|Hd*1`IwJF$k*jD zX!=;r#n(w+&wys?_&pBI8p~X&mt81lLvyq)bD?t0R{LnKVPlm+%LK{7n86+M+ev3jM9~`X31S8My;OKHpAgmz3w$p4|}g zJ_{i~cg^+C^^Ei{CCpZH19Ss@=5`D*H$pe!bNhLiVy?uCxd)miE6Typ_+p~CX95$T zR5AB5)#8gFt3Fc#m9Rp`ciuNaH!0oBGr*fUuVikf^j1*on|UX=oxIu62C_G^3h%s0 zYIX~Bi_$n~9HqN|TAN#;Ta|8uZiC``i}BESr3ugkO?f+XyV4!d9jvtR@Ah{>cPiZl z-9=yjAnh}ELw76P1Kq>i-RgWPT9gXZ3TiEMV1|5cp#4*{;V7X4psW-noFSAA<)-NQ zd`syI!0;v?u|E$#`o& zASvILD8E2|XnB7^TQuKRXq(paFUV?tnuw>%Q8=plLb(yggM0RrgoZ`T0{4C2;m`<8 zc^Pzh#Qm6imvJPNifFt0#zO@CtWnlN>onhbs3GF)w4a=h8X@HO_W2dss3|u=O;NZ< zXoj|E%&m~sl;&P`le{;lpLtWhU%Ur|x|!dE_JsDjmo_|g4b%!k-Ob_h)#be*DGt<(&p0U&857uBJ>vC0fEw5b`N+hHlZ658cmIY=ykVZ61IgVD_wC zBs7`-Moeba&w7z41EJ!{%!zx2N}ws~dk`vBUm5hN`aXlK`pgu@VG36|vLmh86WVJE zqv>|9Yy~0i|8B&$H-!9tR{KD$C7;{htqruVM%fQSewWStA=J(Ek`fM>l9NJyFGMVh z_{NwV9S1_l@2PeW)K=@#j(qJy2@#e>ejl@gp$=NJj!-A{btYez;ES*<^7#(K*HucF zeYztoi@dKJ)IH=gJ)kUYZ8p?%3S;EaF?uP~Oqn1}y8y z_29qop?IIYNa)`oUxa0m-$Su4l+c>>BPELWev7ay@+psjkRQ!sq2siD$3rKF63i(O z@_U?}3L!s!r%?j(d!u1l!n zEbHhBnJ+^jy#SrrQ z(_R80Kg%zLhG~n2L&)!oIs!tz=P!dU*V0E)I`XAs*;_T{ZTOJyT`Y_IE`Q^p3BhM> zCnbt~5tc>1o_FB8N676fjb)MF`8b6y3Q-~~i~RWA3nAYV_d&?d-HGHwamonGB44vf z`0m&8uq^UX9)KnXpP2$7zc=B75b~?dLlE+HnF^sE=4qJ|4^sm2{eoqYPx%OheA}l% z$oG6Hg#1h{gC3QX?ihOvnyz)30U;k{CWL$&9*1UWJ!eBtgu0kH5c1{Cg^+L2lhhi; zQ6el`uD%NBX(3maXGn?sh+$deM{FMSZ19=qAmr=0fPBdJ3zkLxs9J~*#XT5dS>)^a zJcN9d7a-*8@*;$MpS=Vv5^}q%E`};KWfkO{!(JPeqYVbu^7Wp204XW0;v=n+n zeC~LE6GFab%OK=SUrx-ow0&3>`Iv7*$frz0$hRQ_A-_-QJJ5R}ig_PGzC|mb-!)$o zguJhr(vhF@SQhy{`vXG$c>j}>DBg!W!m_Blc~*8Q-U4manA;%aYyB6rUC8Y}-va%u z?fVDXrM2D-SxM=3fHx1)+6N)`c6$c04>Akf`|Rc+=ppLq_QW$&d9pAy+~duM7EBFK z0Tx1O@%gWK&b3FS|tmwLHxS3-r;cn0Bq0Vslsr!ju+TDSx%offX4%b-flR|Tb~g{$oh z^sbcP{!{oK^uCt2Vp`7Fl_ARYSbu#OeA}@^@t$}sSXO=JqiH#9zX&ngVGTiXJ%?gh z^_efH<>dYtVkWUx*M;)dV10|?eBWVxkK(rffVBn1_Xu0Dwxc*@3)T+x?Zn!J;%oig zSXOTcg*H{nwf79J zDrZnn_hg|R)NKYebN}k=4rR@tboUe>8%jt@*V=wi?hLL?f0QS|NvKex6hX-UKVTG8 zJR=;9B@ps0x(P!58THK&@=wTbf$q@K?}YAAUkXCuZHgH=scADc7$b1wY)N@ zQfpQPrDt&E;r@e~foe2mErk5HpFW4`B&Pd+M?KV}zGleI;3>|LG7e@YJvftR3GV-t z?Vzlg^pe|oH5)o&W{7zt)O#i~@>=QnK2Trv{Rcwcmjfl#*AL2_8O9+A<`N3Yarym0XrH({uhohP_g85ztWUI zshJ$%=S#mtQ0Ywi`g!?xNf}fwDcwA)faXa`_s!Dz&;o7kLg)pp^@|YdWNv(gC@(>a zwPuwN@}*ZnY02kiYX+*B$x-}iNAlG|by|8o)Fd@KQfk&bGiUpb5TymnT5X?soY8zd z#LR|rAEz$v=~fa-sV{=cAE!m`KTQ=-`ti_%8K_Qt?jP^MMh0Y0YLsRIN2z3Z*q>23o1URZxwPd+(_h z`g#`6CpsL+O#TK!{6Dolv>HO*w+5=4#kp~k{9aWLHE8cPLQRs-&6j3q`z*Q2lRb-D zAgiUD+0blKx;4c75Bi_d6O7*zjP!}GQbIO_{I7F8q0^pVE={k(cRGZ;FBd}ImxKy6 zN)a?#Q;va(CFPIue5nMgQ(rw~HHw+TZlH5Q%(EanCzNOA@~mVowRUe+w}Oy=FK=%M z`S52noD1g z<4K0;3?U!03xs@K4uiUBzV1*DEiVf~K3_J3{Cl`PA>`lOJRJJBrtAwPG-f~O1g+t?Ue0e8Br)qhpLAmNnLW8t@XF_Lbt@~zE-&e53XLPIs? zc@Xj`&xi80W(9L|QWpx1kS9Y}_7Wl2hD-6G_}$3}%Oc-~VG#1~RSt(P*O()rLhZpK zXq49a3g}8L;VS5A^<6`G$kzqSj@I(VK-WsYxVOfygT`u$u7{8xu^S-d>vE&V`WA_ayX`mQW5M|3-2J^t8r&2135{c@Xk3=R*s$yoJ#75=CZ# zc>#J^qr3uDYw1g&H`Mngv`nKchtiVL&7KVOo{)Q&_EzFVw!B|IL&uFbi$L= z%)NC%LYxl=IlB&}mxU=}@jlNkRiP${7&y|AH9=A>X1ip|d2P zd*AzPXs|}fgNA4uhC=z8uK>D8QeGj~krzY5)HfWuO!Hk1A>S_}A>@1TzYy{x_J0ua zeO3q|U(X`wN{Ql*#;c&KHQzPRXw5eUx?cPG2B=u0lt4GB?`G&0^^JpWm6-0I61PEj zs_!l+B|g_n5j0WDn*=S>l*^&Cwl4#{r}^H8R;cd-2>JG{gpi*FtDq0HE+0W(YLu^_ z)k1ETuYtZ5a!2ZS(D$0}2MGBReuR)u`4d#9bD|zvCqCD{^-zQ6YlJrH_-%qVYY+Yo zHEGIb=nskFX7Zm9@}+NqkoRqcwrhDUkk$S)PeD)dR&LSLtYD@bDp#stmRE2M{ebK> zo`g~r;mSCICJVW1+9^^4h3}iKmd757epAIqifcAVkTkF|mNt8W05D?WEOpM+9RbDivV zAd8^1_}rHUGEm*qw9oy=y&kfX((Myvo*~LJp(m11>KT0Tf83!Lp5ZRb?VkQ3lonsS z;~8Y1VXce*8!?Y-qIry%yB6*T^`A#dw^P#q=%jh!xzx!J@_V zP2~g@;`poTB7D4G}nFVLa49P(D+0YZ_ zIiftrmH*?CF9{VsM@;ubq6jK}j+iUt9<~HZsV{7~ zB^HEH=?C>+z-YSP<_AEz3+Rb>cQPoofLbq-_p>6XT%x$Xu7J`DLce67RZ{xv(k~xE zwF~Hp8llf2bcAUX`U3h<$~)j)=qIRt0WHcF`WgB~eGO2f#2g^LUm@h9{022GU=~~` zzGmo;1ssjn3jGNoU&0m$bukZ#Z!2WgXBI*WX;J(R&-2jpN-sb!kT3pS`$gzQrI+ZX zm-xkKp2SQ-g)fC!R|KVA3TsFNm8-7;N(;F)D+AR@N_THx580O(mCq%nS;Y7)3MsRp z+(ne$D7z{pp~6KWUlCL(Dcv)cDk!~(8R_n5Gf>SUTI6>6sfFqmg*B@lYFb1u?UEg( znjyO=JcBTcnOTcNPqcyBE)MzHL7f(d-t7!!Y07MIY^GlxhDs%+TcOLKS&Kt2&4$Xg4HeJ=Eny*4t!-EeEz^|C zp|qyVKr0tBwvWkPEvuj!Euj`#tx?uMb<*|&-=m)OP?P$aq3s&81^Rn&SeO2Rknf2d zkX4_lWTY$ULH8W24TL(F!Sc4&zR>HEA1~A*)eL6;wrC z;^cTe`rtI(@TuR*Vco;R;UuPZI#InfeQF6MfPt(ch;OK5{Thqr;+ zE}?`+<#&>HP$%)ZQRxh2EeY2>*-&rsx${FGD6xdu;@nAF@`<6iC zm#{9ilV|=Dpp=y0-ujQAiA%zfGYOi!gb{OR^eIs367H*>l>H;ipjp}zv!QY=y#iVw zt#xbbLa0*6U3FDK)mq+CC@oRkZxb14txtEh*iotbytz zO4eie>Y;`u;hLxsY7*bsvg2Sgv|akl{rcDf*(KquZK@gRYR)+Yat>$%wXG(K+n1vq z)Tx^C@?{UV&QO++yT;6hdJDO_^nnsWZcg-r5dTVfjM9&Rj;{{0-~_0@<{JRz3b|t? z2@S5Mm)vzh9+WTSdawW*CNYnfdJcyQs~KtcmTVC;TH4@#GaUn6Tg}+I|5sfHjg^!G z<+s-Bq2g-##m(drXnZwe>%NaS0ZK{RhrC3}2%0SAxiiTWs8mwAZ%vg!$p0etD1>^N zZIbUXXqNhBL*>$jmt|g8Knof3CH$qL-Tq|BJ$5At6wVq}vZC^^;-IMMJvP&sp zq^t$z4Sa7o?=4cscLi_dluvz&arl}_i1-UWXYLS4){8I{K%t3Hz^Uz*b0S{^}7X=?4xE6otwLi*j_ zOa{tOf;$4tJ3MWAC(tfvw~*V9%tFYogXUf6T}s%!LVD>v=sl(PS%u%{&eZML+6qd% zPtRW{bGILqE54ml>m+2=XI4<_6(MB=-MfO(Z2vwf?}H`^-Top`CPC#Z=n1!)RzUMs zgqZW8YK^iKS|%y$&`Nyn8*ye8v?|Ox^C9%1(nruoN*_ZXD}4fe!rP}cdr?pGDfFq*XV7P)jQ>Sn z1Jx+iLbZ&TJF3j*(C3glkC`u^FGv}`lm8|3rIz;9v9ka*XnVtZl9_Dlz zhZDccNg=<-HC7t+G9&S2uq^WV`a{U?PCWoZaY}R2cR7iZgRdV}e@7{z48Y1o*=81$ z#7a4moxmfk$tW&i3YJB_gi}a)s^oJivC=3`nZdHir#uZpey8ly$%lN4u*y*!r2?x) zeYIHWT#bokk>9aAi7zii$zWOJd*U2QK=Gd85mp-cnqk$TLrgWTt;MQCan0(na?cGZ zlUS)Cp@ax4jk3)r((_n#D2`c=Wt~rY!VD!JiuW3iu*y-qgLnm&Jul=l=Tjbvdm@RI zLUGIpD~;lsWw0!YOE>xCLvf!av1~!eXD%QLipM>|vdH(#h2Q05p}4$ktQ3m-Gr~$E zKeMpvP?p(5YwNM>r6Hdg_FYa}#ILd|_(F(SC@!HhRxa{2!%Cqp=5SI*SZVcTu|mBUXi#M!x570}s~p9x zt-wknKg+Q!@_oISe8|ritTgiN!?Nl#m83**ln5)0;_@ce0=tE8U5rq7T`7x!VxSkPK zIf{Ft0;>`oW-g)UtFWy4%qI}?ef=qfd=Gxc7@@c>NvuK?A00(l#VDSGC0Hrs#|X>T zgt=>KA>?CzPRg%Bln5)0;wTv``;FFRHS}$$wfPRJ(sSsPMDSQf?YGwbjn-xFAA ztrh=SsJW@A|tAH}ATD9E=Rs|Lm2ji|+{ zQ(rxnMe%5wU!jemE@l%oLw?+`>Nba%^;i}iW}cFx_;*sGxaX5tDdc+)D~;lsWw0!Y zXNYMcCGx8qmPOvzOnE5omwK$+KSJqAtQ7JkU|Hm6)}N$A@$rxp+8TUGENep)W4?#J z*XyMppdX<48tg|_upj9c`B!^{RsQ0STwmYE8!r`DX_RH&hB8=9>TAZ@?&vq(uxY`v z>N77vi$YAZ_{W^qD2~|%t1V*h8~)GL4lDPq;7elJdd>GUC8M6^NK$5F<)Un}lyOL6 z?N}RpJFzV4Zlo?|9kf2gG#j7>joAn_sjnHb>N7tT}(E zQG9pS4J%81*;s`r&R2wW2a4-*C)PX^=bMjJiQ<%1Sg)$@HLPVQE^j&3N)$&~g;j^* zDD_wt`L+*$a;4ASzl1(ZLW9?G2krJm&x7*UhW%6vpkXo&tA~?vID~wkjerUz$^dy9 zS_F+=%S?7p9mYUorO(_mx$B`~8TU(NM~xC_y!hO6q6tt+>gi@y1WlIGuP!C!6sUA9 z_cHFkfo0GvDZzckdN%Zg&bJgw3%PGMXCUOC<-G%~loH%ifK^b9_}o*mT4=Sj{d9RQyaxJK z$KgAuPV%|7*Fz0b7x&D#5!$FFY=V&Q`OQ$1wyzo5F5~BRa&LjGwBan-2idISxLp_8 z&<1L|j`G}duy#->Rw`1jG)Pq&;3((3REg>cTbecAmopLN1<8ThS^ZL)H5T`TPvV>>%t6~4XQ)AZqvS|!T$huJ;v*o5WsyHpe}y&*xubv6 zx||e>eG!)39P*jpA>>mwLCDYfW@x*lbTgy{veMVrxo3InnP=-mPqc%w*3Hj)i4`q`-U)+v!T8lsF@qT|3HZiA!a`)cLVv{Y)wLggk0Otgz`7g zhH7~#T>uqsU{n^#x9E$Y;tlkt`$tC!l#&wM5gtJkH!u!eUgzkT1Wn$+wNz4Q3WSa{ z$L&d!2O;FkdkC7UzK1vP??Wl?x+Uxpf@P79`3O|1Da)X8X{~!oTme;XU{ubQoy4l3 z^afTp_dS~oRI`D3JzHi$EmS8lFOXj*>!GF%p?901T|(|T?QRJ9bMi-)NcVvf4dGbn2OTH*+(;h}^>1K(*+bTs0Z^{^+)PeFgB!xSln3RD z&y8OJbdi>RF*K|pw0$^KC_cA(7eS*NLZ6L+iW}(pQrVTK1R5_sw^RKD=yq-G9Z*VA zx^rp-P1cxGpi&{%hB9bY17qvX=(C}6@wqlsKnsLipDlzc8yIQ#4897gmVEAPzqBDI zg}R$A?D8IAS#)sxErT~2a$2W@uMJifir4FGtXveQOkx$PuL$c(6vw;@s~E*8OR#QH z-#D!CD2_P+>rND>ybH@Bf8NYME2Twl4Os=%NE_Vm)3p%ttN!QE>V`0P*FbfW&y7kw z)F6HBj_F3IsUaNi&CnK2xfR+jf(6liiIDP7M`fl3?0 zj4Xp@HHH~E8!DH4?mJ=?(7Z-|`+P-?xcShtjlBIi$Q`@e5AZ8!wYF$Y zW6ls1_wG@iG_nr5ca0jMCh@u6ZHBgoe5M83A+>f_5IZ3&CAi)-zjFNj z$}DhSR7*msUqc%rDE({b!3<=7WlrqBl3p^u@fFD5IEvlgXzig+ztO%qvS&wUC`*0W zQ0{Mhq4jWC`I1oKZ;XRm!HS^NZ?wVPEk#iIZnkI8-R)&K*V2=#7k6{I(A?Mr!7c{%axR&vDm5$nPOF7DB%5*F(tv z3VZ{EjxdAen7$DzmYTV>wFDY3G2LvP0Hws|j_?S&SH@`cK8)CX5NdA<o-0+mW#+?OoNpjq0Y*%0#M_df_7YQ~MAm!5!-Z_ykG`78Lj5b9~3m$~#L zg#7R5PeI7%D~Hh0X4pr>tbi8C{B&OtTnJTa=~Ym*wB4mlTQ8=zm**8m}Z<(v+t?U>6=27TnPDIN54Tmn* zlp~?S%^XMWTarakN>aKNGlJ4W?#+h`WH-}>_!-jgl>U22nS@fmQ|ovpL+RhC%N_Cs z+6-jXXPU^@L`wHga1u%h#qSwIX(1OS16d*W9*b!vWpg-gBgi&Wf;(=_ANc;@cyQ;A z2(o|Ri{D886W^a(K7S_luVT{^9ZMTFd?V$ET?PX;@80xeoTz__kvNT^d zbhxB+t9LJ`_ZDWBdn=+3l-NQW-1(><)PGAjBMg8}*+O01Dts!GyCu{!2@T#t8{Br)8%9g{zUknign2>Cm{S_t`>{5kZ6j^>xp*OGGJB1-=TLS0Siv5doN z2>B6P10jF*{TBL8XUO*uI@Y|=mV7@zsINKdY|b)2LZ~Bu=AeY1AasQ3D^EP@AmpF1 z)I-R>k@Yi#d~4T2$lpn=gOK0Bcs+!An$GgR;syx$e)$DLJK3qt;Un++wlhCb^Dv9!zoz{9Rbfe}ghLFG0FM)1W-z^aG^LiXKUShg? z?Fn0R_Cj&#t+4FvTEZRB-CKD}==O{0&wIA!bVYvEz{)~#zHBU;3O*A-R_YS(BDxLV zHuAY^+6bDwjXrblD^7t*#pj;BmO-UUov~-$~YU!{VTLK2_Zix20|%K z89`Q3x;``8@of*KN6^IWp=OhyN4AsFU7JpWO1IO#_~|lKzCElA70^7*Hy=uGrw6CW zli3VZCnY35U>4LvR%4nL?&?}X`}Tm4f2VX$Xm26+#`Qi>Yaw@a(FWRA^0}Jr2kkH9 zt^^K%4r~cW)j?2O$>)x^c2Ijs+0js!gP{)MbN8Yhp+j0|yW2zKP^eQ2)?N3nup%hcqCMY|GkB8F zu{^=b!?O2FJ~y)-fF=vMSuh29P-^C`SRR6=s_$Xw5h3>!XBt#0Dc#jm8T6>s%N^F>RbLV+{F~X6k^SI{pw!=#7a#pl`QM=p6;N8p{huoXeJwHFmma@? z>i%XHxG(V4Lsp`=r|#w->iG}(+)U1fa{u9)Z=Y2h(@CiCAD$YWbP!Yor8H#(P5dXc zZxS?Fd~T$tK&9#{gXXAjE>y0*3Ml=L_9s*)404Qq*ZFjrFXG6I=Lcb)T!X2CqZ;-Fg7eOh>=iUa5pzIi01N5u-{8;J3jDt2`Ba6<8LqP!}_0UK=wXYXOR5F2t%vad}IzmZ3Q1ax7aKQkpWTd?&}IyQ`~!Dt9uo z+^-l_PM|LVg9?A38`+MtaH3`C6z>%5y7pJ=7#6xFe?-+Omt8<$h=03bpJC z_c?zm3q26{W55lwstl&N9y9PX68cW;&b1@so0&9J5PE-_IFES zS>$UyA40LuJPRT3dv1453dQ3PVOd8K#VmkOcJD(J(I&wK>cXueu#wTz~_%3A}~iO*dR)dQdLKP!0$vaJ$_nhXCcG503Q-Wml;?49UgTBM%+puy_P zgYx%IbTwZNAzuMBOi~{DAt{GLm+wu?1Fpk25*jTua1=BKN@aX ztcB`?j=2D;hpa^TX}$E)J_(z<57d;6FA3Rw63iu8O-*Zjtpg>Yl+gOiNEt!t*5o^Q zD3pQfT5AbVgQR>(QZ_L=ytHpOgxJqR$U?~b%)ZdR!Dse^_M^O> zw@J9gx4-%ZdR`zMYvlZEbt?onR~Lf#iaR(<9G+IK*xbrLERa(z|=r4FD! z$KN2Wh0>Zb16fUJ4kYD)quR(p)b|<6|`D?YoLwl+XQWHN9oTjC+6>vRi9}OwGXA6 zgPETP2kHm)KRDE70CbxAPKR<24l$EZzNRdIZdKoH(0KJtfF^2`Nsv{a>5#C<_rxC1 zfgM8W2SLdD+CuHr*S1X5T<-g3o*lHEPUXq0O4|_YR5l_K-4zWl;~aNRE{jXosfUNqIJ;jIb>7^)ww5 zcCU`1o~@wP9YbB(KvsR`kc2HgB%~}mB(Z(YAwFdbmaS1=Eo6g_Ee<)34h=CQsPfPd zrRvbccof%V0+xMMeXkvwNWT$$87ynnXF4%kJB27oDAh?zfUNpVXMCM0!L9QVbZ=); zj+FKDKFD_F2ygBsM{yTob_wfX5*pTpls~=7F*Y1ZiO;Q{5tJ66d)AzRtV`+E-NT^6 z@VRx@bR}Qcutr8uT4>Y&%F94jC|;AhQF=EiPogBD!fvcKi5p2-1f{x#lo6B`ilacb zTR6*@?i>N#S!q9(SA}rfoeLbLm2cPLn%435s z!m{V6?_3DQ<(VO*ye#-4EQ`GFa(q{)56fPuzN?^d!DnuTkk5A;gnS#uL&*CkK*;-U zhmiN(0j1OzK~{WweMB#r9tpcok3?V7Rj4(DdYhi7)AMb5BvL5$MOYRk%wS2mFVr^p zOgjj9Uwa7oCN^J`?d2%6k0)MW}(D&$&J20h&?)a4my zf%+Cgm71>#dP{w8L&&!w4Iv*h1Jy}g+?8WJv|Y$uIkrGnedY*ieMD$&5<-3z9ta`7 zVx9rrslL0Q)DfKjcDU<0s9bz*g|2|oE~Tuk8OR<%&7N~taz}FhI}&nt46Tk#tJEZD>XD&e9)=z{GMqc6LCE)6DfFo3dkjLo&4K?% zl<5%i=bRbPY|ZyS=n2g?2SPsPTr2 z1qk`pz6c>7)5-OqBHRTehT2n5C-q4h9LTOEzfojC(=58(2B;@99 zGi23gj$*`)!sqU!lF(owmoE=W3Aua`WRD7?X?l~dx6E>P?uX9p9a=O5O7$jReB}XI zNg3aZ_F<*%LrQo3vM01xA7Y*>cN(oA)YE(?$HU%z5?LtrWnlooPVIvL0cx%)B6f2O;lk4|Pyq zM<`c)NhozRZE&k_1kI6@?mliV^oID{3ic+nOnu9twE8j-^5wk)AwOH+g;uI>6|`F0 zum-Brn$<&AQ<{HqO#h3RZltrJTp>5oNhtL%Y8D^2Q2D5Hktg{GU+ITcDKsBIsU;;#Sl9ps5nYt%DCk<^Ks+suhscd?trG z#hlP*d*mcqqeD$Ao-4M&vROIAbZ>oRLn-mOnnh5#kZWHBl+FqD%;Y3`qj((pVCDZH zBx6*7mHH|8BCIWS!M7D_ha(xkoml+o)aT<*rwLxw^d<1BCT$2&cr2Aj?Va#r6k(;> zC#ac>QG~^bIgDQ~=#=0yr$V{vOG2px<;BN4l$MmP2Q!crpIcu{Kl1g1+zFX^t&T~Qw+_AvEZbgv2a~Tu@I_du zj=>jU+2e!HoB$y|vrZ)CDZv+ErA`mN2+QUMpGiU~@x@2@F^OIk$M~K<0;{(p8I?X* z^VByVt4V#$SX0v>-@{mSjwD|_mQ|n0K=v5=C0?_RrSxN=cxFNO3dMJd(7a>A_|1o` z`pj{(;kYnjNvQBR+TflH6+x-v!iEEfn8HLRRv*b;2AE9nY~cS)TBi z6Ika@Nc1wlo&)U(A^tU-2kiwR?`w5JB89TdB}4d~QfP0XQ9~gti+Y$rkl6=9-OR=3 zL#-j?qqHF(>R}EzmwZ^((cpYi?hEawDfcHO^5tP!)WeiZ2?s#P*YiM9qPT&Rp*UZJWgWSgFW^JG5lPGl%cAb) zLCNR{kKyPY!%b?}zdyAOJ_b!%ooE%)8KgIt$*1kgw+o=wtPLLKNhWBP@%2%1k||IGA|0Xf!dy!_>cKos2$WvQttUN z)EVlkDZ4>gnlc;eePUvO=`Sh!K)ISS2^DI}B53r9iBn9mq#Og?sVVP*QYR)(H!p}U zf=W+JoM;+_%Ao0*Zw6GZ`6{4SwS?E8YV|FJwrd+&pdISl30ci&`qTFQp|$Oxto}To z=ROzA7lKwa!2_>Z^seXq2tccCB>_v_pM6AuCbboG=5R0eY@FiF_voYJu!Y z%)w7qG3(69(8+&lpQCyb@%OYRT-|(Th zMG=;Dbgmo$8}T9E6Id4cHf+L&d=Co!t|eeuH4+LciuR zd)ybNe}j!L;t962Za2L+?kt5q4?X|5tc>Xw+kPNTN`0n zN4YWwcSFdR$1R9OzAh#SCAl_rdxM&R&_Ja#pfi*PL4%aegw9kt3pz{bZ0KyI!O&o( zJgyw`sCD7fJgqnY>Y7Kb=LvO#vhwJOYu(xk_0Hpr*X9{~eW0_|H#jemLS4*cQbt%7 z`Fwd$zUC`{hG}`jp({f^a}_jNQdUTLW1#Eu=$DMpSV}6IAh?2c4&MK6JiPK9sLi z02L@*09~MTA#|bAMbJe`7ef~-T>@RAbSZSH(lBV4((ptoIh+<*>KS3BQC#Z`mK_v) z=1gdC@R>X)rM?KV!?herG_4qj3wqK@d9H9QF=$ zCWQK$JV|*LgpM}jgwBT0vF1sk!4Nvu)ClE4Lq>28*yA&3D3mWgHx32R1tVzf2{M`& zLKjKt?ksvSgnFCqlJ61-CCr&ZmqNov(7s)gZ#ZnMkG?G zr&$O^Smmg%`4FnWvgl~DO;V16&{3vG4Ri&Bd>gKW&{5`0@m&R7t+l=eD%SRuK&ZD_ z{x|i!2|_*1q&=APH$$k4xlW?o0-?TUqR==99c?OwZiP_7tP#2mLdTlcwa|D7onVd_ zCPx*7dYO~NcRPfRH6w-YfKV?pP3TSt^))XG-36tz))ADJdfxgjwa!3RN|zaF%w?p! zEX<_{T6S58vK(4{S;)5r+Ni!wkX4_#oVD)qa1>`lxtCLdJBpJ~;pN2KSH`voN=eE; z-4PDi%R}jABs7vvKx)b$udr#PziOXeh$6R(<9M=mvaGzAq)*2;E4&xV6Plu~G?Cq9xn} z-K2CgbhFYe&@D>ipmExwTcKO=o#1MH8+4n}cxb%R1ZaZR>~`pOr8}TIlbvSNB5qD%}U&r!*0os5FUtyh$AI?%8%{D0fnLx|M{| z>dQcNlQ@dqQ`>r|VG>c~iLPmctoqFT(ETB$d4PHSK%fY+4}|i}WUc@w2Wp1wWMalo z@1{Ug@VV!v=0WH|rH7!0l%_&cl^%v3R(b?_L}?l{O{o+rRVstZlpcj1ReB71Oldka zU17Q|WQ&aiv+%ET!4dY^DD}|5JJbdO~RqG)HMJG*{_K=t-rgpr@3|p>m}P zs6y#!=xL>Apl6ikLGzU6L-UoMg`QP<4th>$0klAAA+%8GdFXki7oZoEUW8s$dI@?- zX%VzYX)&}|sS>JGs)DMNUWQ&)dIfq#=~d`erPp{H=Cwe5p%Y$XWp}^Bo(T1SjbFVk zl$B@zl&2}rfrf~0xx7U>6#86!UqHx5`4U2YCVvGXpYLnPs?WR*y&h7UCD0O%ocIZH zHB_y%6k4kE2K0u~o6ws|%b;aS%c12;Z$WPL^Q6k8S&$ZU9VNG5Ws2Tca4N=@GyaTf8Gv9L8`Yq41 z`sWjKCuF}RWtNm+zJtEgHhd3#uk-`-gVK-Ck4is5KPlBgbxQS6J+*ef=a`@A!Jh+V zeoiD%{0mh-tdt}9)iT1e$p5nZ4)mF%biZiUK*;-QA*(*K7FtUQ@%d;S>ZLiw|-3wXunf3fmvYs>Zm*epr2-)?aJhK7Xp!5s$i&6vBpwtL8D*X!ms`MN5 zo6<&}=x(Iv-F-(Z2>JVfy&-h8xlLBEeKsZvQS2+iN}>1;CBkBFrjW1A#zY#$`7&7S zDHHBx%qD1)(q?G0((lmkN=;CcQggyqHiv84DrjSKxChzgnZkx zA>@yMo=_i+c{GF)W~=1;7liyB*uNp<@4)&(=m^v6D^mUkLPwjyLOBrXWlDq+5Xv@x z%Y8sU2pwyti|-idSgG|2q2nOr+jl&K2AF#Bod6+!r+*?eKvSLsA>T_UL#UhCS9a7m z1wvg-=dX!!Ds4)%e4U1!GTn6lGXkp6<4{QtsH>@>N+ph8I*5BWoA}PcCRXrz=>cckY~uU7kIIa8 zg^-V#-N>irACovRaINM%h`4I9kvk!ET#=1i&^w5JUC{)4OV$CdpP{`~B zog8PHQ=n5d^Is4O{hbDl*LG(@)tY%WbdF}83)O381B61A$Dt=QHVc}sRbGIaH1-3u zTHCFGe$vdJp*k%ZY*cJ$d z8Eu77=x-aeQ!_1uLdnG`MS@`P`(+X;( zv#D&;|WO1ne5EA0X8p|mHor&2qpozh;=UP^mI zdn>hv+AHNl`AYjh`zY-T?W?pOw4c)c(EdsXKnEywfI293ggPn}Km|&jpiW8$LI)}p zLWN45q0UNOpe{;Xp{`0rP?1ssN+@-Mx+xt59i-G9>aNrS>Y;Qnbg)uSsHajfRIF42 zl_>RsdMOaElV>Z8;b>Z^1pbf{7(RH{@4l_@2mq*6IluGA0er_>+nuXGr6 zn9=}ffYLx{pwb{{kkVjiu+k7{h|*AKsM6uk;Yvq9M<^W$9jTOpQc7tkt#lN0l+w}A z(MrRhVM@n9$0!{O9jkO4bez&~Xt>e{XoS-7(D6zqKqn}Tghncjf<`Hw2%V^O5_FPM z1yrF_2~{ec44tfW3UrFnXlS(3snDrP|APLdbQ*M;(imur(pYG$(&^CYN@qZ4D2;>0 zDUFB5E1e0QsdN@}mQoc|rBn@7E1eCUt#l4_j?%f%xk~3j=P8{Jov-wN(Elk-fF>wS zgeEFo09~MTA#|bAMbJe`7ef~-T>@RAbSZSH(j;h-QVmq2^l#|jN|!;GDP0a-u5<-- zh0>MKl}cAZS1C<~CM!*WrYKzvU9EHtbdA!r(6vg}LDwl=4_&Wx19XE@2FfT!P^2^! znyPdobfeNu&`nA=LpLkk0^Oo?D|D;UG-#UAbZENLZP0B>w?nrp-2vU9bSHGD(p}J9 zN_Usps=H&VhB9}T#orKytj0_&R2yfSI;c+R9_SvWd!c)kWg~g zrJ2x7r3ax0mHq?$N9iHxA*F|*hm{_I9#MJ}dQ|B#=rN^ws9vc7YEXI{dR*xV=n17+ z&@83d&}^kAp(mA|f}T>E1IzBkrA5#prNz)< zr4OMGl|F(#Qd$BnQCbQuRr(nESm_h!6QyO)GNt9va-~n9PnAA{K2!P}`dn!Rv_k0% z=nJJUp)ZwILMxS4L93L$g1%Dv8v0u48|WLQZ=r9MzJtC~`X2gTsS#>aYJ!@Set>>Z z`Vsn3X*IN3X$`bS=_lwXrJtdnmDWOQmDWM)lzxGJQTi47Rp~eAH>LH^dZi7}2BqJj z-<6u7W~COWMd=Ud52Zh$Kb8K1{!-cqZB+Uj`djHA=pUs`&?cqL&}OAA&=#ew&{m~w z&^D#*&~~LA&<>@Y&`u=_StXOStxd9r+3Oyj6SaZbn&dv_iDsTspCPS#e43^jwlbm<8(80_C?@nCLDnSqHeF>7rWMqR%s<-+?E>u*uZwA&v?#1gE2w?zWN$O1kj#82 zqp=9$4{dsz3uI)b4J+6tnK03AWEMb0LSG6cpphCI1yyOR8oIPivb))lqRJ!)g?2Si zM%zVDR%1EH3SD*?D{pp%c1`v)d+a4M+6~$*9*b!UwT)|`#_m3RJC`q+#DuU4NclRS!(7;yBg@F!0w|@iG*r=^ny<)gE1}ccGryOnGrutq z>SvzqL~JZHUSnrMD8$Z!sx(#&W!fh@oAYj?N(7;hITfna%sQxEdvAbVkXS33(Th;F zJ*&G?#*%{?wOtdmLC774-ytiR?l_oyC_mZBOzBG%vk$b7(!S8XO8Y_kDeVvKuXF%( zfKmsjgHlJRqf!A>pwtQKq;w#3pi&`JsMHzitkebSqSO`Ys#F9ODJ7r;=gKZC*#)K> z)J^Fi=%8ewDRHsxNjsrCv0*|Jp$odRN*4%S2wkqRDk0k-6dc?h_p-DaB z`PD#~9$W|AQ}+nU_FyNw-^s{9%OrNc%yv1{+=Kn{oKOq2Nn-A|pf*EWq~@m*+X`(H zS}(L6+Nm`ygu+UggX#TXGTkpjMNs|0)NH$ob`4NgVnsqZsPW)rS2IMY39^zoR><^B z+Mzw;*&Yt1dd5AZp~*d|;*RDN=vpB+vg@FX#v5GpXMCAJybqE)s++qB(w z$ZE_K^SW8gZgs!8(h6!*Ocl3#b}de3P@&mI%?M{v`0cRWpthQs2kjnbnmr&Cs_Y4& zutVBGDD1kuAQXQ2V{a&5+wB9P?xw?6%xGT-g`K<~BSU`F5zcnhb_Gx;>0z+6I}k$M z&8b3#5DH`I452X2E)WXsx2Y`;LSY3zfIigNN6?bu_7p>S-!gEnfrzaiA! zydjzYKwC9)8-&6YWjlmIl^qZ&G~Y|RosiX-DdE+=B)+~>Lbina)_vrcg-kD~m(n57 zAxgcW-b#I-K1zL)c9*{N=dPcvArxx1fp+c7y|??l^xdGg8q0&)_vM^+uVeX8Vc&Qc zbcRr<*#$!3y4w{h(#!;8wWc`~IyCOxltQISW%OP~&D-Sn74x9og|6HW+M_I)L4{@u zYZl@77$}alBZj(~Ix-_1AK=8X{UFrE93fTqhdOGk076~O{$Fz(I#C66HRVu*<3k#% zR7&i?vSb$d%p8u7Y~olUghG|hR6&L2BB_GoW0^RXph~gCZY7q(H9C46YQph>N{E>f zXrRy;GU`DP3Ue3?r6lH7DGd#m9^RC3j)0Dr*w;cQKqECa3PK@vB7{P_lc0(+j^E$X zt`a&~GB1_ma0-O@70PdjjfT*EX2=7?PKCymv9J3}Y&>+fW}X9~5IYw_q2_rI3bFH{ z3EFNVG($4SNX`4ol0~SixeQ9+cyt)Y9)unflJzw85GpjXo~8kMT-w!1l_wz7)jTgW z3wlOl&qBx_G4mY9@A)_u;dsy%x0?r{P;-7+GKC7vQfj7gJZOt!FA_r`hRdKrvxdwF zmqlI8L{>0|Wn?driTpSt91rN?nlD4hS23?Z$nSacD(#T(FT(M-E>tlKh-Kp{ z5srsxA=BiDA-}UC9FNxMq06_7YAvh=l$pG>BM)%e)(BM!3uenu*IS(ikp_ zd=EJskJhO2yzJ}65b`z6ha73-`%B$78q9&MbvcI9EQV z_fO(j4##7+(9SG_knhhdrzQ$Daj8$^DrsB>g}LJzHP(dV0bPih&*%?@+2VLW7spmW zsH-_l&Z;jU6vp`_gu=?NEKBxAzP~=WQsgtsa4C(YaT!N$C2;k~XExw?%oq2*icC~! zM$4KBeHCX$I3D`Ny?+gTBP7S&d<%USXPWPsUt^q^!)2O;+@8SIJCb${I3DwbnE3%h zq2`Ye3demlHIa|ya6I&b+#X!RS|gv?gyRulykE4A5i2nQSAn{j+n`EZ74q$>ahbp4%m~LLxsYl8VbohC_B8EsxZhA$^FFj5$78n; zGut2=$BaqxijX9>noN^`N`+?2T9-isg+>bvf>IhwL&JsKnKJ@H;aYw?RFTxHcQTo( zl$bkna2AD?I2l6WIGmD9W>7eOIP1u*{AlPjZ8wHY6f$uZg_>iDA^+MD;VddJJ7s>S zGZy4uWzx7R}oy-VzGt-Hs zarUmbrnwucjbo;cn)gdgj%|dqD6Go^#Aa#?XHkLquk`mIG1S$(14TINNM0?>e~2N! z>mr;*VPp>_la0vl!6uwVg{DzzJ`6n)w=<7IkHwj$o|-7s#98F4m=c4jez!u`aD)I|QCA;MV{X8RHQ8u?fbXC1jafhDv<;T0Wcm&KW8IY%1#^=!h~ zmC~OaBeRNj$j>jr*{?PB4TN}?C41>x2!(dv(GC@wHpC*Fb##&(>F=RN&1{0!3Y{ob z)+LiWw#5DI#98F;ZOm3^TO2dnAuBQ2*T$6dE~Gqu_S**9vpoJhpdExdo5xysKDZau zUg%JveCP;mcO;Z5XO-M@_B50!mmap089}w0SqEjy<0o`EXsxunU7psjgMJfwN&c6p z^-yzpvdH}L7~^b#tY(^iT)q48j%d(*#1hb9{dlK%fvnO1XrRUhK}YoC8N@yEjPpnc z^)&ZNW(q=~N*WreRYpN$G;=I;iDq63Wwd4l&69QurRIFdY8BHT>d*ak*{Q_%aV@^- zq%;5;pfnH~s5A%~6t9vQ3=LKq0u50b3Jp~{96DU-2;n34^{33`K)$XR)#YR9QAU~Glq2rZKfKGt?$VNgVH8u(w1^IEF2%V^zCqXAE zRV3|S6+$wHjnGaZ*B>w7d_zhzD;Z}cbn3-2PKX!rxQCOWlOaF%Q`pz1Kqtr?au6@# zacnd+8uIIMDzCSvLT)9hp^T8L5vhagU(YrA39&@|DgXLunz-2lzfDzl+EnmHGm=VCIl z`Os@Rqt_u6s=NVZHI{?kl`6+dyZ4|)GU^{?oQt6)((ZMMErph8=5lDIR#^o#YVS?Z zTJ3Kggu+Vv0{y1#)Ww{4RhhE{oe$LRA{8h9+MYk9rD}k(gVF2)apQH$z#C<)B3xTMRWy%=O*` zS?$kU&d4sO2e&^H5DI&+8-&9CJP1Nz)ZHNzj#v-qV2PE;G3p5wYt0e}h27c-!P162qpldaD9hA{_5j0IQ-M*d<&D7o>gzB~T1}H1!t_(S7 znYLRFeXg+;&`KTWDyUI=Z-UlJrrR&;AS+eewZL4#DqRsDl{7T`ia0g`8YwY%HjaWS zu86BtLZgM;-aQo>Bjk40SZLf8@$MN9ovE2;LFZ|I=R+4s%qfF2W2GFU6UiI_R4ri)?Jx=>s}$bvcHYXBEM!iT%#j7(oMM3 zj5srm%OGDf!dZ=(`=FUR&Ih4-A$L?7pgD05W-c^Os<`>hhZbDP{q>D9zlBg%XOx59 zm3D5wyhrbg;yFY(`;lb2-LnMxSTf!I`~+I&`jeW=q0i$vm=(}B8v7RdUe~h``c12> zhc-zM?uc!MtXAP+0^dlEcb1vVHEVJ_qXbkuIUZRFgu)f97lgt%4}nmK^@dRBuMdR6 zj_f-*nL+-F65%Wgt9vMf!VW2gP^ei3rKG0Yk!h$(#_9G%HB_&e4bUv@eKz!*#-4{z z7|T2eg`+YbTA=L~LT_m1n^0EzbLVXiTIR+n`*S(;xwLb~Zw0hcN45%T)b(tFHt1M> zhfwIf8QP?=&5+d?k9hcII4e;xn!8_fHFUMoHQWzg0}VKXSOHWjG(+x9%b=9d-PKSU z8m^fmpp1~K89`Z%j<~@}S){wg-fg<}%6L6Y8#+J)mNtSrRLOdJDP!`amf3*B3&e zzeAx?%`AiZU6<@{R!Eip&>(F$7($`l5NN1S-VbCR4jrNGj)YR0nT9gg@ve2SWJXZE z#N5alAgeKRJ?nCP9BTvZF635e52(Gy@}Ye+wl9Q2&HW%0#<@RKczryh&QOuY5>T;L zDS=8gRtEJGa`WpC9VT>+%wYgDP{_?;5R}p$($H{;xlxaRs;-acUJW%$%pK__$ZE{o z0Nuc+vgcpLd1NwBhVRX||KHw3d=Dbxtm-ZAY+6Amd;@bAsI|u0Ky4%XyZZ;KAJ29V5QExk;Xm6hK8nch*4(D5W*iP*uc}pcdk$F<8WFb^!I$lVX9MmYsp^yAlSrfE6;+qfumAS8h zewUi7gqk5#YIgY%YJsfA%+#dKo66J4q{McI_MXc3Mo$rH4-M4VAZX}Ro{{~Z#14m0 zXLF;_5m0I>BYR9J4GojnTSCV`$7yUhG+Ap-fijZ$m1IWHRH46xZiJ=@?Xw!14&5Qt zSLjY?rqJ<14?^`CYk(ft9-e?+kl6W>`686nSPuG1V_!qfLT)TAkkw3cBXlF*!@ck> zs+gOgo0M*bZsv*Lq<=UL<`!mjOH2_|D|CYVK3^R)>lXIlPeQYyCne^7yYDGzj>hIf zS)r*i>KtTm;jFs&PwC-S&W>ASNZilQ?@vAhA zE9SJA_Meu_beISi92 zy>u6Zx|%nH?w+1Z)y6f`xC{#Ia8_fc4nm>IJrF81UrCjFp&4;Ib01ZZA7_NKD2(%d z2!-AsfF9M@W6AxAW?IJDJnv*zN+gy`2?2P-1z|ez)_w(s2#r+#l+AJKy&{Qm6nb)>sKN zK-&$3QW{G`!?cHEpkpP|N`J>e!zETDHAg@rZ|9Zdbm@H*#K)b5<_?MRai=}`c4qsO z&?(Spt$8XmPTP%#&XmmWq{>+kACrdpRYQDC8hYSkQae>+H$pcHxv|^=p|0jsIUjC? zQ0RRc^sr_=0-+Fl6naW)&bd9AK|U7YEDB?p3(eCi^C1*Q_5$>ZX1)r&rm@$d#<-?w zf>!HMSp)s0E58v!;fVbWS&i|bur0bH-USJWkBEDk%Vf6ZPUueP(6Owwxr?j*T``qG zDWTP;5lcfeHTEEs)mRStOz6Bb$^0DRgXYkV51MVo-Q1tKPrWK3dpGw|{_ibnsZtwL z4r;9B3g*6P-vq6$WkCC^l&URye>JwwEBsfBcKx` zw(H%*MnWfPW(9;otg?=82Gk|}uOme`i$d&d2n{v^rRF)%#nMBK&?OKGu}dKoVw0$e zLQR}SAyxxjCOteQRW656h+P4pex_b#bR{%dVqZz-6sTS^8=$Oa=AcHc*#x1`?gt1B zFztV)%8w8VJ*+3=a%VuA81m;)E1X3G%m%k7pkv~gIriRUcBI7Q?9bt>BbPY} zLg9=%5kiAa_jMfilb}zUBgaV)~wXSCh35E^X8%j!M{q0ry+5DGJz zM-}8_5zeA8hxx>iU!@3_eIbtJa2AERzepwunL;mXO`Lr-Zf6!i@5h z#~o);81+&Jg^_(sCJO!GY@^1SSWnc?+|S%w;WCco)hWVR6sr6{42Ax17KPZ4&~Nct zoAqR(&>zmCF!v3_kl%F?&Y}?eo!CZghs*pE_YmQ%Blo(p2|{6CZ-%yM@7p01UQ>5K zD7-fAglt@s|EiPg{ET=Gi5W?Q!b;$5O2}W?iKT^PU0UI6mA0!UhT>~EvFo(m^)r$g z6xIx9QK)$Xgu*o|1ECO$pn4&9MQNZ16nemAkk5>8c7EK0d4X6~V>pY#>gJ$#XT<09 zdk_jM_&$WfntcGFaP}{vCi0mP&N_0}p2ZLfqyCW0PvdqG&Y}Tkv7A+(K`0!D&mk0g zUjeNYay3^$U&U3-*AVJwc1o3RAQZ;=Erh~2zoU2Lt3)`9LhO5}QTuCxnsr@TX!ln< zvIu8UShJ0+JPOAhXC1kt@;5Q$duWB@mp|h9nfrKsxDRq?70y-(x%;|mVkpFLHgjLx zUqlS~>wGJmt(Q!9|I$DVg%~b_{GNz}7RFT~oJHZz<249{8NCj@AvN6`-h{r_SR;hO zj%=bo6#B#2bsGBxLZRlb5c03g<~In19@aw}wB7Fz3Nvbk{?gb+Xs6b+5DH^4_p^fc z$49gJ{$%Eh`-#bZiEtK$)%}tf3Nf5@nspEgYyAuK zhsOSdPsCriD;gGxGqu^??v;g|n?5U`Fn|-396^F}HUQeIS`Z zVU=*UR4Dli=VlqyPc!?IiOS3bViC@waQ|``G(a;4k~!poFuw?AQ8?~Hp~E%v2ndDa za3t+eI0JANg&tB+mA0#9ekh#9xD4|1i*ObVHZx@oXG17lU(O*Dg*o6X3fHW2Arw~W zya$qv$Y(a;EDBZ5rwa1NFT&aX({>Y}3pDdWGLgRziEtK$Di;w$NwZwm6K9d{&sT=77%3i((yE`!3{aaLpIN(hDfrK`w9p?91` zVU;Emn-bTIaQ12)%QeJMI2(nojcZ0Yi^A$&2cd9muO}0Q^~Bj5;@(Y$7z*>lSrm?K z#4bQ#7vT8)CCFV{%}hQcni-#CZDuAj`_2r#M>vZHn^wQFL-r%qaVG8Lyp3=cg>e=@ zowUk<5DKwE+9l#xgv%VHRd8A4XOzQPG}sK0{<@RdM>BD@uf*iNs5z8adE73-SrlUZ zh@r4bIEx0D%cW+2Vkpc3XHn?yFk%C>9nPXqa}YE*ZfAy&iF~^VXOZvE44ugt7iYG@ z*^H1|>j=uujIVV$s8Ls`39=gFYybQX7SFo=E2HL%{`?M>(nHWgN)JO1D?P%|e1xYl zOQl^KsQn{+TDDdwAKLGc_<7I%&;gI|xl?&9&n`MZ9fjO4uoOT=nwfw~AK}T+4;iYI zLH!=#iP8?Yp3p$)Ay2+JJqSu^l{9q1Bk~;r`G2-XLa4+XD>UknWCr=JWE21AhQv zD<0+PT_2f4C4@SeJ%3}iCqrW%cak|4LPe&B#7>7$h@AnQDeZ?{a%Hsgh= zAgh_?F=q4_Pw$4_N$fso#7y7T#)6mmO&p^*8Jqtan^c?h@((};sO7oz3O7o%loSWy% z@9CHqpcj;0WS_mr>q_sFS(ga1FA{UVyklO1UQ&7)dRgfe=oO_`p;wg_Knoz>-$H1i z(reIbO0Pq&E4=}|0r}tdc@uh5V{dU*y#=|ErJ;P_(Pcz?z-c@=JdJpn7--q6Z{2V@jK2TZ& zErR^IEao0%F=xlk@_BeQG-)x{rLkL~8VDU^{u284;$&vB#F9{ivnaHi0@Z4}Ix>-O z7vb!qaXa%EG30xQaQ1PjGHx3^JOQE5-z*4)vCM|v(kgF5D8#Z5@*^`jdO)4bp>4Qp z##t1``3^BuWFDu#2xl8L)&yUfvVv`(wwjp-?I~4u zO1pLt3YmL-n9LwwGs30X$FVf73i)=`IIA&}Pdn7v%#&S!vnY&ZAL!V)ra2Bmp}*nK z2%XXK5DM*1fKVrM;CjY65;{p^74(NXn|{P1oJFBsC4@q|lgS*TBg0wL%~VLuvCuee zHy%RWOpV0OgplvSoCP62KT`#v&|fulxz6tj`a^z|BAi9x7+ndWaQv=qsuJ>78>m`) zI2%Ht<~h)LTIGD`B8^=PMH-t5p|H9)GEU^@7vU@l$KfUjh0L2F6jtIEXr{&-xguVu!W zW+_*qrF^29mA{N?wKQ4OdMS6fQ=tUT?yj*tmhyfjjzu_|(pVZgb1C?c5Bz6HbDUO*MD6^Eir(PR~MNsWh?w%@y>L9EA znUA@X`#2u;DyZ>e+MQg&onsTU^JCggk#-hB`Q~vU^9iH=B+SpGp{h^fD%H@XpTzAZ zeZuFIaV)}F6l&H$8L9Gu8z*G7rdh_>xGe4=0in>Y8-&7G4uVjDSs_)rLny3S52#qH zlt8H1WG*DL7lcBUL!jQvxCh(cF!w%C>9Tlr%b?V3xTMCpd!aax!;0M~=#;yaIj7 z?pZG@kq@OlWzCLtpVUK{Ps!Zvd}#-*6>{y?K~|{k?^5M6GCza*3Pq3=Y8)l8&xw5= zQv_L|haQ&L3a11k~#b&iAD|$UFo>J?ObJXTF5KWSoO; zl5wttRw}K6Rw;c2eWmm@^tIAA9I!iQ$ zn9+B{&U_k5Kq;Y1_GV;hC?mA%iNqpk>UYfHYN>J~bklcKxvM>yH$$kWnJKYbAQWP^ zLenHRUoxjdcL}{EnRi20Gx})5P_xiEqWb|8cq% zgu?$#y$gi=|2S=0Ln!>e&TXJwHFGzpt!Cyydp2>!bpKa$J7_P>+#71&lnnnLbUw77 zWV-(udw&Rp|I_*a2!&V&2!*k9gisiD0n|xj2R0=$h0<;{>k{FtBiBP`VguvM2xn0k z=OAck6Yu-wf4??|L#a5^q@j%V7eTk_jBbZs*D7y7D2($>`g=QGvj~?(envT*MPaS8 z5DKfCgRJ&vet>?6XKQ|BEI-B+LH5UZmCR~rbsRHm_%_oTo{qR*q@4z#@T;@aq1rWE z6?@6=jn+Z+5_7*++W?^gvuiW4$01Z;x(Gc1J+p=$4ikD7nx{R?hhEamm!Sn~I5+<# znG2z}B<6ne_H8IDR3ot*^sQF;4nhTHy2QSR8a39mCRz1EJnCxPWaLLZ1(*3Tjzze( ztK)Wgxc11;HXm1ne7girz0B z+h=j}G&Uc%_Sd-GI$X0O$!x)0+Z@NP!)1`~FTz=knHI>#F=Kv$eu_tCe&$N_b4&@S z_-Dr1AnREIS&f;seB*O%oOvgN!ph$Tp)lLKq1v^an+v5%-CEw!O6HePgtI7Q-UFd< zr0*pY`ClTAa9I>q7q`sCey7TET(ibnaMs0kYoWgx&~w_u^AHL>%!6Lm*eehUb9fb6 zAk6j9A-|sHBWQ_M zSqlB4nZH6CB<9ZW-ysx!NxGSJLB5AHuF;YF%5)RXqR?Lp^p|8#l~Hdbb88%H!dX;c z{v)w%%oc^&;)+p$d4X67u2f@XxRi^@{L;ALj$~(zz*Qh$r4l#B#UyhquF8?js2Vo` z`OJy9NiOyw{ng+mJCd4HaMK(~mFYN(!u+;FC|s9zFf!!VERD+`pBdpAT}-ZEO}Mp= zexSc~IIA(UlXi$I#3Ec4bv66^!MBTWR%3=AE8+Lx$&_)*50>zI@LXxXl;6!Zzd*kz z{R;gGEtO}#<~Qg!rS-gWt&i_;(@;jpedjWQ9$ill?)#XJLG|mo&l)U$1=9d!HI{=G zt>?aHmHeg4VrbcVzE^pm{Ibb%==1gQll2wQ%JtmWxxWBf1vN^|Ez+(DYF^KIBL4OF@CTIP^|QX6<57YU`I+ckCvw0r|qDkSzP^qIy!hpfiT@6`OA zn*MKopl-kOTI`0!lTr5--SiSkq9Z znRf1%BO)j(vd!`iWitJl7N{l8G=Ff_|0AY5q1rzf^*OQz#LS=2pYh6@zxbB( zUoowLn*Sox&8P+1q_NGAmDpYKp37{6HpXY1`5XG1JHSTye+SGz?1_J<>Cbnl@gLrS zy(90InxG&4p~_{mpdX>t|8T~+|IK&}^pnPZhSvTQUm4axzi8}N=(m5^$$vjfmGuw` z@9{Q3Rx`~e?)5jZcT2g}nS97@qW6DKldIe2WOn%G@WdsDOH~WWbD}h^%8@*`sm5hA z775)Ew~KIDdq8p}Z_Jc)V-TBfn(5DMe` z6#7RqH*HQ9ZH+S%xD@JODp>h6&TfxmW(S1gCtqaRIF`m`9m&e)aQu%Q;iwq1g-_16 z#827^p)OnEadw5eY3!gae2yN+BAhJ|a?b#JL47szPzV*9wz8h35DL%o%AnzK6*B@F zsZ~Zn6&kCAsbdQkx1mIq%UNajQ^%HT;2xn1v9ytp_;py^hGN05; zoPA2G%z>VXW9C_C-j;Z+=R;YI<)B8X={~7w+LFv7{A)tZiTjK=5~l5aSOfk zBW@P?^W9hog>mweZv2ljyi&TSRAw8rO=&x{J-+Um9UPS%T&0f4W9~WV(;ZyTj!$rv z`V4B^LAzGnpeD#_%PICfCdUZEpr$IS_8^2hm{ZOqwtt)Qwy2w_ zeJIc5;pU)D<^*UiZl1>G;}&RaAufx0m?H-9%hk9=$X8j6v&dI52S6zF-T^{|rusvA z?+BqV&H|`NGZRp^xCe6()VED}S5riPe9&HQk?-AF5DHa}fT}cB4V|mwJP$&l%J~oq{rw+=LX`;+3Yin3$y#L!bhXa*8VH4U*FraF zECZpiN)d!Y=2QrU-fx6%(#)G76#h@^TObsw+zKIo#LP4Zg(Ef{s?}H>RIfcWK=ZW9 zd{D7TV7y3k!$2ZQ1`aw`-9t@!lW{bpnLdBBV{!gd` z>LpbU5;_FxEtx}v`apf#va>3M4uwkFmUl512$exe?V%h(-OQ~L>j$Awvp0ox;gUICXaqE}ZFzT7FEk3O(79JaqlHSOhf|?(T5~*9 z)waCU+;|VMYG{(IQuAo4)IcbV<=+tMWKNPQmq94RE{9NvT>+sm>MJ1>R^lpXvi2|q zdS84009vFq7egOvyN{qH8e0l|tnEI5KGpes27Ruv70_yJw+8x2*X3vE56%1&`dj<^ z2il}HH$z+Gh)s~WZ-ur=O?Nc6Lp$_n?u1ZSPYa;}bG@`Pc^u6=j>G*zX(*G&5qm=@ zf@~gZ_Pxv8oy^_IoHmK$kcKk5bLDHhQ8J-vyOVja&~zxfJ4fs}$;?4k+MOgZvj^?= zfX?>Wfs5!W<(lDQ|bD_jpyR>)^U{A4np9=n?D$ZW^lb1oCgwj=hPP!8hf zluJ$PzoqxRn8RMg{2ZXnUc54wODux2d&RTOL3S_BoYW*~w>PwR+=FQkwU5Wbk23TB zxzXrl0k(zRcWbTFPr9ZiQG7S*wWWJQUuE!zN&CHRfI8Q)xG&UESrz4vWWo0b+ z$H@+Xmc_lB<a+;CuTY@hYqy6?KCKYvK`o2C(4e@K~`dyyS>|yUCelb6Z>qj+)tE1y@d)!Kz*PJja5Qbo#J-Y zP`ze0K#MxDg1u#R7eiJv&4JK?aXV8;yTX_nAX`Yg@$#O_bf$;S@ttusl^r&Q>cown8pn8e9^PvGkp~~YB>SP|4yO}4TS(54Y z?ri8up?ybD4cK3bsHf zeA@B{ghK33=&z#kz0DHI+z9=xRsMlc7gIEZ-MR@vp@+?o)tV;3S|{S8l7LDRTzg)> zgLY+5y~Y|Kt1;7!i{r7F5;9BTy_A4#2_ti_2&NaYUh%pl zAlr*}?!Blvq}(2ONKA!?l&6q?U!2C-B8?@W)FJfl*AuFenA_LYQ09=hhX~3_%pK_* zWQE*&d()fD-jH8G$O`$f^nv>5sQZ%HH>L=(edBiKQ0P#|osB%9vGt`fH9)pB9*Zf1 z%HnH}NkU1ba;TghE_2tqeo#LhOMiOr54qzWLAF10cUNI^7<3phHwQC-%mHz`2x=5^ zBWr@12gG}~1=^&s&5+fY8OT}>jK`9Iss_gWRYP{5&S((*4T_I+1X&^XG{y|3zrk^= z1+s(VtG5}#^Q9r2W2aopZfysl?q<-x+1GnPsN6i8A+|TP&k$a#Y9w>tA?5X`r#a`< zJkx-igZP!BTX`CTo9AMZIUlzG`5qSHmLcE6a@^+{TY+1Pd=Kkz&B$lA;Ou^J6|?`4 z@)YvDr*TPS|%4A&3& z%>KBc$oFtKE`@x%G_DHy{;F{kkY9<3xQmc)cQI}f>S<)nYH*jk*ryBg%oVshkgswl zZXWVI%*VZ3Oyeq$uTqH{t+7*a<1{uN zSA~4dYFxd>8gNlUYXOW++={%&oE%N>4;Vkm)Oc$sqj+q41O=#{E_WVH* z3RSv8sHfRS&W9dD${#L`Gatdd2iW8R}^Ua*i#>HM^Mf*MhT- zq>3qn2FfmY`Fdy&a~Lf2@hdzH#aUEnI{m||?+^(2m>CKkp|K;Ol*ZERUDV6WU>6kN z3K8GAq`%I%BGk={qK5>o)Y0Wvvmc?#Ne(aPK;j z`Mrl*g#3&a)H#oB$!;pBV|E zu!5r)3-VPWoJFDCiL58`u{_+Kj$~ch;o75KrawC?A6Mi^j!^6*`t_t~GSB;y5e3cs9 z<;b_Y0(UL)t8^VMgTisZ-GqF*n{kcEXEx!i#!SVK^47?=YlGVp`T4cOrCdyoSQ-cM$-oQIdsMqA>Vrf*WJZrC3@hBHCBS_ zjeM0pxKiY+l;JYSAHN9qhQ{8+WgSV)9L^%&pE;G|FeYv{2v>!C&1&4`8oL5FSz}Xh z^%`ry6_1VAtOS=uzK0yH(UGiR6E1Ul+(U%3D4a)UuoB4kp1_qNU!@F}a^#K!ZaDIp zBXAWiCcCu~SEaFP+yqB5_ldYkDC{L%2Kjv!;jG5YIL=$-d&tAJM}92%xQ-etz!f3C z3lg~Q8tZ{8b|k%*;8MuXHjNvO{B?H(ZluOW;VO{dJ(ajIE++FEiyMdh$j0NWi^;l} z@euMcb0&nst~(1teqWm^2!&WRguX>iaKn+WIRckK zzDk6vcO>l^aEp+?GAza|Lw+Te<2Gq*GtQnDS25>PbAr64?vU#|ZWiikHt||D8#f2} znsaf_AV1D$aarWs<#5);G#?2)a?)O_RHln;_K7bdguzn;{fp zw?L?mX~V0ixs@x}G@&zjC250ei@KVxh~?qhJL-009>4C)Y*8<>njR83i$cxm&}~C_ zmUo);emjKxot(LY9#9{1&w@PD2Gy&bMS>S-#;%*PdLtORH8jN6&J zAjB{Kb0==@hH5od2R$TJE|YnC$;goJ zJ&mh!Bt2B)GRV&%!qp;Qr4Ba_`LWE$Wl>kN$4%@I+%n`dm*Xt*ndT`7h4Xd}z0Zx? zrEyv0+vRY}kiQBq$9;}^nglDj0=E)%H8R^(xWACExe>R?#bhj-an_Mrm;XX2ti;n0 z3L|@ly^H)xF>!ezs$ARmfMV#+{4&IM2gPKz@D`ag!X$ zKC8jiBi~;GZk~(Dai5Q~&&4tGJi7q-DiLl5@;%&#o9SXw^Fds_i^(x+z-1lD8IZ#* zLw=mgaVuR+GFRcgL4HQx;u;;vSekIn$gf}vZWHoVHsiJ-KeFvOi~OE2^EfKVXO`iH zBAS{;-xq< zhih~sujoxUi$dnh5DIg61wwu=nO7kcVhf+D_RpKj&jIwD`QK>ppuv#a*S2!3{@#hm62gIFhGumAEm;k8CV%9P)D?kE=rdwYVBL z0r{E}ag&f=vl?6m`Ef?LTI8$L;byp)to41kdW|*UW+7i?Hf|2`J

    +Nn;38BTMBbl52N{qiCfdo@B8by8(4^Se!1t{%|O0VP-|=>2>d zw#_qpKffJ&KbmGAumY%G5!(#e78ZME{h~4UEca);D{0x!BLJHQ{U41pSRAq_Pql0_ z*iUuQ3N!qn9g%(Wy@)leSwH)(dm}a6qA_%*&yYyIpesS@ne_gZCl2u@=iURltYNj^ zm$t0K8oaWWjo4twhOlAK@r_{XA=|)WNbZIn`&xY|sAUn+Nq_n{Nes3tHJbEdKsN8r1I% zR`ASP7O~-fkgjC}YiUd8YsH3tDD5|b-Gt=c3^oeMeN${4G{Xe;5_C+reHe18SMYE`io^85;nt^9nZZm7SFd>~6^JVfR6! zJiw+wqs(9rLC5e28$6wkIfM;^`i)?@kma#iuWY_K>}klJVT+&{mayfJtzfHO+0m|H zamb<{;o1eww;THubbR};+K|;@2RyS;4q`XGvOT+nje_plj$yY!W8T5;g2udu4gYAm z&Jk=Bv=d`kTPAJUj&*ot^L1ifpfS6#Vb5o{cE>=RfFY8hpDt^@tOr!uViip^j@ zhu2`cU{Cbt{9L>nI|OQZ7;6EwY{lBVnxSPo))BH!>io_yeAsqD`tIR=pfD>{HO4(tTJQvS@{4_zRoeekZgQYxByUU)r$_&~JKnVkcs2 zY1v7v&nxTKk7YnDv)Dk$u3*DnS-%l%K4c5nV#t=TmmzzFErZ5f!QzlbQK5cEWr4NW z`|F)pE$IKvt;3o^){M1){$fZgw*J@Cej8W;)USwTzCCT3#p3F;WmH48r@&e9-xu9W zRa;=^{b!51Lj6aePpC?;$MrU*eR}l-+x#rewy;Zw((E!8H>6qA$XdSOIQ(}5i&*TH z|DC;`D%6*Pep9dvtM#zf$-zhwZ9SYfDtidZA zr4c(4vZGiN=x2*&tOYbmD;8f!R~=nsCqU~g!AiZd*~_qUP`?UnPssLSpL+G4Rqw+Z zyz;eRM?imz<|x(_vSzIME9vYt*e6|w#Xb$$J~OX;U$6s^>7Qv}F(jX9{5)fVvNCKN=n=jh zJ9ItG4r3i&*?gT?SID}tbD+;q&SUW}gc*L3Jp;`!gk1;Ceghi@eQGj-je?G742vJ8 z{h~)aPy9yOFNCuLYUB=?@MmmzzFEr)CcTZQD#EVd51Gqhp<{+sCxyRki>J5hVF zPeC*6!x}*S8nGr&%Vw-4WUW{m=)O%mb_z7gY3vMWl(Se@$hxr}&+IN$FO~t#kj1Wg zX2*UF8wAZTgbjmc7{LmlJu6~Ozm?9{jJ1HqY{kxn>^#;38nYMc3t2yw@yb?~#d@Bk zWA#z3yohB$E6ZX7pc$@Uqo8|TW7s&TIr<4A~YIgJzFrX$cyo0*n9ebd=~RV}g#a466p6+Zt?F$aZ6ULbew>;FVo3 z2eCt-84hDdLUt5u0-dpD>=_ilyNG2V zd5mELpi!=1S3xVgh7EdUvkzgjA)CYIL$-h|f<{@wmLa(YvDc97C$<{0HEca(8(6_B zo3Ds%g7$g~i(y}6ch;l-Sg8LPH0CE*C2004Y=>ty%1*4>D?3LuSS@H}b=U#VtmQ#$ z)+=i{hrI-i`3hSG?dJ+s`Mc@tRamuGHcAb4AY=!zL!eO(V-28D8nM^&=_qfo>IJKO z6m0bO(rgU74I1SRHtw0VoWLG?WvhOI&4Ol_!{$S_fGvZbdseXZkZoWE&U0FBv+ErWJ< z1>5!)X|^3J2W1u54zKKpc4F0_wbWp>ptDkk&By7=7OLRZo$1 z`uC7}seaPNd~bgl>K{{mUaUVD{U-0kqc4i}AHlw8yL~$hEA`6$7tb*-u|1%cd$CVJpR(=44uSrn+hME$^m9ie)&d%{ z6+7XXeI9obYXjX2YR5V}JIwKQVrM`<6`aLJJ+q@7!zR75ou9()gIYeoraimB_pD~H z9Y2+>Y$sL?T3HRY3pD#~Y!4)NzOYZdvQH8BVYQ&WuEX|wWixz+9rDUneHc6Dl^x%4 zti>x^XDfCBw1X$HHqUI7cI*^rKTl&Fpq8E38PH#>JBxLJj+2U^*Atj{Z(y&t;( zYIzaMfX2*Xmq2IbGByC(mn+y+uWZ%Vu)&ZGVK2S1^Y98=2F;X0n8gmAF2x|EV%YmK;^Vnn1?moeudS){`!{$S_fISDT3486C-SK>bt%qy_i#_x2UHlWq1kE>xJq6A1414aGwS0jshinBa zgsf=){xfO6I_wbWHQ+GT;+3tX6>9^{(2jM4tP>mc%H|ux=0PnNu+@;QVGS43RX1Ys zSJL^S3_A$P4r1Ftqin}^cxE%~#NK*kD|?5nhin6T59;>;i#_u@$XTjfcr@nO`DLr@ ze)$si5_H~QVR6W!6;=j%1}npwK-WYw7RPB8MPKLp$zM;#EW^q{p9WT7ABXG{tkNs{ z6r&2;;gx-+xD%@e^{c_2hwKIRGGwo?<&dplt6td=tzmCLqrAh`L$-l^{7qkHukHIN zpJ0{WVym{Rt)&iY1I^HmJrCInY%yd@*mB5Lu(yyLJGLIO4Xogm&0fSdL$-y*UfJ$BSC}P_h!VFkBXk`^xHE6yX>=bA%r?HNZ zbz(hU*?hfNUg3n9CRWk54zu|d#UhOkl4d}G+SS2pGZmJ3-Pn+1(Bhdl+Y>=`y6 zvIT4rl2<2e8PswGD}=0w#a`J}7d5i7Q#ONr4{!(@@34OMu6Y8BA^EGNo$LhYH!M1_ zfgiWb{xazm>?$a`h7E##`(g;Y32J!@8wa(V!1~UlSwEKX%6=~-iw%VA3N{GInAmmC ztmO@?@Du4MMJ)EpvZ#wHlV(wtYS1cwPc+0M9R5aRzn5Hs#iLgF&zoaZzh;$xQ}ow) z%>jK{U4{+*Mw$&_H$mAgY&>KW*d*wa>?thHrTwBjv;U?Yk^LM{f|Ww@DH~Slm3`7w zg%xJgtcX?rVcM?-+XebWY&SOOm3<~PgxwC=9c(;g6WD#w><_SM&-{}uY}P9qWe%GM zwOqg!L$-u1du9Dru-BlLZ?NkBlCHW2YXW7>Sc_-YuN7^^9n53t3M zEn&-`t7ir4`J;55y;vWpUq6=d%x1`9^Iq9bEMS$zv}F}m3p&$v*nY39%_W1GjwBpA?wF3fR67XmI2+N%VGndepj%o zpku#=4SHoO8^T6GEyu9ip4lQVJoXqg z$`foBlKsS=UdKG+!0A1GLVa zST$&r8vFN<)nWTVV}6D;d1YfZV=bT~YQ;`~W;ltpfyQjdIzY$QiS>A8$JdK}9sHwSFrnD*$fY`17AwVJcu0vwLFY9cx5dcu_GZn ziZz9-89N4A*>S7|w3b%v1ZbB|VjZBBbz*~u()ot4QLk*iF>E|!6WAoE-xPK~WDl@u z(Cjl<4m5inTLc}^67~|*@)fr1na#I?Eq*z)#9l(u5?cn%zJk34jrk5+2esV5;*doR zT!WB2apzysxu+OVo2UqX1SJbT4mq=Jcu=b zzW>>XHG%H4H)AJ2El*-ypqAZO?pB)Ru>-%9_B)7GkEU4-whPp6H`WlcMyv^v`LJW4 zmdCMG(Clm2ddN1gIAqZn&o8-e@ak*7#Z<&%sE;PTn^(g;j}_4OBM)P3pzo%&V`oBk z7VGiKzC+iGtrpXMYgqNyt+K2JoAk=Q(>8@Y4%riI)+_tA-5mB5^o_b_*s^EVZv|WR z%Eny7)qjk3=S)UpmcC9iDH zE@Qcn<*{Y2?7M|4SYb0g_99lfWtH`-!U}&8GAsto7sXs>psS!6Yw^m~*^0G+TDD_d zp4qCqu?r!)h;4>!3yZz7QKD$GKCay4nZbXTt!lG=|6kusKL7g+i>o)elI=ZaRI^!s z;?qqY;r0pRNvsW$Z@yz4A?w7>fIe+Giw%HUUcrVzql{pqAsfTSLpFg;gGQOba-dQ2 z*eqz2Icy&Eo%{vtdB|R1i=ci>*fOZ)3ii>yG%LYQfaW`iUHXnRyNnHh=DUJj1+~0} z4SHpFsfMs&NRAI%^vu3nzl1G^Yz2D@TG=~nJ!BhL`FE!4tiUQkEvv8|&m(r{e>jJH;8#@PTc^>Nljna!{JhPq8VxwN!UXNip&?tFq7SwMJ z+kYq>^D`_1YMI4`K`lqHyP(kR;9-BkhfnT=FKFvLdt^R77tzpAcX*PnzxipLNT;-7b zHZJ!1KU!vY#ol1+pif&iutLa+*rsRpw|2I$+TTt4)nV21>6kTG-|wYaKi2gk9kUx7 z1kE>u4SQx|j$m(B)9f9#4qE317Kbca;}HN_XBD;wH0EBcHe_|!ey?nX&#(bd%PZK` zkX^$DLpFqsdS<_0Jcdn&Yz8~?Azj&7Y#cP^1eWv6`sK08LYh@!J3vRY6WbNC-B_1r z)~_4u30W`J7qWirx>t5YH!O?O8TzpdXof5{8nQ8L+%ubD0-F!n0=DRxjk$!q^vYVk z!eY?zMbQ@jBl;FIe9UV=Bh~`RZ_iluO;*{p)QV;PQkrG4@oi}~fpvV6W}VpZUrVzQ z>?UZ2TUhLw{hm!!NlQ>Rf{lXa8^cPg(lN`hYEa7>>{Q53V*_5Wc-egSB32e*-k9}*|cBu6^<4( zW;2!pz4ORpv!J_FbJ)X7+V2rI3+gwA&3k6EFJR9@_5v$-W_P!W*rr$3atn(gIif7p z;1=H%`!0SyiiW6$w|IT<--R04s{aV|xpoP5AM{!I18h2EGg$S%vdo_KYp@2d>=W=t z>`2ItVkbajp2XTf&-(3HN60#{ZT~u*Z##DVm(qSWu;-&`_5xc5jk$uo_RQAu23rl; z8um71@33{RY@Hj}dq_UT$70X?b!?2C0R2vA3Dy9*&Kj{JAv=oQ2mLPT18f@9at2!l zjk1Eh2DN;Ht%6#vVe6pZGu^=AkVU`3zJU5|$D004+OHWq2Fi|OZ6RyNPKE3=b_SB) z;>G$vv-e}epxH;To1m7ru*!d%wyeUcL0Jv93pD#~tQIt89X5V9%_gu-P_~7|A&c&@ z*PyHfD+SF^hP8yO6>9^{*N%0CtQ$KA>USO+2-y{E7?R_|Mng7+(0k!PKG9k-i;~|^CCPAZ2Ve=td zz@CHF`2t%6&9H>6f@WC5)|6+%|THbFCNVR6W!DXRN6ray-~*s9+T`o8IBSPaR} zoBy5)lJBDa2dZhy{Clc1R1a+}{!{BCp22cf*0>{n#bYD3`HC&+KTIuw_W@cw+0ImK#{^x6}FZ z*sNDJ-yHT7^m{MQuzAmH%mr)_w7W~#OVA9jumb4#idY=7=y!Ov0Np`u#@f8HG25|@ zkac3CpmmO6;~|^CCcUzLQ`j_U_8IIUsNW-O9yH$qRyv!mtPI-$YPl1u4Ot!502;Fq zo19DgO=06t(=jKo(v>tT!*+nKmz`L3$ZD`Xpgr4*eQN*WzXPxjs|D>@9d-b;^9Qkx zkac2bys~q278?)Q1U3m8a|*lfnYDa?O@n5i!5(^MJNO98g)EO%{^xWpRaiA>%o=Q0 z$aZ73UfB$FST1CF>~YARV6!2c!(Kx2*u-l8zjVGjZ2b?@`8KfH{}M86Kd9el*s+iu z$6CCywX|YwpjEeH9iG|Ic4A$i^WKg1cxL^2v0<;Q+znXS4NI|1r<5^D=tJJ#Wqt)&wi4A~Gi3_89MY}7Mr zIfjjUWiw1*cR@4U!zMkmmQz^4D{EQAHbLv$!eUUr=#P1)1DdY_dkosaC)g~gV$Z$u*|7p>lp?kX$+2TG==h>_t{%`RCD?VZY?K?=C}@;1?6zliM0c=p&@1Hx zR{hm<%o?l~G+!Oo}klJVb8s?QC?t+pq5M6Drn3#>}|;2VdL-9bxvTjUfC#f z*nG$quw~He!wR+&FH`J28Y^_so8;^#(Q^vJq_5E8hw1{nyie zAFxf(5p7{H=!l|kaMgj{*Og&=K=bXzTD-C`Td_9KDD7BB$U3nBuWYZcV1uBRL)frq zHp&QAx|z;UhHV3_Wjpqav{rOvmu+qo`&ohHV<0a0#^R#>3kK~ z$6oodW0fJR!ghcj0Xwl>A={1Z0gbsAs|B^J!*X#tLmrz29qkJL}}G(!>F^vte_EiCrRT1L^I*M9`MS6PCUeqxny%EoAN3sgRw<&Va@| zi}i%87aQ=(@04QGA)CQ+p!xFH>yW*{3SL>iBGy!y9z!$M;+18sSVzb@u`bY<-B=v5 zsEScQJJ^i1d1c4fj-3kGX{-ZuM4kUXPxl^YL;c5be2TK6kb5q>E?KwQjm;`*wr`hLIjegE-!JU-7p zGvC|HnVEC;IFSvuHM8XqvY~`q-C<-PrH9hHPZA?_m_# zEW))pn`{o@TA52$pjshW@%g^Z60*I7+j1Y-0m7~4L9%Hre4EqBdZqeWZ?dtM``S3N z@q`=a1hSZFak4{%>)|lj@GQUI2(kjg)e6a0tG0%$NVNpnX4STkZ6#cP+sO8+wvX(9 zm08b&WJd_sLn&FAt(jw6PIkh|Y5DJo0tdSqeD zZyChdZ;_E%mxE*>;l48zEarUK;A_*#@(5SUCku;wEl8lP|M}05wjm-~`L9@A!d)4X z$hQ9AYum_n60Ww3Ed0^ef?}=(gsW8~t3WYA`RX z%)fg3kAL;%TiHW)f^e;zBnwpwO3+brUfb`_AES37`u;pwH^SArll4%oCs}{h29V_t z?k_*NWLrbO-!`(HMD$l7vQV`kIK}_j=M*C|e?!S9n_*@ChBA|EYs*utg!!w`HnN?B z`z&u4*&)>qlMQX+{i)=+_-@{6>)r1?_8nTV5Z6e#O+7_~{wr0k%jcliCyT~eC=G#mnOD0^K zDP(D?rITe6Zk!#-vIw_jHdzI`Kvb}^G^**u#svRT?3D-)HjdD)$sxsdz335;Idjh6r{w2==@-knL1$7ujyYeOGJ`S+T8|9!kg#SedaLB&#sR&!Hk&B`ec=5?OMA zuceSRBQ6M>V9nU(o| zPjj+V!nM+ptd03E_PcOx$T7oQjsoyWfWQ1FZmSkyG<}6Pq%OYG4 z*&IgD%y5sjLxK(#`$BdV2>mD!qUrJO9ZvahOT9O*aws0WY@B;3db zkxj8OTTUg*Bi#J*$!6G^X>%soV#1AO30c;gzLjjU9KzLd$p+h+X>$l!jBstn$>!La zX>%^wTGiH(ZCv5kdK1}Z!nL`Dte9}El#uNu+#L3i#a8((<7Bf4cf@9s%~5SG*$N^$ zeq?J2x8*vrA}cep1ld-?^}da4r>&V8?IJ5CqOp)2A>5XwWMzaKSvgsScYLiPStY{N zlE~7mOq=OsF~YSHC!3<$RI)rPGqQZL1F9V)JEYoSvNFQ8Qcf1C7Q9REM06&TO<(iv9Be_HWIG3iR^@GC&@z9f)d6-xWCx8B-=~4{|DYavVr@2Z4lXD z!qtY5jkY!O?>EPgjkPl0AR0$j_pslx9$6aUdPpbBRIMXfmTK8#U93!-UCD9?*IzE# zU|Tai3?Un8WwsnfHk@!5WMi#NE91ywR;HCWS)P^s?OL+M zR%Xj3WGk#pn=8py6Yh?;hOF4u%$6l&N32YnrDSDR_P1-vvQG1}%_i$&Wk%MOtechD zuRB?Rm1(n(Y%$^bTS8W*S~*#91=FV4uY~M?mD%qg*&)LH`+&n_M{LdXS4uXvq94mR zvRPK9mDyx-2-n|SvhB8J+T205>b2f*<_mux8D}Bt+r;i+(vfD%JguU?1*ZmWT}-*4`zNX$=X<%k+n6Mm1!lN ztcR6pr6*Z0E3;p3vi??P%K>Bqt<08#$a1Vqo4I5|2{+DRWFrZ;)}zP@I{4WZl7)oZ zGRUL{!aaWuAj`3`ZITUDZ5Y`|!tFPTY?5k|$?{aoC!0aI-e;1nCR`6|$ktk!b9WtC z>@r`AlT9JqepAWvY|V@%pRB;joV$f&i>=JMEFs%$W!7a2*;XsFsfQMG{S9}PS&1qTV{|A zCft@o$c7TpH*?5F5^ldyWMc`pzs-=^)S1p5VDB;FAjBF#}df!C0U9}x#$pd^Xg)ED3y=Rm4 zQmr>xAHucSm#n{P1ITg+H?mx^p{fld8%ek}N0E(IZ46nAaBarPcB;0Ete9|ZmXIA$ zt(2^caQl^$Ex5(cVIkQv!qt|Oty1kBvIB%0%R#augzKS{>?q;(J4RNfS~*#zfxd?% zvTB6evN~B!!nIP1YzpDldMeoh!fm;bY_)1@$PN(ESjfs$Dtyz%E{he^%M6XCzg>c`v zu1Z$T*39Rv)ya0=<+t2LR%~TzC1lA%eJzD7)ymXbl4Yvakt~aFJ!F#&Rc#pANGr47 zD6&e!e49yR)vWAT$dXk{A?r`LHV2R`P;DXEYAdtf8nQ$8`8E%e9UDim8p#*i>VeTD@<8u4LT^*F$%* zUWEILdvCJ-gqywOd1XM~&4=VV(|+eWt2)=VqA$aWLa%9HI?Z6DbI!hL7qAldlmeVY@= z@~liN`D8N)_q~~!WZPBSLAKM@%rAKCA}c1`+)K!g5^nCt$jS&ehjOye*38_48C=f@ zw`CGp6~gsbm8`C6^~jQ~%zi0k%?Q_DbFwzJW=7qXERAq;PbbS%ts~iRE7Qsdvaza- zBa0DkoN=;AgzI54*%ZQ!b1K<1!u2qnERS&e<&*6s+?Kn@c3YYL_K+15Zod+;BdV2> zogmz~bds#nOy5cpSvA6aXQ(<^UBdNOk1UyR{iTr2urmG4Br8y@kZiGPOUPEJwvucW z;YRijS&?c9vYl3@&0S>0s+ExKCETd@ksYu#v*kguPA~Y8btcOqTo2h~eN^j9)}L@Q z8bFq#S}xg8E7RsMvRSIlCX2o5_luKFA>6BDD%mtEb52Yr%OhOx`DC+f&9ph2tmXng z&RS%33AbfEvIc}3Swpg9B3f&*R4da;OR`MWI+ArFTq~W)vTV(4nN8Nk%8aZlSvS?X zll4%oCs{ATy&ig#^(UfLB8w4jEOD|)gzIlI*)-LrljRX^zkIS;s?8=Vvoa$qCp+-2 zZ}T8orT2_XEs3nIl^JzCvSh*?_Y|^J)mobWPPqQk$ubEyvW{d~gljXKtiP3Ma{yTm z;r7cV>-D}LOK-BG^}dw^*=EAceGA!Vgd5A}WLs6+MpjI?HcQA3sCJO-5aCw$Fxkir zzRgi&qY1a)7_zadjU$T@uD>|hR@JtVZMQOOwu5XZ;ab^6R!q2+C?PvSxH*)Pm8n)v zc0#q2WTBO5GbrNOlyLi1B&$TY9+JrFs#cFInQ;B3m`t^nWNB8W&2+MEs&yyprCM*Y z{)D>@4j{|1HFLyr$z~C*_t|6xwr1KaB-?Cdw%kItlZaM=?5M4oEsv3vS(z=%$wIw*L;vDw_KI1IM70D_Qt%ItJEQzd|YSqbV67K&X zT#KxMY7NPfiFSect%ww|)SrEuEy?;5uFU~tlT@2bHidAXu}me4{bFQ(-6Kx6m2e~5 zMpkTVriT);BdV2>l@V@aQ91*iA)SfNlq#W0QW8p% z&O&ENl~HA>3aTPiMOCG<(b>{D=p3mUswP!O)ukG!hIB4ESE`9>O0`fesWz%D)j@Tn zx~Q&H57m?Eqx#Z$=sc+bY9KX44W&k?k<=J9mXcAjl!8*ECa8(j6g8Eap=MHZ)VzJe z;LD&2$NhYCe*5~tZ&oeZhgmJy@|`~BcP>#*i}v+`!8f8@6l>AGW>EPB6h{R{pYkoJ zpb&-HFGxkH>Y*iSsoql|tbRoJ>N<(Q)slQtE=bNL^7^=_+)UbTztK>V~>W-BEYx8gz|xExNXSouJ7>oCQIT_F-}lYWD0& zL0LWg8Ip~1jO^Kyi$ZM~^h7x=$Cp=!a6%;84gW(vx>v3**QYI@H`p=v=N)W>fb^hJH8eyE>x6S_(2 zkNQgk&;aRXbhC5|xP0B$zQZCAsZb!FEcW{jE@XAH8J6QR7 z51JK3p=!ZkG}vz$3_(MrJJFreUFa@pC~XeqxPQ@vXNEW`GMds9C5E<7CTa!+D1|JG zaJ6i*Ff_HB%y9^Yag2twZxpON#K=-m)-aBAjl(D#6%A|OAn5%YN}y0%26wj)d)@8# z>y7%~ZN_3+8Gv%`ribgzSaMN#H^;q+X(hM^-J@5|a5P*RfksI8qI;$L(0$TKG*TLc zMrmE{NB2t)pa-PUXtXp2jgcNi4@wWAhorG+tTYadlO9G7OOK#OG_vt%yflIJoZwd? zfx-!_(ipQ!!K3I=welExOp2kH6i0DsBAO^YjvkjLp-IwYG+BBAJs~}bo|LAbDbiFl zReB0NB|VLvmZqU;(sVRkdImisJ&T@|@=%_XkMgDG&~wuB=y_=dnjy_ZGo=^M3(||| zMQIkACCx^&rI*l4(#z;&X%3nr%|&yiSI{fcJTy-#Km}4EDwO7<`O>TCRcQfQAT2}- zrPt7F(jv4-T8tJ;OVASOb@aNl6fKpOp=Hu?v|M@vy&=7c-jr6L71BzyQhE!$CB2Q_ zmR6xv(mUuK>0R`$^d5RoT8&mqYtS0$ee}Nc0s25%i`Gi(&^l>7S}$!t8>AvsBqdNn z`Vf66eS|)eHlmHvCbUWV7=0}L5B*Qtj5bSK&=%d>p*j5-+^{$%Wu#((zobaX(!q#?Lxbx z@6dPB_vm}|up8~x)pQTqqb+|xKS)2KAN`X~P>hPzN(m~_mOr7Nq@U5x(q6Pz+K2W@ zzo1{F{b;{*03DDHqJz?}=vV1C^qXdM2p!Tm52M4{@^|#R^auJw`yD|?q*7EW{fYjR z{z89AN6}H~7&;~$N5`eV(ce-TDwE1lx%3bENBS52E1f_mq?72RX8RxdPdbH8v86lD zLKI3t2JhH2S_Rw3nzQaSbeeQJI$f%ODo7PkMd=K5hIA%6Q>uh2Nl7RvqivA!tLg77 zbe2>ZRnBM}Zs8;(`F4+Lpm3oE7e3b zrCO+#MqL}#mg=B7Qe9M6s)y>)ikm}yR9`v|ohLOw4Wx#sq0|U9k{YANQZh=GQc#N2 z1T~SGqNY+a)J$rQnoH-S^Q9K3g_Md?rIx6r)C#qdTBFud8`MT>i`r_HE z7iKgF?l#ZRXCb0t(DE0KSY<>+=SvktG!818T2(}}3r_ux&PGIo;L-!=97MDVJ~FC? z2>xG9qfvEKN!C z5sl>{M6?aMnw4mWh^W>c5z+iI5Yap+GCf?3h?L+l^IGYEh-kk|L^KKBGo$W^h?HQb z(Itpz7Su4W+e;CV7QAbE?}UhG4xJGZy*s-M5m679BO+=g3lUK*8|CO2<)WCG!|!A0 zA&v^n3fikwAzG~dmY`*-Ek`TNejRG_K42wUZML+p#x-cIR(>7Ypj9fmFykk}&EaRV zUkJB?`^kPK+6HB;#BXGO67IPFMHUj#S|`xATI-z`X5s8x877{5zv(s7YUFe8LU5btx*(=yFRPM(a{DX*St^Y-#Rw774Y5trK(owo^CAbJ( zB(+2Br1q%2lz}q*>o2$%T`YA#9sE%VGEt_~5p^`@nK^#JCFm0Nv*&zpDY{hZggQx` zQD^j_-S0AV8Dnu*)63E2oOKu3>wFfQ5SRe1bt8+sc%NutuG_9SE=rZh_0B|AR;5yeu#*!OE;nZeL0f{oBjr%9JP{*hW2GfF;g3cMw*&EuScP=M)ry_4oy-IlhG6E z{Yf;%jPsDW4o*dReYqwtFxSs~L`1DThi0gindk*G&e^8-7ZKrJf59w7M0b+eh=|U} zmk<$M!CpqMXe{&4g1-Llv=F`4*PkJa&|*7xb4D&ftNZ#hYYqBPwU5wd)5?2hWLwY| zrWJcN{Ss~O>(AC5XlGx4z21esGi};?!1st~9eib4*^P=d+YC#g_>W`FC(nb&(F3Zs?^V~d=fgVpI__Bs7gOSvZ|<>YSmF~)5<*a zny!Ny^y8>BGIzCxD7l|M&r(p6e*VmAic(F@-ZxvK3;MB2C(PV0MD5L%=4u*bpi9h_ z(~T}g-OM=cwWmAkr5<{t{zmpJAAkm`HVECS-fu&LO)DRnV><*9(V2WF8mg5ThVE8> z_n`awaTje}fpLyRMD%Pk3XRsN$Dju_>W9!+wK5KkSAP@G#D1KuJxrUAqbX)TdpDel zrWwV}dQL~r^z*NkXHlNo%ty1d-)vN1j>A~fW+8g5pFejOp~Y%t2_mA`^y_GuMz$QS zHzTuG_6=yG?a!>)CbU`mZ9$)?%}>!cW-Rtu>RYtitde~`+k=WVvJ&)@+0tIYenv#} z+_M)QP=5!}?^>ll(9wSWb#x4c{rnXrxC!0lS1{<0`bz`Q0O@9Qvvdo(MH+|(N`pA( z2eF>^?-tcj@*rPJLCps7TCw-B=BR~{edbF=MD)bc5~Z1%z1ydw_Jf$MeYcc>G6(tV zL`T$Vkbf0-Mp=XWD>WPSP@6qbFVm)df7BcG(UyHte~o$o8aT+mat5Is^_Po6jXJm$ z-RkEM+=gzGa!`(x%QYmIm1sPbbD|=ul*>3jFwc=mh+tie&O$`v;0L41xfwCS?H4Bt ziD=6zh-eW!X11(~h^Wo8b2G9C*Ge{7jBvF$Sx7`#-e6X+{uEa5M)o6Ie{r&qh{n<H1zXHt;QAw?UNGGJopJyoqWx}0x2T7Kh=^)~5E0GjRupQ@g1gXNeg%V} zXs9#{4U_IhcT4x6d!*rLxHJNdknTnIO824rq>*T(GzyK9?nn1a51E*P5; z67_<1FSGKeA);Z>s5Uws5lw;(<}cC}5YZ&4HwRTjMDt+Ma&!i&q%D&WQ8Rd~HzPX> z5p{wiDX20c>ICQZL{$*McMIR3m8ytn9h_nQs(v;ingqv959gq2V>#;<4Ww2bC6CQ$ z6TE5Wkb;OhLG`PtH90eT8TD@ zh`y827Nr^GnKesCnWl#{GwO~g%gBCTBOB#tb#oEXB-r~5``wO+Xk>RFf?t`wmsSQN zqE67sd{#LG5zz|XiHQ1qcZ4nPLU*fn4Yr2t@GyHbnQLkw$jSMxmIQ z``zY9$5Fvpu3+{ZSRq<5)|_?bDSahcJ=Q;Cu0d-}5B6Q;I#gt8_US%>Hk$qHv*{+Z zc`WCkeHXF?eQs*@nf41rM6G;@wrb05=qvU9HQGMbKhy3Qn-L@2=L~VOaHsa$g^JA_ z>}Rnh=!j~is7xa(M??c&Ld@?UMAQlPnonc?MJF`QlPJ_!f^nRK9_H2fupe0xs`jwIqEtt9ANIZ1L&*>Ok)@z!WVWR{9$Ia!pvv{8fj$b zHwukbD`U`D^)L=SY}DG^R~|tzGwR)@l{lJZ#_3iUE!38;AtJgTEkdDs2p&O?_%#d0 zqw&%Nu9y=zTfa5ev(pgaubAlc3H~}!0TI!4q9Up^fw9!TkNuKRHPgyv{ZMs8L|29y zh-e%nccgYMs;M4oq1qF8rOvpKS{+n(LPqoruzD!j^f0k6b5B7;bj@mlh&n+<^KHqd zsF`WAUUyn)j)-WS=c81!rM+IaL_~CdXoW5`qqf(cG(<#Kn{?FPjI3=xddNVT8d*m~ zL|6Sw5YZ$kGFPxm5z#!j%*?hEBBK5}Bcfq&UTtcZAtJixT#kq)L8^J~$wEYQugXR_ zW@LAEqn3+eTI)EPIKjWSe;g6f-#aFuC(O#*>(Y~G$^_0q`$_Or^wb2d6ZTVwrxDRE z_|DvcrlIK*IP2`^6VD(by7E1Xi0ISMJT$|!X|Itp(TiqXTAORnEcBA;!Cr}8MsrP@ z_KNumDwx38YX1^bh=_}WDi!H{J|d#ekX}U#&6f6)sMpXUGe7(7x5a3wZN>Dr3=z@i zV9OEFDo8h?T8xaxx z1z-~*qEAsjMnv>U+y4*|{S|RDBK)VXXp0$3d-I9eCy0pt;_xXVqQ4@3hKT4~`Wz9_ zx_p7Q>CE~H5z$%jH6o(F25v{Ev}K5h=qv~xMUU!A^cd&-W5_-oW~10+thK#*$5D}y zy*niy%ZL$f%Q#sP;c5x8aGUn~3K7w}m#+~KwXz)%QGYwoZf&^-9nh8s(eG;W4|GJe zQWR>xAjTCe=G&}?l4HL26qFkCy|+Y1jqLo6p)&1Pj!tOHlPFXxL7XdXoa11xeAQ9y zc*X_6-mADW)IoLQx_+Vt@r+u*9sCYY&=3*P^I0QA)DNyT&wPy$5ncI`5fR-3QVL`3US4H0#Mv&=o9I;v@8M_mgM z(erg}L^KatO*L{EqH5fMFco`;C&S+M~kqWu~oBKmZs5h9{1QDa0zSIlHY zL{FnBC`+TxMlsXo3G=KUM@4Egfx@Z&Nj!Lp{hsp5MzN>3%glJ%?1#dqn1g+95j@S7 zPy2CZquA4aWN{Qe&3^VfN%*d`%92HID6<|MqNuY3=Ux{Ek`%Py{yVlt# zHr=ma92FVaqmn@3bU*jt8TNaIqwKGXK2%Yo}578GkyhwXW8#r zYIc>fQS4bihd3&Fmh;noM?QhVXZ#Wv3ay<&(Covrdk|*m&e@g zclp0ZyYn(S2IragIeQQh9qAuXaUS!t?^jAtn5R+a^R6J@Uo}ocMD$t7>4=D~C>0RV zJQ#0Y#T5||U0csUL~8J-xyzi1hz7wc6;UNbM6DztBI@BRL`3^lMntq_6+}c^Rz=m! zmKU2(K&m4mCCE0h^Zhk)6dJ2>jzi-$ zmI>%lt^8vsW@PW$aTMlrX1QzYbLcsLeg@C8<@5d(5J%zj^zL58Ggz}3T(j)^;uMr+ zWZyGqqnsHWP5TLJE{e@y4!4^tW*imG@Mm%Yg)@8)!Ax42$$s{_n~h>KSqb}|IgW~E z(!0I(Bv3fhzs7TDEy!Pnv_G&TDw4ql}8qE{S+FZ%w1S+p`st)SQ}e=U!r z(9~Qjv#HJ2H3WsTReOn6Uh-GPY!rJ*TcVF44y!wdHtkh01!c`q zn<$*4<1m*k=dz`}ucV-?x%6N^gUCh&bN!kXqN2I}h$T>HYVK&h!hWx)2Nb^IS1_2z z9On5g+V2^kA>v!2-0vpD)2ew9t<_cn!Vg#~Ca_i!{H*XtD1-Sb~<& zraRKFqt~USXsNUeE%V1VSk4@lQ?qxcI0~2h`2}yFH`M!^=uP#$g8f$bJ6;?Wtzdrk zm6Jf>3csGgO0-g~yoKJBIf!u9_A3^uUk2ESh%g&TA(6;Ufv4NWTYvz?4qo0?sTHK<6n1PZlZ zkU$B>Vn00yK13f%AEA$=jcB8^32l--MjuQ6L;sUDqs`J5w1qj?Ph)~l&?nNT=u_!4 z^qKTI`ds<~eIb2`zLd72tO%~Q?s9Z z^+df?>y2`D)298-NiG_>o3p@vn|Bl%uQn&3DcWx;nxo$5qWNY^`+41~C^T)lJM12` z$B!lW0sSET$ozivGm4`rM)q5QQ&HZJ{?o#IwBkoUhn1-4N1k--HyaaZ>yLgVwxO@h ze)iku+Yu3cvbO{Ms9G^1qFMe?;1~3Zv>)x44zS+=|BfM!!UO*KB{)d!puc*@ z(UgPqVDHmYQQkr3V4sQd5fMH2Jco$rUC8r@i0(2o5E1?DdL|;GJM#;Oi01wx3J+?2 zzj9Cg)z2>*<^0Ni_U@aDVy0%VU~v@w>U#)&qxPF`GmgUF{8)lRw0Q{G8D*pXMz)m! zD0ayA5JyF(W`#Ho>cv!vvP7lAcpFJwsDE7O*YsXQMsk!%KDE!^8 zXYdF6{ozNQjf(!DKliyJ3jfgYJHmHgkN7PY9?6Ii?x`|PRz$e@CCEY|Ix4SGBis>7 zkcC8ao-IN|G~309h@MoJpk=0JpBa{;720nly%TPnak7wz*7GeyL}%pNXtm!mScBeI zD<2>tT9>u7Nx1Jp#K}S;x-+jsMD!f89ud)6Z(u*d%{@+5M7X&p$U-7ITZ<6UEcn}e z_a}jfXeB;GL^Oww5D~p=-H3>2Mw<{3eOmD`BBJN{{~;o3b2IuxbN>_((bM5)=yTP+ zKt%NF_>wtn^{phxLLxd2+t63q?`yPOquznOIpWXAZ_y9h??+VZ+YCw&5q%=@6C$Gb zIX@#JdcE&OL^QvB=og)X`wYziUQ+AR=1%BZ!FlD@8=~X~mz2h>qA_=%`lW7&@-=`fn6!U4l|xsil5(<0v$8 z&)$EcKm98%_={KSU)0<+1jYXHV~L~iFF(%UDEH~3UMVQ+D6@6%UQq0)U+XvukJ5^J z;yi|q(SyDA1jqTzY*V@Hm%s}Sqf@qWar)-r5^V)YKht$_vda~l%}zyqxQ%B8JU4HO)K`g z+Yxm-?$5!_D9g0z-X)_h$Njm~6?M~WyQ3ba6?=?&qF!cx_6fc>>T}$mt$k5{v!#9a zG5`(KI0vB|)1P}cj|Q7o>^rw1Xy|c&)fk3`n^x?4jzA-|o}$I#a5I zDoIHwNjeLiB~?b1r7Eb3R25Z~&PHcT=b&?>YN(o29aWcVpc>M-=v=8Lswve%wWQjp zwp0hzk?NwlQaw~ps*mbR=b`hY2B?A55H*wl|tbRoJ>N<(Q)slQtE=bNL^7^=_+)UbTztK>V~>W-BEYx8gz|xExK0ffqFkmaL&K!I(cRKL z=pJb}8ZM1MBcyxLz0!T?K4~NxDUCv-r2EnR(gWxLX*3!wjX`6i2hoGlL+BxCEE+3~ zL*t}}(ZkXs=n-i=8ZS*i6QoDcqtavOF)4;(QXIvliD;tqIC@-~geFOo(PZff^n~;z zdQzH#rbtuKROu=7l=L)uTAGHYNz>7E=^6Bl^elQ-%0qclKFXJ#L(fUiqvxd=XofTs z&6Hk1FGw$<7o}NfmNXm9mR>?HNiU<9r8#JhG#Aa4UO}%&^Uyr002N4us8E`Z=1Z@l zSEU7LfwT}UlwL!xNsG`TX)#(XEkR49*U{_JQnXZBhL%ao(Q@ex^oH~%dQ)0~R!A$+ zO6e{1mh?7yTUv!yN$;R{q<7J~(tGGVX*F6ctwC#~_tE>(2j~N7Em|wBL+hmVXuY%n zZIFskk(59Q=|l9P^bz_<+K4tvo6sidWAw4~KlDFoGukX|L0hCx&?nNT=u_!4^qKTI z`ds<~eIb2`zLd72tua7D_wvt zkS;_QN@*xfN=NC^Md%`_9cm}FNA0Bylp$S=E|xl=4pJt{lscl0(k18;=~8s5)CqNx zI-}0gW#}^La&))slvV;G4$b`bd3IU#TDJC*6c@lKP|m(f~9-x*6Rp-GXkB2BLw|pblYdkYD*Y3J0ln zE4r1Hw|{32ZbP?qh<-02$U!-NzaSUo>e$|nZkO&rcSwWLU}*>%BHfAZlH%?s!S=|%LSUIDXs9nJDrsW=K}b%?&N5zI!j)#gj+CFy1K zvNQ+Hk>;Yg(kti{X&#y<6`%sC5EV-E(R}Gu^s2M~Esz$Xh0<&2HE9uABrQgZr6p*I zR^oN^y5_zVE!8!08CoVSN6Xd98|V$`P4uR;0%9)qzZjd1UQ(McqXHxQdq{<7zS?{hz5X6oTKhYROA!%$t7RE_ zLp{8SR=w9D`n|4q(EDZ%_ImvRTC4unp$*!v2ocdYUJ~dd^}Z2(Y-DHjKeR>reS$vI zj6O$1w4Ps}t!6(vqiu+Y*6b^^T{GH&h-h`cK}6Kbw}^=5z7u_?`F)S}XnsGSU)0Ke zL_|mB03xD)Q+N;&(X+;{h=}fEzab*}H))3u5got7h=^wNJ0hYP{eg&R4o46Xt$Zo^ zQ?2}kj_b_&8=W$(*k95N5fS}+ykIq2tuuKITEmv^+VehoU-|%jAgx7frFCeXv>vUO zHlPhs5h{`r9l{C;Klh5LQiA!}vmgnbmEdY>f3vDGs*>PUWq)g~Dyo+7=U{bIH{s9L zdZG*3MgAR;;o3ekc@hv;9#`CN(Xo9CtunP- z^Y13!L95MJ?C-6uL2J#qWdEJ;I#gt4YkzAlfi@c1b7>RWtQl=VpK0YkN85E4>_9us z>e^ob+=X`QxbH#58cPYrlD$~fyQK)k__z->Q zk6-W+`iPZq?@2bIjnXEx$hcNv9=hV`N`(ZBd$$eS%0w?aesdwH0OV*VPnt zGHrIK&9}Tdqbxfkdo@K})Mi)I&B*>@a(C2ABkPU&7`dw{>c8LLI|iVEre``M#19nDaiGtsR5y0)U1)ceb5j#h9kD%kI@FNJ8qeqCG9 zDsA}=TCJn92CX$K=&qQkNGq5?8?{QC&}Q3;xd&`PpJ{bJM_bkAHniRBXV1DFXs200 zcl|`WHHSUu2aW7URBYO`XI2T?t999j4w!Met0_99*&aqmv|lMIGplQVeY6~fY9%

    {+u|7j+ia&Sx}0O9`Hx( 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

    DgZ%z{7B>(1 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 z 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

  • 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�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