From f053037227790f138cc2952421c37cae7385717a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E6=B5=A9=E7=8F=8A=5F=E4=BF=A1=E6=81=AF=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E5=8C=96=E9=83=A8?= Date: Sat, 23 May 2026 17:20:26 +0800 Subject: [PATCH] first commit --- .gitignore | 35 ++ README.md | 97 ++++ pom.xml | 305 +++++++++++++ sql/miku.sql | 234 ++++++++++ .../java/com/mikufufu/AdminApplication.java | 18 + .../mikufufu/common/annotation/Account.java | 10 + .../common/annotation/AnonymousApi.java | 21 + .../common/annotation/OperationLog.java | 28 ++ .../mikufufu/common/constant/Constant.java | 30 ++ .../mikufufu/common/constant/HttpMethod.java | 10 + .../mikufufu/common/constant/RedisKey.java | 53 +++ .../mikufufu/common/constant/RegexStr.java | 50 +++ .../mikufufu/common/entity/AjaxResult.java | 166 +++++++ .../mikufufu/common/entity/BaseEntity.java | 64 +++ .../com/mikufufu/common/entity/LogEvent.java | 19 + .../java/com/mikufufu/common/entity/Node.java | 54 +++ .../com/mikufufu/common/entity/Query.java | 27 ++ .../com/mikufufu/common/enums/Channel.java | 16 + .../mikufufu/common/enums/HtmlTemplate.java | 26 ++ .../com/mikufufu/common/enums/ModuleType.java | 31 ++ .../mikufufu/common/enums/OperationType.java | 48 ++ .../com/mikufufu/common/enums/ResultCode.java | 21 + .../com/mikufufu/common/enums/RoleCode.java | 64 +++ .../com/mikufufu/common/enums/SexType.java | 43 ++ .../mikufufu/common/enums/UploadFileType.java | 32 ++ .../com/mikufufu/common/enums/UserStatus.java | 33 ++ .../common/exception/AuthException.java | 16 + .../common/permission/PermissionHandle.java | 133 ++++++ .../java/com/mikufufu/config/AsyncConfig.java | 24 + .../com/mikufufu/config/DataSourceConfig.java | 45 ++ .../java/com/mikufufu/config/InitConfig.java | 22 + .../mikufufu/config/MyBatisPlusConfig.java | 45 ++ .../java/com/mikufufu/config/RedisConfig.java | 53 +++ .../com/mikufufu/config/SecurityConfig.java | 165 +++++++ .../java/com/mikufufu/core/aop/LogAspect.java | 169 +++++++ .../java/com/mikufufu/core/aop/WebAspect.java | 82 ++++ .../com/mikufufu/core/cache/RedisCache.java | 322 +++++++++++++ .../com/mikufufu/core/cache/SettingCache.java | 57 +++ .../com/mikufufu/core/cache/UserCache.java | 51 +++ .../exception/GlobalExceptionHandler.java | 172 +++++++ .../core/listener/LogEvenListener.java | 23 + .../FastJson2JsonRedisSerializer.java | 59 +++ .../com/mikufufu/core/utils/AesUtils.java | 95 ++++ .../com/mikufufu/core/utils/BeanUtil.java | 52 +++ .../com/mikufufu/core/utils/CollUtils.java | 64 +++ .../core/utils/EasyExcelExportUtil.java | 162 +++++++ .../mikufufu/core/utils/EncryptionUtils.java | 95 ++++ .../com/mikufufu/core/utils/FileUtils.java | 136 ++++++ .../java/com/mikufufu/core/utils/IpUtil.java | 116 +++++ .../com/mikufufu/core/utils/MailUtils.java | 182 ++++++++ .../com/mikufufu/core/utils/RedisUtils.java | 423 ++++++++++++++++++ .../com/mikufufu/core/utils/RegexUtils.java | 145 ++++++ .../com/mikufufu/core/utils/RsaUtils.java | 76 ++++ .../com/mikufufu/core/utils/SpringUtils.java | 129 ++++++ .../com/mikufufu/core/utils/StringUtils.java | 415 +++++++++++++++++ .../com/mikufufu/core/utils/TreeUtils.java | 45 ++ .../java/com/mikufufu/mapper/MenuMapper.java | 16 + .../com/mikufufu/mapper/OperateLogMapper.java | 12 + .../com/mikufufu/mapper/PermissionMapper.java | 24 + .../java/com/mikufufu/mapper/RoleMapper.java | 16 + .../com/mikufufu/mapper/RoleMenuMapper.java | 16 + .../mikufufu/mapper/RolePermissionMapper.java | 16 + .../com/mikufufu/mapper/SettingMapper.java | 16 + .../com/mikufufu/mapper/StorageMapper.java | 16 + .../java/com/mikufufu/mapper/UserMapper.java | 16 + .../auth/controller/LoginController.java | 121 +++++ .../auth/model/dto/ForgetPasswordParam.java | 33 ++ .../auth/model/dto/LoginEmailParam.java | 18 + .../modules/auth/model/dto/LoginParam.java | 29 ++ .../modules/auth/model/dto/RegisterParam.java | 54 +++ .../modules/auth/model/entity/SysUser.java | 110 +++++ .../modules/auth/security/TokenStore.java | 99 ++++ .../security/filter/IllegalRequestFilter.java | 38 ++ .../auth/security/filter/TokenFilter.java | 63 +++ .../handler/AccessDeniedHandlerImpl.java | 40 ++ .../handler/AuthenticationEntryPointImpl.java | 42 ++ .../handler/AuthorizationManagerImpl.java | 81 ++++ .../modules/auth/service/SecurityService.java | 16 + .../service/impl/SecurityServiceImpl.java | 31 ++ .../modules/auth/utils/AuthUtils.java | 85 ++++ .../storage/controller/FilesController.java | 103 +++++ .../modules/storage/enums/StorageType.java | 78 ++++ .../modules/storage/model/entity/Storage.java | 93 ++++ .../storage/service/IStorageService.java | 36 ++ .../storage/service/UploadService.java | 65 +++ .../service/impl/StorageServiceImpl.java | 53 +++ .../service/impl/UploadServiceImpl.java | 115 +++++ .../storage/strategy/StorageStrategy.java | 89 ++++ .../storage/strategy/mode/StorageMode.java | 120 +++++ .../storage/strategy/mode/impl/LocalMode.java | 208 +++++++++ .../storage/strategy/mode/impl/MinioMode.java | 293 ++++++++++++ .../storage/strategy/mode/impl/OssMode.java | 363 +++++++++++++++ .../controller/OperateLogController.java | 26 ++ .../controller/PermissionController.java | 80 ++++ .../system/controller/RoleController.java | 41 ++ .../system/controller/SettingController.java | 60 +++ .../system/controller/UserController.java | 79 ++++ .../modules/system/model/dto/LogDto.java | 57 +++ .../modules/system/model/entity/Menu.java | 77 ++++ .../system/model/entity/OperateLog.java | 90 ++++ .../system/model/entity/Permission.java | 66 +++ .../modules/system/model/entity/Role.java | 54 +++ .../modules/system/model/entity/RoleMenu.java | 48 ++ .../system/model/entity/RolePermission.java | 48 ++ .../modules/system/model/entity/Setting.java | 63 +++ .../modules/system/model/entity/User.java | 102 +++++ .../modules/system/model/excel/UserExcel.java | 79 ++++ .../modules/system/model/vo/EmailConfig.java | 37 ++ .../system/model/vo/PermissionRoleVO.java | 38 ++ .../modules/system/model/vo/RoleVO.java | 20 + .../modules/system/model/vo/UserVO.java | 80 ++++ .../modules/system/model/vo/WebSiteVO.java | 50 +++ .../modules/system/service/EmailService.java | 28 ++ .../modules/system/service/IMenuService.java | 16 + .../system/service/IOperateLogService.java | 11 + .../system/service/IPermissionService.java | 58 +++ .../system/service/IRoleMenuService.java | 16 + .../service/IRolePermissionService.java | 16 + .../modules/system/service/IRoleService.java | 22 + .../system/service/ISettingService.java | 44 ++ .../modules/system/service/IUserService.java | 38 ++ .../system/service/impl/EmailServiceImpl.java | 136 ++++++ .../system/service/impl/MenuServiceImpl.java | 20 + .../service/impl/OperateLogServiceImpl.java | 18 + .../service/impl/PermissionServiceImpl.java | 217 +++++++++ .../service/impl/RoleMenuServiceImpl.java | 20 + .../impl/RolePermissionServiceImpl.java | 20 + .../system/service/impl/RoleServiceImpl.java | 33 ++ .../service/impl/SettingServiceImpl.java | 122 +++++ .../system/service/impl/UserServiceImpl.java | 220 +++++++++ src/main/java/com/mikufufu/task/BaseTask.java | 27 ++ src/main/java/com/mikufufu/task/DemoTask.java | 87 ++++ src/main/java/com/mikufufu/utils/Demo.java | 33 ++ .../com/mikufufu/utils/MybatisGenerator.java | 71 +++ src/main/resources/application-dev.yml | 32 ++ src/main/resources/application-local.yml | 32 ++ .../resources/application-prod.yml.template | 32 ++ src/main/resources/application.yml | 69 +++ src/main/resources/banner.txt | 15 + src/main/resources/mapper/MenuMapper.xml | 5 + .../resources/mapper/OperateLogMapper.xml | 56 +++ .../resources/mapper/PermissionMapper.xml | 37 ++ src/main/resources/mapper/RoleMapper.xml | 5 + src/main/resources/mapper/RoleMenuMapper.xml | 5 + .../resources/mapper/RolePermissionMapper.xml | 5 + src/main/resources/mapper/StorageMapper.xml | 5 + .../resources/mapper/SysSettingMapper.xml | 5 + src/main/resources/mapper/UserMapper.xml | 5 + src/main/resources/static/css/style.css | 31 ++ src/main/resources/static/favicon.ico | Bin 0 -> 270398 bytes src/main/resources/static/index.html | 16 + src/main/resources/template/email_code.html | 19 + 152 files changed, 10574 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pom.xml create mode 100644 sql/miku.sql create mode 100644 src/main/java/com/mikufufu/AdminApplication.java create mode 100644 src/main/java/com/mikufufu/common/annotation/Account.java create mode 100644 src/main/java/com/mikufufu/common/annotation/AnonymousApi.java create mode 100644 src/main/java/com/mikufufu/common/annotation/OperationLog.java create mode 100644 src/main/java/com/mikufufu/common/constant/Constant.java create mode 100644 src/main/java/com/mikufufu/common/constant/HttpMethod.java create mode 100644 src/main/java/com/mikufufu/common/constant/RedisKey.java create mode 100644 src/main/java/com/mikufufu/common/constant/RegexStr.java create mode 100644 src/main/java/com/mikufufu/common/entity/AjaxResult.java create mode 100644 src/main/java/com/mikufufu/common/entity/BaseEntity.java create mode 100644 src/main/java/com/mikufufu/common/entity/LogEvent.java create mode 100644 src/main/java/com/mikufufu/common/entity/Node.java create mode 100644 src/main/java/com/mikufufu/common/entity/Query.java create mode 100644 src/main/java/com/mikufufu/common/enums/Channel.java create mode 100644 src/main/java/com/mikufufu/common/enums/HtmlTemplate.java create mode 100644 src/main/java/com/mikufufu/common/enums/ModuleType.java create mode 100644 src/main/java/com/mikufufu/common/enums/OperationType.java create mode 100644 src/main/java/com/mikufufu/common/enums/ResultCode.java create mode 100644 src/main/java/com/mikufufu/common/enums/RoleCode.java create mode 100644 src/main/java/com/mikufufu/common/enums/SexType.java create mode 100644 src/main/java/com/mikufufu/common/enums/UploadFileType.java create mode 100644 src/main/java/com/mikufufu/common/enums/UserStatus.java create mode 100644 src/main/java/com/mikufufu/common/exception/AuthException.java create mode 100644 src/main/java/com/mikufufu/common/permission/PermissionHandle.java create mode 100644 src/main/java/com/mikufufu/config/AsyncConfig.java create mode 100644 src/main/java/com/mikufufu/config/DataSourceConfig.java create mode 100644 src/main/java/com/mikufufu/config/InitConfig.java create mode 100644 src/main/java/com/mikufufu/config/MyBatisPlusConfig.java create mode 100644 src/main/java/com/mikufufu/config/RedisConfig.java create mode 100644 src/main/java/com/mikufufu/config/SecurityConfig.java create mode 100644 src/main/java/com/mikufufu/core/aop/LogAspect.java create mode 100644 src/main/java/com/mikufufu/core/aop/WebAspect.java create mode 100644 src/main/java/com/mikufufu/core/cache/RedisCache.java create mode 100644 src/main/java/com/mikufufu/core/cache/SettingCache.java create mode 100644 src/main/java/com/mikufufu/core/cache/UserCache.java create mode 100644 src/main/java/com/mikufufu/core/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/mikufufu/core/listener/LogEvenListener.java create mode 100644 src/main/java/com/mikufufu/core/serializer/FastJson2JsonRedisSerializer.java create mode 100644 src/main/java/com/mikufufu/core/utils/AesUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/BeanUtil.java create mode 100644 src/main/java/com/mikufufu/core/utils/CollUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/EasyExcelExportUtil.java create mode 100644 src/main/java/com/mikufufu/core/utils/EncryptionUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/FileUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/IpUtil.java create mode 100644 src/main/java/com/mikufufu/core/utils/MailUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/RedisUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/RegexUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/RsaUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/SpringUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/StringUtils.java create mode 100644 src/main/java/com/mikufufu/core/utils/TreeUtils.java create mode 100644 src/main/java/com/mikufufu/mapper/MenuMapper.java create mode 100644 src/main/java/com/mikufufu/mapper/OperateLogMapper.java create mode 100644 src/main/java/com/mikufufu/mapper/PermissionMapper.java create mode 100644 src/main/java/com/mikufufu/mapper/RoleMapper.java create mode 100644 src/main/java/com/mikufufu/mapper/RoleMenuMapper.java create mode 100644 src/main/java/com/mikufufu/mapper/RolePermissionMapper.java create mode 100644 src/main/java/com/mikufufu/mapper/SettingMapper.java create mode 100644 src/main/java/com/mikufufu/mapper/StorageMapper.java create mode 100644 src/main/java/com/mikufufu/mapper/UserMapper.java create mode 100644 src/main/java/com/mikufufu/modules/auth/controller/LoginController.java create mode 100644 src/main/java/com/mikufufu/modules/auth/model/dto/ForgetPasswordParam.java create mode 100644 src/main/java/com/mikufufu/modules/auth/model/dto/LoginEmailParam.java create mode 100644 src/main/java/com/mikufufu/modules/auth/model/dto/LoginParam.java create mode 100644 src/main/java/com/mikufufu/modules/auth/model/dto/RegisterParam.java create mode 100644 src/main/java/com/mikufufu/modules/auth/model/entity/SysUser.java create mode 100644 src/main/java/com/mikufufu/modules/auth/security/TokenStore.java create mode 100644 src/main/java/com/mikufufu/modules/auth/security/filter/IllegalRequestFilter.java create mode 100644 src/main/java/com/mikufufu/modules/auth/security/filter/TokenFilter.java create mode 100644 src/main/java/com/mikufufu/modules/auth/security/handler/AccessDeniedHandlerImpl.java create mode 100644 src/main/java/com/mikufufu/modules/auth/security/handler/AuthenticationEntryPointImpl.java create mode 100644 src/main/java/com/mikufufu/modules/auth/security/handler/AuthorizationManagerImpl.java create mode 100644 src/main/java/com/mikufufu/modules/auth/service/SecurityService.java create mode 100644 src/main/java/com/mikufufu/modules/auth/service/impl/SecurityServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/auth/utils/AuthUtils.java create mode 100644 src/main/java/com/mikufufu/modules/storage/controller/FilesController.java create mode 100644 src/main/java/com/mikufufu/modules/storage/enums/StorageType.java create mode 100644 src/main/java/com/mikufufu/modules/storage/model/entity/Storage.java create mode 100644 src/main/java/com/mikufufu/modules/storage/service/IStorageService.java create mode 100644 src/main/java/com/mikufufu/modules/storage/service/UploadService.java create mode 100644 src/main/java/com/mikufufu/modules/storage/service/impl/StorageServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/storage/service/impl/UploadServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/storage/strategy/StorageStrategy.java create mode 100644 src/main/java/com/mikufufu/modules/storage/strategy/mode/StorageMode.java create mode 100644 src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/LocalMode.java create mode 100644 src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/MinioMode.java create mode 100644 src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/OssMode.java create mode 100644 src/main/java/com/mikufufu/modules/system/controller/OperateLogController.java create mode 100644 src/main/java/com/mikufufu/modules/system/controller/PermissionController.java create mode 100644 src/main/java/com/mikufufu/modules/system/controller/RoleController.java create mode 100644 src/main/java/com/mikufufu/modules/system/controller/SettingController.java create mode 100644 src/main/java/com/mikufufu/modules/system/controller/UserController.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/dto/LogDto.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/entity/Menu.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/entity/OperateLog.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/entity/Permission.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/entity/Role.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/entity/RoleMenu.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/entity/RolePermission.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/entity/Setting.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/entity/User.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/excel/UserExcel.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/vo/EmailConfig.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/vo/PermissionRoleVO.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/vo/RoleVO.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/vo/UserVO.java create mode 100644 src/main/java/com/mikufufu/modules/system/model/vo/WebSiteVO.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/EmailService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/IMenuService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/IOperateLogService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/IPermissionService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/IRoleMenuService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/IRolePermissionService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/IRoleService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/ISettingService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/IUserService.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/EmailServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/MenuServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/OperateLogServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/PermissionServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/RoleMenuServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/RolePermissionServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/RoleServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/SettingServiceImpl.java create mode 100644 src/main/java/com/mikufufu/modules/system/service/impl/UserServiceImpl.java create mode 100644 src/main/java/com/mikufufu/task/BaseTask.java create mode 100644 src/main/java/com/mikufufu/task/DemoTask.java create mode 100644 src/main/java/com/mikufufu/utils/Demo.java create mode 100644 src/main/java/com/mikufufu/utils/MybatisGenerator.java create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application-prod.yml.template create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/banner.txt create mode 100644 src/main/resources/mapper/MenuMapper.xml create mode 100644 src/main/resources/mapper/OperateLogMapper.xml create mode 100644 src/main/resources/mapper/PermissionMapper.xml create mode 100644 src/main/resources/mapper/RoleMapper.xml create mode 100644 src/main/resources/mapper/RoleMenuMapper.xml create mode 100644 src/main/resources/mapper/RolePermissionMapper.xml create mode 100644 src/main/resources/mapper/StorageMapper.xml create mode 100644 src/main/resources/mapper/SysSettingMapper.xml create mode 100644 src/main/resources/mapper/UserMapper.xml create mode 100644 src/main/resources/static/css/style.css create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/template/email_code.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5206b37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ +.fastRequest/ +application-prod.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe6ecc9 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# miku-framework + +私有开发框架,基于springboot3.0 + +### 项目结构 + +```markdown +miku-framework目录结构 +└─main +├─java +│ └─com +│ └─mikufufu +│ ├─common # 全局共享层(跨模块复用) +│ │ ├─annotation # 自定义注解(如@Log、@Permission) +│ │ ├─constant # 全局常量(错误码/正则表达式/通用配置项) +│ │ ├─enums # 通用枚举(业务状态/类型标记) +│ │ └─exception # 全局异常类(如参数校验异常) +│ │ +│ ├─config # 应用配置类(如Swagger/线程池配置) +│ │ +│ ├─core # 核心技术组件(与业务解耦) +│ │ ├─cache # 缓存抽象层(Redis/Memcached操作封装) +│ │ ├─serializer # 序列化协议(JSON/ProtoBuf自定义逻辑) +│ │ └─utils # 核心工具类(加解密/反射工具等) +│ │ +│ ├─modules # 业务模块(按功能垂直拆分) +│ │ │ +│ │ ├─api # 对外接口模块 +│ │ │ ├─controller # 聚合API入口(如第三方回调接口) +│ │ │ └─model +│ │ │ └─vo # API专用视图对象(避免污染内部模型) +│ │ │ +│ │ ├─auth # 认证授权模块 +│ │ │ ├─controller # 登录/登出/令牌管理接口 +│ │ │ ├─model +│ │ │ │ ├─dto # 认证传输对象(如LoginDTO) +│ │ │ │ └─entity # 认证实体(如UserToken表映射) +│ │ │ ├─security # 安全子模块 +│ │ │ │ ├─filter # 安全过滤器(如JWT校验) +│ │ │ │ └─handler # 认证处理器(如登录成功返回处理) +│ │ │ ├─service # 认证服务接口 +│ │ │ │ └─impl # 接口实现(如JWT认证服务) +│ │ │ └─utils # 模块专用工具(如Token生成器) +│ │ │ +│ │ ├─storage # 文件存储模块 +│ │ │ ├─controller # 文件上传/下载接口 +│ │ │ ├─enums # 存储枚举(如文件类型FileType) +│ │ │ ├─model +│ │ │ │ └─entity # 存储实体(如FileMetadata表映射) +│ │ │ ├─service # 存储服务接口 +│ │ │ │ └─impl # 实现类(如OSS/Local存储实现) +│ │ │ └─strategy # 存储策略模式 +│ │ │ └─mode +│ │ │ └─impl # 策略实现(如分片上传策略) +│ │ │ +│ │ └─system # 系统管理模块 +│ │ ├─controller # 用户/角色/权限管理接口 +│ │ ├─exception # 模块专属异常(如UserNotFoundException) +│ │ ├─model +│ │ │ ├─entity # 系统实体(如User表映射) +│ │ │ └─vo # 视图对象(如UserVO) +│ │ └─service # 系统服务接口 +│ │ └─impl # 实现类(如UserServiceImpl) +│ │ +│ ├─mapper # MyBatis数据访问接口(统一管理) +│ │ +│ ├─task # 定时任务调度(如XXL-JOB处理器) +│ │ +│ └─utils # 自定义工具类 +│ +└─resources +├─mapper # MyBatis XML文件 +│ +└─static # 静态资源 +└─css # CSS样式文件 + +``` + +### 部署方式 + +执行下列命令 + +``` +mvn package -P prod +``` + +在打包好的jar文件所在的文件夹下新建config文件夹 + +将代码中的`application.yml`和`application-prod.yml`复制到config文件夹中即可,如需修改只需要修改prod文件即可 + +执行下列命令运行: + +``` +jar -jar [jar包名].jar +``` + +Ps:修改yml文件后需要重启才可生效 \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8286280 --- /dev/null +++ b/pom.xml @@ -0,0 +1,305 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.13 + + + com.mikufufu + miku-framework + 0.0.1 + miku-framework + 私有开发框架,基于springboot3.0 + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + com.mysql + mysql-connector-j + 8.3.0 + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.9 + + + + com.baomidou + mybatis-plus-jsqlparser + 3.5.9 + + + + com.alibaba + druid-spring-boot-starter + 1.2.21 + + + + com.alibaba.fastjson2 + fastjson2 + 2.0.48 + + + + com.aliyun.oss + aliyun-sdk-oss + 3.18.3 + + + + io.minio + minio + 8.6.0 + + + commons-io + commons-io + + + okhttp + com.squareup.okhttp3 + + + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.9 + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-starter-mail + + + org.eclipse.angus + jakarta.mail + + + + + org.eclipse.angus + jakarta.mail + 2.0.4 + + + + org.projectlombok + lombok + true + + + + commons-io + commons-io + 2.16.1 + + + + com.alibaba + easyexcel + 4.0.3 + + + + com.baomidou + mybatis-plus-generator + 3.5.9 + + + + org.freemarker + freemarker + 2.3.34 + + + + + + + + org.springframework + spring-core + 6.2.14 + + + org.springframework + spring-web + 6.2.14 + + + org.springframework + spring-webmvc + 6.2.14 + + + org.springframework + spring-beans + 6.2.14 + + + ch.qos.logback + logback-classic + 1.5.21 + + + ch.qos.logback + logback-core + 1.5.21 + + + org.apache.tomcat.embed + tomcat-embed-core + 10.1.49 + + + io.netty + netty-codec + 4.1.128.Final + + + org.apache.commons + commons-lang3 + 3.18.0 + + + + org.apache.commons + commons-compress + 1.26.0 + + + + org.apache.poi + poi-ooxml + 5.4.1 + + + + + + + + + org.apache.maven.plugins + + maven-compiler-plugin + + + + org.springframework.boot + spring-boot-configuration-processor + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + application*.yml + application*.properties + + + + + + + + + dev + + dev + + + true + + + + local + + local + + + + test + + test + + + + prod + + prod + + + + + diff --git a/sql/miku.sql b/sql/miku.sql new file mode 100644 index 0000000..8451bbc --- /dev/null +++ b/sql/miku.sql @@ -0,0 +1,234 @@ +CREATE DATABASE IF NOT EXISTS miku + CHARACTER SET utf8mb4 + COLLATE utf8mb4_0900_ai_ci; + +use miku; + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for m_menu +-- ---------------------------- +DROP TABLE IF EXISTS `m_menu`; +CREATE TABLE `m_menu` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '菜单id', + `menu_name` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '菜单的名字', + `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '菜单的路由地址', + `component` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '菜单对应组件地址', + `parent_id` int NULL DEFAULT 0 COMMENT '父菜单id', + `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '菜单图标', + `status` int UNSIGNED NULL DEFAULT 0 COMMENT '是否隐藏(0.显示,1.隐藏)', + `sort` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '排序', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标识', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '菜单信息表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_menu +-- ---------------------------- + +-- ---------------------------- +-- Table structure for m_operate_log +-- ---------------------------- +DROP TABLE IF EXISTS `m_operate_log`; +CREATE TABLE `m_operate_log` ( + `id` int NOT NULL AUTO_INCREMENT, + `module` int NULL DEFAULT NULL COMMENT '功能模块', + `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '接口地址', + `params` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '请求参数', + `operate_type` int NULL DEFAULT NULL COMMENT '操作类型', + `method_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '方法名', + `operator` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '操作人', + `ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'ip地址', + `operate_time` datetime NULL DEFAULT NULL COMMENT '操作时间', + `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '附加信息', + `status` int NULL DEFAULT NULL COMMENT '状态(1.成功,0.失败)', + `error_message` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '错误信息', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '操作日志表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_operate_log +-- ---------------------------- + +-- ---------------------------- +-- Table structure for m_permission +-- ---------------------------- +DROP TABLE IF EXISTS `m_permission`; +CREATE TABLE `m_permission` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT ' 权限主键', + `method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '请求的方法', + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '接口名称', + `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '接口地址', + `sign` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '接口签名', + `status` int NOT NULL DEFAULT 0 COMMENT '接口状态(0.启用,1.停用)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标识', + PRIMARY KEY (`id`) USING BTREE, + INDEX `url_path`(`path` ASC) USING BTREE COMMENT '权限连接', + INDEX `idx_deleted` (`is_deleted`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '权限表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_permission +-- ---------------------------- + +-- ---------------------------- +-- Table structure for m_role +-- ---------------------------- +DROP TABLE IF EXISTS `m_role`; +CREATE TABLE `m_role` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '角色主键', + `role_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称', + `role_code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色编号', + `status` int NOT NULL DEFAULT 0 COMMENT '角色状态(0.启用,1.停用)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标识', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_deleted` (`is_deleted`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_role +-- ---------------------------- +INSERT INTO `m_role` VALUES (1, '超级管理员', 'system_admin', 0, '2025-04-23 22:31:03', 'admin', '2025-04-23 22:31:03',null, 0); +INSERT INTO `m_role` VALUES (2, '管理员', 'admin', 0, '2025-04-23 22:31:03', 'admin', '2025-04-23 22:31:03',null, 0); +INSERT INTO `m_role` VALUES (3, '用户', 'user', 0, '2025-04-23 22:31:03', 'admin', '2025-04-23 22:31:03',null, 0); +INSERT INTO `m_role` VALUES (4, '游客', 'visitor', 0, '2025-04-23 22:31:03', 'admin', '2025-04-23 22:31:03',null, 0); + +-- ---------------------------- +-- Table structure for m_role_menu +-- ---------------------------- +DROP TABLE IF EXISTS `m_role_menu`; +CREATE TABLE `m_role_menu` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', + `role_id` int NOT NULL COMMENT '角色id', + `menu_id` int NOT NULL COMMENT '导航id', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标识', + PRIMARY KEY (`id`) USING BTREE, + INDEX idx_r_m (role_id, menu_id, is_deleted) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色导航关系表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_role_menu +-- ---------------------------- + +-- ---------------------------- +-- Table structure for m_role_permission +-- ---------------------------- +DROP TABLE IF EXISTS `m_role_permission`; +CREATE TABLE `m_role_permission` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', + `permission_id` int NOT NULL COMMENT '权限id', + `role_id` int NOT NULL COMMENT '角色id', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标识', + PRIMARY KEY (`id`) USING BTREE, + INDEX idx_r_p (role_id, permission_id, is_deleted) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色权限关系表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_role_permission +-- ---------------------------- + +-- ---------------------------- +-- Table structure for m_storage +-- ---------------------------- +DROP TABLE IF EXISTS `m_storage`; +CREATE TABLE `m_storage` ( + `id` int NOT NULL AUTO_INCREMENT, + `storage_code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '对象存储服务商(目前支持阿里云、minio和本地)', + `storage_type` int NULL DEFAULT NULL COMMENT '对象存储服务的类型(0.local,1.minio,2.oss)', + `storage_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '对象存储服务商名称', + `host` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '外链访问地址', + `endpoint` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'API访问地址', + `access_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '账号或者用户识别码', + `secret_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '密钥', + `bucket_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '存储桶名称', + `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'icon的url链接', + `forced_path_mode` int UNSIGNED NULL DEFAULT 0 COMMENT '是否强制使用路径模式(1.使用2.不使用-默认)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标识', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_deleted` (`is_deleted`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '存储桶表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_storage +-- ---------------------------- + +-- ---------------------------- +-- Table structure for m_setting +-- ---------------------------- +DROP TABLE IF EXISTS `m_setting`; +CREATE TABLE `m_setting` ( + `id` int NOT NULL AUTO_INCREMENT, + `code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '配置编号', + `config_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '配置名称', + `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '配置描述', + `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '配置的值', + `status` int NULL DEFAULT NULL DEFAULT 0 COMMENT '是否启用(0.启用,1.停用)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标识', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_deleted` (`is_deleted`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统设置表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_setting +-- ---------------------------- +-- ---------------------------- +-- Table structure for m_user +-- ---------------------------- +DROP TABLE IF EXISTS `m_user`; +CREATE TABLE `m_user` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '用户id', + `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名', + `password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码', + `nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户昵称', + `avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户头像', + `role` int NOT NULL DEFAULT 1 COMMENT '角色', + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱', + `phone_number` char(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '电话号码', + `gender` tinyint NULL DEFAULT NULL COMMENT '性别(0.保密,1.男,2女)', + `status` int NOT NULL DEFAULT 0 COMMENT '用户状态(0.正常,1.封号)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标识', + PRIMARY KEY (`id`) USING BTREE, + INDEX `user_relo`(`role` ASC) USING BTREE, + INDEX `idx_deleted` (`is_deleted`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Records of m_user +-- ---------------------------- +INSERT INTO `m_user` VALUES (1, 'admin', '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918', '超级管理员', 'https://oss.moxiaoli.cn/image/avatar.jpg', 1, '1414212942@qq.com', '12345678901', 1, 0, '2025-04-23 22:31:03', 'admin', '2025-04-23 22:31:03',null, 0); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/src/main/java/com/mikufufu/AdminApplication.java b/src/main/java/com/mikufufu/AdminApplication.java new file mode 100644 index 0000000..37cd381 --- /dev/null +++ b/src/main/java/com/mikufufu/AdminApplication.java @@ -0,0 +1,18 @@ +package com.mikufufu; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@SpringBootApplication +@MapperScan("com.mikufufu.mapper") +public class AdminApplication { + + public static void main(String[] args) { + System.out.println("项目启动!!"); + SpringApplication.run(AdminApplication.class, args); + } + +} diff --git a/src/main/java/com/mikufufu/common/annotation/Account.java b/src/main/java/com/mikufufu/common/annotation/Account.java new file mode 100644 index 0000000..246d724 --- /dev/null +++ b/src/main/java/com/mikufufu/common/annotation/Account.java @@ -0,0 +1,10 @@ +package com.mikufufu.common.annotation; + +import java.lang.annotation.*; + +// 注解位置设置为类成员变量或方法参数上 +@Target({ElementType.FIELD,ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Account { +} diff --git a/src/main/java/com/mikufufu/common/annotation/AnonymousApi.java b/src/main/java/com/mikufufu/common/annotation/AnonymousApi.java new file mode 100644 index 0000000..88ba8dd --- /dev/null +++ b/src/main/java/com/mikufufu/common/annotation/AnonymousApi.java @@ -0,0 +1,21 @@ +package com.mikufufu.common.annotation; + +import java.lang.annotation.*; + +/** + * 一个用于方法级别的注解,标识该方法具有匿名特性。 + * 这个注解适用于那些在运行时希望被识别为“匿名”的方法。 + *
+ * {@code @Target} 指定此注解可以用于什么地方,这里限定为方法(ElementType.METHOD)。 + * {@code @Retention} 指定此注解的生命周期,这里为运行时(RetentionPolicy.RUNTIME)。 + * {@code @Documented} 指定是否将此注解包含在JavaDoc中,默认为false。 + * + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AnonymousApi { + + String description() default ""; +} + diff --git a/src/main/java/com/mikufufu/common/annotation/OperationLog.java b/src/main/java/com/mikufufu/common/annotation/OperationLog.java new file mode 100644 index 0000000..9653cc3 --- /dev/null +++ b/src/main/java/com/mikufufu/common/annotation/OperationLog.java @@ -0,0 +1,28 @@ +package com.mikufufu.common.annotation; + +import com.mikufufu.common.enums.ModuleType; +import com.mikufufu.common.enums.OperationType; + +import java.lang.annotation.*; + + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface OperationLog { + + /** + * 功能模块名称 + */ + ModuleType module() default ModuleType.OTHER; + + /** + * 操作类型 {@link OperationType} 默认 插入 + */ + OperationType type() default OperationType.INSERT; + + /** + * 操作描述 + */ + String description() default ""; +} diff --git a/src/main/java/com/mikufufu/common/constant/Constant.java b/src/main/java/com/mikufufu/common/constant/Constant.java new file mode 100644 index 0000000..4a1a3ee --- /dev/null +++ b/src/main/java/com/mikufufu/common/constant/Constant.java @@ -0,0 +1,30 @@ +package com.mikufufu.common.constant; + +public interface Constant { + + Integer YES = 1; + + Integer NO = 0; + + /** + * 网站标识的常量键名 + */ + String WEB_SITE_CODE = "website"; + + /** + * 存储设置标识的常量键名 + */ + String STORAGE_SETTING_CODE = "storage"; + + /** + * 系统邮件设置标识的常量键名 + */ + String SYS_EMAIL_SETTING_CODE = "sys_email"; + + /** + * 顶级父导航id + */ + Integer TOP_PARENT_ID = 0; + + +} diff --git a/src/main/java/com/mikufufu/common/constant/HttpMethod.java b/src/main/java/com/mikufufu/common/constant/HttpMethod.java new file mode 100644 index 0000000..46cb81e --- /dev/null +++ b/src/main/java/com/mikufufu/common/constant/HttpMethod.java @@ -0,0 +1,10 @@ +package com.mikufufu.common.constant; + +public interface HttpMethod { + + String GET = "GET"; + String POST = "POST"; + String PUT = "PUT"; + String DELETE = "DELETE"; + String OPTIONS = "OPTIONS"; +} diff --git a/src/main/java/com/mikufufu/common/constant/RedisKey.java b/src/main/java/com/mikufufu/common/constant/RedisKey.java new file mode 100644 index 0000000..a5d15d6 --- /dev/null +++ b/src/main/java/com/mikufufu/common/constant/RedisKey.java @@ -0,0 +1,53 @@ +package com.mikufufu.common.constant; + +/** + * Redis常量 + * + */ +public interface RedisKey { + + /** + * 系统设置缓存前缀 + */ + String SETTING = "setting:"; + + /** + * 用户缓存前缀 + */ + String USER = "user:"; + + /** + * 角色缓存前缀 + */ + String ROLE = "role:"; + + /** + * 权限缓存前缀 + */ + String PERMISSION = "permission:"; + + /** + * 存储服务商缓存前缀 + */ + String STORAGE = "storage:"; + + /** + * 菜单缓存前缀 + */ + String MENU = "menu:"; + + /** + * token缓存前缀 + */ + String TOKEN = "token:"; + + /** + * 邮件验证码缓存前缀 + */ + String EMAIL_CODE = "mail:code:"; + + /** + * 邮件验证码过期时间缓存前缀 + */ + String EMAIL_CODE_EXPIRATION_TIME = EMAIL_CODE + "expiration:time:"; +} diff --git a/src/main/java/com/mikufufu/common/constant/RegexStr.java b/src/main/java/com/mikufufu/common/constant/RegexStr.java new file mode 100644 index 0000000..cbcfc0f --- /dev/null +++ b/src/main/java/com/mikufufu/common/constant/RegexStr.java @@ -0,0 +1,50 @@ +package com.mikufufu.common.constant; + +/** + * RegexStr接口定义了一系列常用的正则表达式字符串, + * 用于匹配不同的数据格式,例如手机号、电子邮件、URL和密码。 + */ +public interface RegexStr { + + /** + * 手机号正则表达式,仅匹配中国手机号,格式为13x-18x开头的11位数字 + */ + String MOBILE = "^1[3|4|5|7|8][0-9]\\d{8}$"; + + /** + * 电子邮件正则表达式,匹配标准电子邮件格式 + */ + String EMAIL = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$"; + + /** + * 密码正则表达式,匹配8-16位字母、数字和下划线、英文句号、斜线组成的密码 + */ + String PASSWORD = "^[a-zA-Z0-9\\._/]{8,16}$"; + + /** + * 邮政编码正则表达式,匹配6位数字 + */ + String POST_CODE = "^[1-9]\\d{5}$"; + + /** + * 身份证正则表达式,匹配15位或18位数字或字母 + */ + String ID_CARD = "^[1-9]\\d{5}(?:\\d|[xX])$"; + + /** + * URL正则表达式,匹配http、ftp或file协议的URL地址 + */ + String URL = "^(https?|ftp|file):/**[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"; + + /** + * 定义QQ号的正则表达式:QQ号必须以非零数字开头,长度在5到11位之间 + */ + String QQ = "^[1-9][0-9]{4,10}$"; + + + /** + * 域名 + */ + String DOMAIN = "^([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}$"; +} + diff --git a/src/main/java/com/mikufufu/common/entity/AjaxResult.java b/src/main/java/com/mikufufu/common/entity/AjaxResult.java new file mode 100644 index 0000000..a05e26e --- /dev/null +++ b/src/main/java/com/mikufufu/common/entity/AjaxResult.java @@ -0,0 +1,166 @@ +package com.mikufufu.common.entity; + +import com.mikufufu.common.enums.ResultCode; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 通用请求消息结果封装类 + * + */ +@Schema(description = "通用请求消息结果封装类") +@Data +public class AjaxResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 状态码 + */ + @Schema(description = "状态码") + private Integer code; + + /** + * 返回消息 + */ + @Schema(description = "返回消息") + private String msg; + + /** + * 返回数据 + */ + private T data; + + public AjaxResult(Integer code,String msg){ + this.code = code; + this.msg = msg; + } + + public AjaxResult(Integer code,String msg, T data) { + this.code = code; + this.msg = msg; + this.data = data; + } + + public AjaxResult(ResultCode resultCode) { + this.code = resultCode.getCode(); + this.msg = resultCode.getMessage(); + } + + /** + * 返回成功消息 + * @return 成功消息 + */ + public static AjaxResult success(){ + return new AjaxResult<>(ResultCode.SUCCESS); + } + + /** + * 返回成功消息 + * @param msg 返回内容 + * @return 成功消息 + */ + public static AjaxResult success(String msg){ + return new AjaxResult<>(ResultCode.SUCCESS.getCode(),msg); + } + + /** + * 返回成功消息 + * @param data 数据对象 + * @return 成功消息 + */ + public static AjaxResult data(T data){ + return AjaxResult.data(ResultCode.SUCCESS.getMessage(),data); + } + + /** + * 返回成功消息 + * @param msg 返回内容 + * @param data 数据对象 + * @return 成功消息 + */ + public static AjaxResult data(String msg, T data){ + return AjaxResult.data(ResultCode.SUCCESS.getCode(),msg,data); + } + + /** + * 返回成功消息 + * @param msg 返回内容 + * @param data 数据对象 + * @return 成功消息 + */ + public static AjaxResult data(Integer code,String msg, T data){ + return new AjaxResult<>(code,data == null ? ResultCode.NOT_DATA.getMessage(): msg,data); + } + + /** + * 返回错误消息 + * @return 错误消息 + */ + public static AjaxResult error(){ + return new AjaxResult<>(ResultCode.FAIL); + } + + /** + * 返回错误消息 + * @param msg 返回内容 + * @return 错误消息 + */ + public static AjaxResult error(Integer code,String msg){ + return new AjaxResult<>(code,msg); + } + + /** + * 返回错误消息 + * @param msg 返回内容 + * @return 错误消息 + */ + public static AjaxResult error(String msg){ + return AjaxResult.error(ResultCode.FAIL.getCode(),msg); + } + + /** + * 返回错误消息 + * @param code 错误码 + * @param msg 错误内容 + * @param data 数据对象 + * @return 错误消息 + */ + public static AjaxResult error(Integer code,String msg, T data){ + return new AjaxResult<>(code,msg,data); + } + + /** + * 返回错误消息 + * + * @param msg 错误内容 + * @param data 数据对象 + * @return 错误消息 + */ + public static AjaxResult error(String msg, T data){ + return AjaxResult.error(ResultCode.FAIL.getCode(),msg,data); + } + + /** + * 返回成功或者失败消息的通用方法 + * @param flag 返回内容 + * @return 成功与失败状态消息 + */ + public static AjaxResult status(Boolean flag){ + return flag? AjaxResult.success() : AjaxResult.error(); + } + + /** + * 返回成功或者失败消息的通用方法 + * + * @param flag 返回内容 + * @return 成功与失败状态消息 + */ + public static AjaxResult status(Boolean flag,String success,String error){ + return flag? AjaxResult.success(success) : AjaxResult.error(error); + } +} diff --git a/src/main/java/com/mikufufu/common/entity/BaseEntity.java b/src/main/java/com/mikufufu/common/entity/BaseEntity.java new file mode 100644 index 0000000..ddcec32 --- /dev/null +++ b/src/main/java/com/mikufufu/common/entity/BaseEntity.java @@ -0,0 +1,64 @@ +package com.mikufufu.common.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 实体类基础字段定义 + * + * 包含数据库表中共用的审计字段和逻辑删除标识, + * 使用MyBatis-Plus注解实现字段自动填充和逻辑删除功能 + */ +@Data +public class BaseEntity { + + /** + * 创建时间 + * {@code @TableField} 配置: + * value = "create_time" 映射数据库字段名 + * fill = FieldFill.INSERT 仅在INSERT操作时自动填充 + */ + @TableField(value = "create_time") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime createTime; + + /** + * 创建人 + * {@code @TableField} 配置: + * fill = FieldFill.INSERT 仅在INSERT操作时自动填充 + */ + @TableField(value = "create_by") + private String createBy; + + /** + * 最后更新时间 + * {@code @TableField} 配置: + * fill = FieldFill.INSERT_UPDATE 在INSERT和UPDATE操作时自动填充 + */ + @TableField(value = "update_time") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime updateTime; + + /** + * 最后更新人 + * {@code @TableField} 配置: + * fill = FieldFill.INSERT_UPDATE 在INSERT和UPDATE操作时自动填充 + */ + @TableField(value = "update_by") + private String updateBy; + + /** + * 逻辑删除标识 + * {@code @TableLogic} 配置逻辑删除规则: + * value = "0" 表示未删除状态 + * delval = "1" 表示已删除状态 + * {@code @TableField} 显式映射数据库字段名为is_deleted + */ + @TableLogic(value = "0", delval = "1") + @TableField("is_deleted") + private Integer isDeleted; +} + diff --git a/src/main/java/com/mikufufu/common/entity/LogEvent.java b/src/main/java/com/mikufufu/common/entity/LogEvent.java new file mode 100644 index 0000000..007bf66 --- /dev/null +++ b/src/main/java/com/mikufufu/common/entity/LogEvent.java @@ -0,0 +1,19 @@ +package com.mikufufu.common.entity; + +import com.mikufufu.modules.system.model.entity.OperateLog; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * 定义日志事件 + */ +@Getter +public class LogEvent extends ApplicationEvent { + + private final OperateLog operateLog; + + public LogEvent(OperateLog operateLog){ + super(operateLog); + this.operateLog = operateLog; + } +} diff --git a/src/main/java/com/mikufufu/common/entity/Node.java b/src/main/java/com/mikufufu/common/entity/Node.java new file mode 100644 index 0000000..009acce --- /dev/null +++ b/src/main/java/com/mikufufu/common/entity/Node.java @@ -0,0 +1,54 @@ +package com.mikufufu.common.entity; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 一个改进后的Node类,提供了更安全、更易于维护的结构。 + * 对于泛型T和E,建议子类在使用时确保类型安全。 + * 这个类是线程不安全的,如果需要在多线程环境中使用,请在子类中实现适当的同步机制。 + * + * + * @param 节点类型标识,用于区分不同类型的节点 + * @param 父节点标识类型,确保与节点类型区分,避免数据一致性问题 + */ +@Data +public abstract class Node { + + /** + * 节点ID + */ + private E id; + + /** + * 父节点ID + */ + private E parentId; + + /** + * 子节点列表 + */ + private List> children = Collections.synchronizedList(new ArrayList<>()); + + /** + * 添加一个子节点。 + * @param child 要添加的子节点。 + * @return 如果添加成功,返回true;如果子节点已存在,返回false。 + */ + public boolean addChild(Node child) { + return children.add(child); + } + + /** + * 移除一个子节点。 + * @param child 要移除的子节点。 + * @return 如果成功移除,返回true;如果子节点不存在,返回false。 + */ + public boolean removeChild(Node child) { + return children.remove(child); + } +} + diff --git a/src/main/java/com/mikufufu/common/entity/Query.java b/src/main/java/com/mikufufu/common/entity/Query.java new file mode 100644 index 0000000..b4f87e1 --- /dev/null +++ b/src/main/java/com/mikufufu/common/entity/Query.java @@ -0,0 +1,27 @@ +package com.mikufufu.common.entity; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.Data; + +/** + * 分页查询通用实体类 + * + */ +@Data +public class Query { + + /** + * 当前页码 + */ + private Integer current = 1; + + /** + * 每页条数 + */ + private Integer size =10; + + public IPage getPage() { + return new Page<>(this.current, this.size); + } +} \ No newline at end of file diff --git a/src/main/java/com/mikufufu/common/enums/Channel.java b/src/main/java/com/mikufufu/common/enums/Channel.java new file mode 100644 index 0000000..50ebe05 --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/Channel.java @@ -0,0 +1,16 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Channel { + + WEB(1, "用户端"), + ADMIN(2, "管理端") + ; + + private final int type; + private final String name; +} diff --git a/src/main/java/com/mikufufu/common/enums/HtmlTemplate.java b/src/main/java/com/mikufufu/common/enums/HtmlTemplate.java new file mode 100644 index 0000000..cb96edf --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/HtmlTemplate.java @@ -0,0 +1,26 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum HtmlTemplate { + + EMAIL_CODE("email_code", "email_code.html", "邮件验证码"); + + /** + * 模板名称 + */ + private final String template; + + /** + * 模板文件名称 + */ + private final String fileName; + + /** + * 描述 + */ + private final String subject; +} diff --git a/src/main/java/com/mikufufu/common/enums/ModuleType.java b/src/main/java/com/mikufufu/common/enums/ModuleType.java new file mode 100644 index 0000000..9ba495b --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/ModuleType.java @@ -0,0 +1,31 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 功能模块类型 + */ +@Getter +@AllArgsConstructor +public enum ModuleType { + + /** + * 用户模块 + */ + USER(1, "用户模块"), + + /** + * 系统模块 + */ + SYSTEM(2, "系统模块"), + + /** + * 其他模块 + */ + OTHER(0, "其他"); + + private final int type; + + private final String name; +} diff --git a/src/main/java/com/mikufufu/common/enums/OperationType.java b/src/main/java/com/mikufufu/common/enums/OperationType.java new file mode 100644 index 0000000..df61001 --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/OperationType.java @@ -0,0 +1,48 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum OperationType { + + /** + * 插入 + */ + INSERT(1,"插入"), + + /** + * 更新 + */ + UPDATE(2,"更新"), + + /** + * 删除 + */ + DELETE(3,"删除"), + + /** + * 登录 + */ + LOGIN(4,"登录"), + + /** + * 注册 + */ + REGISTER(5,"注册"), + + /** + * 发送邮件 + */ + SEND_EMAIL(6,"发送邮件"), + + /** + * 其他 + */ + OTHER(0,"其他"), + ; + + private final int type; + private final String name; +} diff --git a/src/main/java/com/mikufufu/common/enums/ResultCode.java b/src/main/java/com/mikufufu/common/enums/ResultCode.java new file mode 100644 index 0000000..92b68b0 --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/ResultCode.java @@ -0,0 +1,21 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ResultCode { + + SUCCESS(200, "操作成功"), + FAIL(500, "操作失败"), + NOT_FOUND(404, "资源不存在"), + UNAUTHORIZED(401, "未授权"), + FORBIDDEN(403, "禁止访问"), + BAD_REQUEST(400, "请求参数错误"), + NOT_DATA(501, "暂无数据") + ; + + private final int code; + private final String message; +} diff --git a/src/main/java/com/mikufufu/common/enums/RoleCode.java b/src/main/java/com/mikufufu/common/enums/RoleCode.java new file mode 100644 index 0000000..af73fe8 --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/RoleCode.java @@ -0,0 +1,64 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 系统角色枚举 + * + */ +@Getter +@AllArgsConstructor +public enum RoleCode { + + /** + * 超级管理员 + */ + ROLE_SYSTEM_ADMIN("system_admin", 1, "超级管理员"), + /** + * 管理员 + */ + ROLE_ADMIN("admin",2, "管理员"), + /** + * 普通用户 + */ + ROLE_USER("user",3, "普通用户"), + /** + * 游客 + */ + ROLE_VISITOR("visitor", 4, "游客"); + + /** + * 角色编码 + */ + private final String code; + /** + * 角色值 + */ + private final Integer value; + /** + * 描述 + */ + private final String desc; + + /** + * 根据角色编码获取枚举 + * @param code 角色编码 + * @return 枚举 + */ + public static RoleCode getRoleCodeByCode(String code) { + return Arrays.stream(RoleCode.values()) + .filter(role -> role.getCode().equals(code)) + .findFirst() + .orElse(null); + } + + public static RoleCode getRoleCodeByValue(Integer value) { + return Arrays.stream(RoleCode.values()) + .filter(role -> role.getValue().equals(value)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/mikufufu/common/enums/SexType.java b/src/main/java/com/mikufufu/common/enums/SexType.java new file mode 100644 index 0000000..ef69a18 --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/SexType.java @@ -0,0 +1,43 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + + +@Getter +@AllArgsConstructor +public enum SexType { + + UNKNOWN(0,"未知"), + MAN(1,"男"), + WOMAN(2,"女"); + + private final Integer type; + private final String name; + + /** + * 根据type获取sexEnum + * @param type 类型 + * @return sexEnum {@link SexType} + */ + public static SexType getByType(Integer type) { + return Arrays.stream(SexType.values()) + .filter(sex -> sex.getType().equals(type)) + .findFirst() + .orElse(UNKNOWN); + } + + /** + * 根据type获取name + * @param type 类型 + * @return 类型名称 + */ + public static String getNameByType(Integer type) { + return Arrays.stream(SexType.values()) + .filter(sex -> sex.getType().equals(type)) + .findFirst() + .orElse(UNKNOWN).getName(); + } +} diff --git a/src/main/java/com/mikufufu/common/enums/UploadFileType.java b/src/main/java/com/mikufufu/common/enums/UploadFileType.java new file mode 100644 index 0000000..3349121 --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/UploadFileType.java @@ -0,0 +1,32 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文件类型枚举类 + * + */ + +@Getter +@AllArgsConstructor +public enum UploadFileType { + /** + * 图片 + */ + IMAGE("image"), + /** + * 视频 + */ + VIDEO("video"), + /** + * 音频 + */ + AUDIO("music"), + /** + * 文件 + */ + FILE("file"); + + private final String path; +} diff --git a/src/main/java/com/mikufufu/common/enums/UserStatus.java b/src/main/java/com/mikufufu/common/enums/UserStatus.java new file mode 100644 index 0000000..636d821 --- /dev/null +++ b/src/main/java/com/mikufufu/common/enums/UserStatus.java @@ -0,0 +1,33 @@ +package com.mikufufu.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserStatus { + + NORMAL(0, "正常"), + DISABLED(1, "禁用"), + LOCKED(2, "锁定"), + ; + + private final Integer code; + + private final String name; + + /** + * 根据code获取枚举 + * + * @param code code + * @return 枚举 + */ + public static String getName(int code) { + for (UserStatus value : UserStatus.values()) { + if (value.code == code) { + return value.name; + } + } + return null; + } +} diff --git a/src/main/java/com/mikufufu/common/exception/AuthException.java b/src/main/java/com/mikufufu/common/exception/AuthException.java new file mode 100644 index 0000000..c6b6db6 --- /dev/null +++ b/src/main/java/com/mikufufu/common/exception/AuthException.java @@ -0,0 +1,16 @@ +package com.mikufufu.common.exception; + +/** + * 认证异常 + * + */ +public class AuthException extends RuntimeException{ + + /** + * 权限认证异常 + * @param message 异常消息 + */ + public AuthException(String message) { + super(message); + } +} diff --git a/src/main/java/com/mikufufu/common/permission/PermissionHandle.java b/src/main/java/com/mikufufu/common/permission/PermissionHandle.java new file mode 100644 index 0000000..11b1429 --- /dev/null +++ b/src/main/java/com/mikufufu/common/permission/PermissionHandle.java @@ -0,0 +1,133 @@ +package com.mikufufu.common.permission; + +import com.mikufufu.common.annotation.AnonymousApi; +import com.mikufufu.core.utils.SpringUtils; +import com.mikufufu.modules.system.model.entity.Permission; +import com.mikufufu.modules.system.model.vo.PermissionRoleVO; +import com.mikufufu.modules.system.service.IPermissionService; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition; +import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.lang.reflect.Method; +import java.util.*; + +/** + * 权限处理类 + */ +@Slf4j +public class PermissionHandle { + + /** + * 存储权限角色列表的静态变量 + */ + @Getter + private static List permissionRoleList; + + /** + * 存储匿名权限列表的静态变量 + */ + @Getter + private static List anonymousPermissionList = null; + + public static void init(){ + initAnonymousPermissionList(); + loadDataSource(); + } + + /** + * 检查权限资源与权限的关系是否为空 + * @return true:为空 false:不为空 + */ + public static boolean permissionRoleListIsNull(){ + return permissionRoleList == null || permissionRoleList.isEmpty(); + } + + /** + * 检查给定的URL和HTTP方法是否有权限访问。 + * @param url 请求的URL + * @param method 请求的方法,包括GET和POST等 + * @return 如果请求的URL和方法有权限,则返回true;否则返回false。 + */ + public static boolean hasAnonymousPermission(String url,String method){ + log.info("检查权限资源与权限的关系:{} {}",url,method); + // Spring提供的用于匹配路径的匹配器 + AntPathMatcher antPathMatcher = new AntPathMatcher(); + // 检查是否匹配任一匿名权限列表中的权限 + return anonymousPermissionList.stream().anyMatch(permission -> antPathMatcher.match(permission.getPath(), url) && permission.getMethod().equalsIgnoreCase(method)); + } + + /** + * 初始化匿名权限列表。 + * 遍历所有映射的请求处理方法,检查是否有@Anonymous注解,若有则添加到匿名权限列表中。 + * {@code @PostConstruct}注解,在类实例化后执行 + */ + private static void initAnonymousPermissionList(){ + List permissionList = new ArrayList<>(); + // 获取所有映射的处理方法 + // @Qualifier("requestMappingHandlerMapping") + // 注入请求映射处理器映射 + Map handlerMethods = SpringUtils.getBean(RequestMappingHandlerMapping.class).getHandlerMethods(); + // 遍历处理方法,解析URL和请求方法,并检查是否有@Anonymous注解 + handlerMethods.forEach((k, v) -> { + Permission permission = new Permission(); + // 解析URL + PathPatternsRequestCondition pathPatternsCondition = k.getPathPatternsCondition(); + if (pathPatternsCondition == null){ + return; + } + // 当前patterns中只有一个pattern,所以直接取第一个即可 + pathPatternsCondition.getPatterns().forEach(path -> permission.setPath(path.getPatternString())); + RequestMethodsRequestCondition methodsCondition = k.getMethodsCondition(); + if (methodsCondition.getMethods().isEmpty()){ + return; + } + // 解析请求方法 + methodsCondition.getMethods().forEach(method -> permission.setMethod(method.name())); + Method method = v.getMethod(); + // 检查是否有@Anonymous注解 + if (method.isAnnotationPresent(AnonymousApi.class)){ + permissionList.add(permission); + } + }); + anonymousPermissionList = permissionList; + log.info("匿名权限列表:{}",anonymousPermissionList); + } + + /** + * 加载权限资源与权限的关系 + * {@code @PostConstruct} 注解用于在类被实例化后执行 + */ + public static void loadDataSource() { + // 获取权限的Service + IPermissionService permissionService = SpringUtils.getBean(IPermissionService.class); + // 从permissionService获取角色权限列表 + permissionRoleList = permissionService.getPermissionRoleList(); + if (permissionRoleListIsNull()){ + // 如果角色权限列表为空,则调用permissionService的saveAllPermissionOfController方法写入所有权限 + Boolean flag = permissionService.saveAllPermissionOfController(); + // 如果写入权限成功,则调用permissionService的grantAnonymousPermission方法写入web权限 + if(flag){ + permissionService.grantAnonymousPermission(); + permissionRoleList = permissionService.getPermissionRoleList(); + } + if (permissionRoleListIsNull()){ + permissionRoleList = Collections.emptyList(); + } + } + log.info("权限资源与权限的关系:{}", permissionRoleList); + } + + /** + * 清除权限资源与权限的关系 + */ + public static void clearDataSource() { + log.info("清除权限资源与权限的关系"); + permissionRoleList = null; + } +} diff --git a/src/main/java/com/mikufufu/config/AsyncConfig.java b/src/main/java/com/mikufufu/config/AsyncConfig.java new file mode 100644 index 0000000..fed826b --- /dev/null +++ b/src/main/java/com/mikufufu/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.mikufufu.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean("logTaskExecutor") + public Executor logTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); // 核心线程数 + executor.setMaxPoolSize(10); // 最大线程数 + executor.setQueueCapacity(100); // 队列容量 + executor.setThreadNamePrefix("Log-Async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/mikufufu/config/DataSourceConfig.java b/src/main/java/com/mikufufu/config/DataSourceConfig.java new file mode 100644 index 0000000..ce24f76 --- /dev/null +++ b/src/main/java/com/mikufufu/config/DataSourceConfig.java @@ -0,0 +1,45 @@ +package com.mikufufu.config; + +import com.alibaba.druid.pool.DruidDataSource; +import com.mikufufu.core.utils.AesUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; + +@Slf4j +@Configuration +public class DataSourceConfig { + + @Value("${spring.datasource.driver-class-name}") + private String driverClassName; + + @Value("${spring.datasource.url}") + private String url; + + @Value("${spring.datasource.username}") + private String username; + + @Value("${spring.datasource.password}") + private String password; // 存储的是加密后的密码 + + @Bean + public DruidDataSource dataSource() { + DruidDataSource dataSource = new DruidDataSource(); + dataSource.setDriverClassName(driverClassName); + dataSource.setUrl(url); + try { + dataSource.setUsername(AesUtils.decrypt(username)); + }catch (Exception e){ + log.error("数据库用户解密失败:{}",username); + dataSource.setUsername(username); + } + try { + dataSource.setPassword(AesUtils.decrypt(password)); // 解密密码 + }catch (Exception e){ + log.error("数据库密码解密失败:{}",password); + dataSource.setPassword(password); + } + return dataSource; + } +} diff --git a/src/main/java/com/mikufufu/config/InitConfig.java b/src/main/java/com/mikufufu/config/InitConfig.java new file mode 100644 index 0000000..7bab1f7 --- /dev/null +++ b/src/main/java/com/mikufufu/config/InitConfig.java @@ -0,0 +1,22 @@ +package com.mikufufu.config; + +import com.mikufufu.core.cache.SettingCache; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Configuration; + +/** + * 初始化配置 + * 修复Redis相关依赖和服务可能还未完全初始化完成,导致初始化失败。 + */ +@Slf4j +@Configuration +public class InitConfig implements ApplicationRunner { + + @Override + public void run(ApplicationArguments args) throws Exception { + log.debug("初始化设置信息"); + SettingCache.init(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mikufufu/config/MyBatisPlusConfig.java b/src/main/java/com/mikufufu/config/MyBatisPlusConfig.java new file mode 100644 index 0000000..7312a24 --- /dev/null +++ b/src/main/java/com/mikufufu/config/MyBatisPlusConfig.java @@ -0,0 +1,45 @@ +package com.mikufufu.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatisPlus配置类 + * + */ +@Configuration +public class MyBatisPlusConfig { + + /** + * 创建并配置MybatisPlusInterceptor,用于分页拦截。 + * + * @return MybatisPlusInterceptor 分页拦截器实例 + */ + @Bean + public MybatisPlusInterceptor paginationInterceptor(){ + // 初始化MybatisPlusInterceptor + MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); + + // 创建分页内部拦截器并配置 + PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(); + // 设置是否允许页数溢出,默认false意思是不允许 + paginationInnerInterceptor.setOverflow(false); + // 设置最大限制条数为不限制 + paginationInnerInterceptor.setMaxLimit(-1L); + // 设置数据库类型 + paginationInnerInterceptor.setDbType(DbType.MYSQL); + + // 将分页内部拦截器添加到MybatisPlusInterceptor中 + mybatisPlusInterceptor.addInnerInterceptor(paginationInnerInterceptor); + // 添加阻止攻击拦截器 + mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); + + return mybatisPlusInterceptor; + } + + +} diff --git a/src/main/java/com/mikufufu/config/RedisConfig.java b/src/main/java/com/mikufufu/config/RedisConfig.java new file mode 100644 index 0000000..99b6a8d --- /dev/null +++ b/src/main/java/com/mikufufu/config/RedisConfig.java @@ -0,0 +1,53 @@ +package com.mikufufu.config; + +import com.mikufufu.core.serializer.FastJson2JsonRedisSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * redis配置 + * + */ +@Configuration +public class RedisConfig{ + + /** + * 配置RedisTemplate + *

+ * redisTemplate方法是一个@Bean方法,@Bean注解括号中的参数用于指定RedisTemplate的名称。 + * 用于创建一个{@link RedisTemplate}对象并配置它。 + * 它接受一个{@link RedisConnectionFactory}参数作为Redis连接工厂。 + * 在方法内部,它创建了一个{@link FastJson2JsonRedisSerializer}对象用于序列化和反序列化Redis的value值, + * 并将其设置为RedisTemplate的值序列化器。 + * 同时,它还使用{@link StringRedisSerializer}来序列化和反序列化Redis的key值和Hash的key值。 + * 最后,它调用afterPropertiesSet方法来初始化{@link RedisTemplate}对象,并返回它。 + *

+ * @param connectionFactory Redis连接工厂 + * @return RedisTemplate对象 + */ + @Bean("redisTemplate") + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + // 创建一个RedisTemplate对象 + RedisTemplate template = new RedisTemplate<>(); + // 设置Redis连接工厂 + template.setConnectionFactory(connectionFactory); + + // 创建一个FastJson2JsonRedisSerializer对象,用于序列化和反序列化Redis的value值 + FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer<>(Object.class); + + // 使用StringRedisSerializer来序列化和反序列化redis的key值 + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(serializer); + + // Hash的key也采用StringRedisSerializer的序列化方式 + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(serializer); + + // afterPropertiesSet方法来初始化RedisTemplate对象,并返回它 + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/mikufufu/config/SecurityConfig.java b/src/main/java/com/mikufufu/config/SecurityConfig.java new file mode 100644 index 0000000..274d03e --- /dev/null +++ b/src/main/java/com/mikufufu/config/SecurityConfig.java @@ -0,0 +1,165 @@ +package com.mikufufu.config; + +import com.mikufufu.modules.auth.security.filter.IllegalRequestFilter; +import com.mikufufu.modules.auth.security.filter.TokenFilter; +import com.mikufufu.modules.auth.security.handler.AuthorizationManagerImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.Collections; +import java.util.stream.Stream; + +/** + * Spring Security配置类 + * + */ +@Slf4j +@Configuration // 表示这是一个配置类 +@EnableWebSecurity // 启用Spring Web安全功能 +@EnableMethodSecurity +public class SecurityConfig { + + /** + * 认证失败处理类:用于处理认证失败的情况。 + */ + @Autowired + private AuthenticationEntryPoint authenticationEntryPoint; + + /** + * 权限拒绝处理类:用于处理没有权限访问的情况。 + */ + @Autowired + private AccessDeniedHandler accessDeniedHandler; + + // 解决循环依赖的问题 + @Bean + public AuthorizationManager authorizationManager(){ + return new AuthorizationManagerImpl(); + } + + /** + * 获取匿名访问的权限列表。 + * 匿名权限即不需要用户登录即可访问的资源路径。 + * + * @return 返回一个包含所有匿名权限路径的列表。 + */ + private String[] anonymousGet(){ + // 初始化匿名权限路径列表,包括默认权限、Swagger文档权限 + String[] defaultPermission = {"/cache/**","/logout"}; + String[] swaggerPermission = {"/doc.html","/swagger-resources/**","/webjars/**","/v3/api-docs/**","/swagger-ui/**"}; + // 合并权限列表,去除重复项 + return Stream.of(defaultPermission, swaggerPermission) + .flatMap(Arrays::stream).distinct() + .toArray(String[]::new); + } + + /** + * 静态资源路径 + * 解决 ** 路径无法匹配的问题 + * @return AntPathRequestMatcher[] + */ + private AntPathRequestMatcher[] staticResources(){ + String[] staticPath = {"/","/*.html","/**/*.html","/**/*.js","/**/*.css","/favicon.ico","/image/**"}; + // 创建一个AntPathRequestMatcher数组,用于匹配静态资源路径 + AntPathRequestMatcher[] antPathRequestMatchers = new AntPathRequestMatcher[staticPath.length]; + // 遍历静态资源路径数组,为每个路径创建一个AntPathRequestMatcher对象 + for (int i = 0; i < staticPath.length; i++) { + antPathRequestMatchers[i] = new AntPathRequestMatcher(staticPath[i]); + } + // 返回AntPathRequestMatcher数组 + return antPathRequestMatchers; + } + + /** + * 配置CORS(跨源资源共享)设置,以允许来自特定源的Web请求访问应用程序。 + * 这个方法返回一个CorsConfigurationSource实例,其中包含了对跨域请求的配置。 + * @return UrlBasedCorsConfigurationSource 一个基于URL的CORS配置源,用于指定哪些URL路径应用此CORS配置。 + */ + @Bean + public CorsConfigurationSource configurationSource(){ + // 创建一个新的CORS配置对象 + CorsConfiguration corsConfiguration = new CorsConfiguration(); + // 设置允许的所有请求头 + corsConfiguration.setAllowedHeaders(Collections.singletonList("*")); + // 设置允许的所有请求方法 + corsConfiguration.setAllowedMethods(Collections.singletonList("*")); + // 设置允许的请求来源,这里指定了两个来源:本地开发环境和一个示例域名 +// corsConfiguration.setAllowedOrigins(Arrays.asList("http://localhost:8080","https://www.mikufufu.com")); + corsConfiguration.addAllowedOrigin("*"); + // 设置预检请求(OPTIONS请求)的缓存时间,单位为秒 + corsConfiguration.setMaxAge(3600L); + // 创建一个新的URL基于的CORS配置源 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + // 将之前配置的CORS配置应用到所有URL路径 + source.registerCorsConfiguration("/**",corsConfiguration); + // 返回配置源 + return source; + } + + + /** + * 配置安全过滤链 + * @param http 用于配置HttpSecurity的bean + * @return 返回配置好的SecurityFilterChain + * @throws Exception 配置过程中可能抛出的异常 + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // 配置CORS跨域资源共享 + .cors(cors -> cors.configurationSource(configurationSource())) + // 禁用CSRF保护 + .csrf(AbstractHttpConfigurer::disable) + // 禁用会话创建 + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 禁用security默认的注销接口,以解决重定向到login页面时,因为没有登录,所以会重定向到登录页面,但是登录页面没有权限,所以会抛出异常。 + .logout(AbstractHttpConfigurer::disable) + // 配置请求过滤规则 + .authorizeHttpRequests(authorize -> authorize + // 放行静态资源 + .requestMatchers(staticResources()).permitAll() + // 允许GET请求和POST请求 + .requestMatchers(HttpMethod.GET, anonymousGet()).permitAll() + .requestMatchers(HttpMethod.POST, "/login","/admin/login","/register").permitAll() + // 使用自定义的权限管理器进行权限检查 + .anyRequest().access(authorizationManager()) + ) + .addFilterBefore(new TokenFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new IllegalRequestFilter(),TokenFilter.class); + + // 允许iframe访问 + http.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)); + + http.exceptionHandling(exceptionHandling -> + exceptionHandling + // 认证失败处理 + .authenticationEntryPoint(authenticationEntryPoint) + // 权限不足处理 + .accessDeniedHandler(accessDeniedHandler)); + + // 构建并返回安全过滤链 + return http.build(); + } + +} + diff --git a/src/main/java/com/mikufufu/core/aop/LogAspect.java b/src/main/java/com/mikufufu/core/aop/LogAspect.java new file mode 100644 index 0000000..544c547 --- /dev/null +++ b/src/main/java/com/mikufufu/core/aop/LogAspect.java @@ -0,0 +1,169 @@ +package com.mikufufu.core.aop; + +import com.alibaba.fastjson2.JSON; +import com.mikufufu.common.annotation.Account; +import com.mikufufu.common.annotation.OperationLog; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.entity.LogEvent; +import com.mikufufu.common.enums.OperationType; +import com.mikufufu.core.utils.IpUtil; +import com.mikufufu.modules.auth.utils.AuthUtils; +import com.mikufufu.modules.system.model.entity.OperateLog; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Slf4j +@Aspect +@Component +public class LogAspect { + + @Autowired + private ApplicationEventPublisher eventPublisher; + + @Pointcut("@annotation(com.mikufufu.common.annotation.OperationLog)") + public void operationLogPointCut() { + } + + @AfterReturning(value = "operationLogPointCut()", returning = "result") + public void afterReturning(JoinPoint joinPoint, Object result) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + String[] paramNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + OperationLog operationLog = method.getAnnotation(OperationLog.class); + OperateLog operateLog = new OperateLog(); + setOperateLog(operationLog,method,paramNames,args,operateLog); + if (Objects.nonNull(result) && result instanceof AjaxResult){ + AjaxResult ajaxResult = (AjaxResult) result; + if (ajaxResult.getCode() == 200){ + operateLog.setStatus(1); + }else{ + OperationType type = operationLog.type(); + if (type == OperationType.LOGIN){ + return; + } + operateLog.setStatus(0); + operateLog.setErrorMessage(ajaxResult.getMsg()); + } + } + eventPublisher.publishEvent(new LogEvent(operateLog)); + + } + + /** + * 设置操作日志 + * @param operateLog 操作日志 + */ + private void setOperateLog(OperationLog operationLog, Method method, String[] paramNames,Object[] args,OperateLog operateLog) { + HttpServletRequest request = getRequest(); + if (request == null){ + throw new RuntimeException("获取请求失败"); + } + OperationType type = operationLog.type(); + operateLog.setModule(operationLog.module().getType()); + operateLog.setUrl(request.getRequestURI()); + setParam(operateLog, method,paramNames,args); + if (OperationType.LOGIN.equals(type)){ + setLoginLog(operateLog,args); + } + operateLog.setOperator(AuthUtils.getUsername()); + operateLog.setIp(IpUtil.getIpAddress(request)); + operateLog.setOperateType(type.getType()); + operateLog.setOperateTime(LocalDateTime.now()); + operateLog.setRemark(operationLog.description()); + } + + + /** + * 设置操作日志中的登录用户信息 + * + * @param operateLog 操作日志对象,用于设置操作员信息 + * @param args 方法参数数组,从中查找带有Account注解的字段来获取操作员信息 + */ + private void setLoginLog(OperateLog operateLog, Object[] args) { + // 遍历方法参数,查找带有Account注解的字段 + for(Object arg : args){ + Field[] fields = arg.getClass().getDeclaredFields(); + for (Field field : fields) { + // 判断字段是否带有Account注解 + if (field.isAnnotationPresent(Account.class)){ + try { + field.setAccessible(true); + operateLog.setOperator((String) field.get(arg)); + return; + }catch (Exception e){ + log.error("获取参数失败",e); + operateLog.setOperator(""); + return; + } + } + } + } + } + + /** + * 设置参数 + * @param operateLog 操作日志 + * @param method 方法 + * @param paramNames 参数名 + * @param args 参数值 + */ + private void setParam(OperateLog operateLog,Method method,String[] paramNames, Object[] args) { + // 如果参数为空,则直接返回 + if (args.length == 0){ + return; + } + // 如果只有一个参数,且是RequestBody注解,则直接返回 + if (args.length == 1 && AnnotationUtils.findAnnotation(method.getParameters()[0], RequestBody.class) != null){ + operateLog.setParams(JSON.toJSONString(args[0])); + return; + } + // 遍历参数,将参数名和参数值存入map中 + Map params = new HashMap<>(); + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof MultipartFile) { + continue; + } + if (args[i] instanceof HttpServletRequest || args[i] instanceof HttpServletResponse) { + continue; + } + if (args[i] == null || args[i] instanceof byte[]) { + continue; + } + params.put(paramNames[i], args[i]); + } + operateLog.setParams(JSON.toJSONString(params)); + } + + private HttpServletRequest getRequest() { + // 从当前请求中获取RequestAttributes + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes instanceof ServletRequestAttributes) { + return ((ServletRequestAttributes) requestAttributes).getRequest(); + } + return null; + } +} diff --git a/src/main/java/com/mikufufu/core/aop/WebAspect.java b/src/main/java/com/mikufufu/core/aop/WebAspect.java new file mode 100644 index 0000000..bafa345 --- /dev/null +++ b/src/main/java/com/mikufufu/core/aop/WebAspect.java @@ -0,0 +1,82 @@ +package com.mikufufu.core.aop; + + +import com.alibaba.fastjson2.JSON; +import com.mikufufu.common.entity.AjaxResult; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; + + +@Slf4j +@Aspect +@Component +@Order(1) // 数值越小优先级越高 +public class WebAspect { + + @Pointcut("execution(* com.mikufufu.modules..controller.*.*(..))") + public void controllerLog() { + } + + @Around("controllerLog()") + public Object around(ProceedingJoinPoint point) throws Throwable { + // 获取方法签名 + MethodSignature signature = (MethodSignature) point.getSignature(); + ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (requestAttributes == null) { + return point.proceed(); + } + HttpServletRequest request = requestAttributes.getRequest(); + String url = request.getRequestURI(); + log.info("请求{} :{}", request.getMethod(),url); + try { + // 获取参数类型数组(Class类型) + Class[] paramTypes = signature.getParameterTypes(); + // 获取参数值数组 + Object[] args = point.getArgs(); + String[] paramNames = signature.getParameterNames(); + for (int i = 0; i < args.length; i++) { + // 如果是MultipartFile类型,则打印文件名 + if (args[i] instanceof MultipartFile file) { + log.info("上传文件 ({}){} 文件名{}", paramTypes[i].getSimpleName(), paramNames[i], file.getOriginalFilename()); + continue; + } + // 忽略HttpServletRequest和HttpServletResponse + if (args[i] instanceof HttpServletRequest || args[i] instanceof HttpServletResponse) { + continue; + } + // 忽略null和byte[] + if (args[i] == null || args[i] instanceof byte[]) { + log.info("入参值为空或者是字节流 ({}){}", paramTypes[i].getSimpleName(), paramNames[i]); + continue; + } + log.info("入参 ({}){}:{}", paramTypes[i].getSimpleName(), paramNames[i], JSON.toJSONString(args[i])); + } + } catch (Exception e) { + log.error("接口{}入参解析失败", url, e); + } + long startTime = System.currentTimeMillis(); + Object result = point.proceed(); + long endTime = System.currentTimeMillis(); + try { + if (result instanceof AjaxResult) { + log.info("通用接口{}出参:{}", url, JSON.toJSONString(result)); + } + } catch (Exception e) { + log.error("接口{}出参解析失败", url, e); + } + log.info("接口{}执行结束,耗时{}ms", url, endTime - startTime); + return result; + } +} diff --git a/src/main/java/com/mikufufu/core/cache/RedisCache.java b/src/main/java/com/mikufufu/core/cache/RedisCache.java new file mode 100644 index 0000000..dfd4ebd --- /dev/null +++ b/src/main/java/com/mikufufu/core/cache/RedisCache.java @@ -0,0 +1,322 @@ +package com.mikufufu.core.cache; + +import com.alibaba.fastjson2.JSON; +import com.mikufufu.core.utils.RedisUtils; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * RedisCache类用于提供通用的Redis缓存功能。 + * 该类封装了与Redis缓存服务器交互的基本操作,以便于在应用程序中方便地使用Redis作为缓存机制。 + * + */ +@Slf4j +public class RedisCache { + + /** + * 将指定的键值对存储到Redis中。 + * @param key 键 + * @param value 值 + */ + public static void set(String key, T value){ + log.info("新增缓存 key={}, value={}", key, JSON.toJSONString(value)); + RedisUtils.set(key, value); + } + + /** + * 将指定的键值对存储到Redis中,并设置过期时间。 + * @param key 键 + * @param value 值 + * @param time 过期时间 单位秒 + */ + public static void set(String key, T value, long time){ + log.info("新增缓存 key={}, value={}, time={}", key, JSON.toJSONString(value), time); + RedisUtils.set(key, value, time); + } + + /** + * 将指定的键值对存储到Redis中,并设置过期时间(单位可指定)。 + * @param key 键 + * @param value 值 + * @param time 过期时间 + * @param timeUnit 时间单位 + */ + public static void set(String key, T value, long time, TimeUnit timeUnit){ + log.info("新增缓存 key={}, value={}, time={}, timeUnit={}", key, JSON.toJSONString(value), time, timeUnit); + RedisUtils.set(key, value, time, timeUnit); + } + + /** + * 将列表类型的价值存储到指定的键。 + * @param key 键 + * @param value 值列表 + */ + public static long setList(String key, List value){ + log.info("新增缓存 key={}, value={}", key, JSON.toJSONString(value)); + return RedisUtils.setList(key, value); + } + + /** + * 将一个Set集合存储到缓存中。 + * + * @param key 缓存的键,类型为String。 + * @param value 要存储的Set集合,集合元素的类型为泛型T。 + * 通过日志记录存储操作,将key和value序列化后记录在日志中。 + * 使用RedisUtils的setSet方法将key-value对存储到Redis的Set中。 + */ + public static void setSet(String key, Set value){ + log.info("新增缓存 key={}, value={}", key, JSON.toJSONString(value)); + RedisUtils.setSet(key, value); + } + + /** + * 向已存在的Set缓存中添加一个元素。 + * + * @param key 缓存的键,类型为String。 + * @param value 要添加到Set中的元素,元素的类型为泛型T。 + * @return 返回操作的结果,类型为long。如果添加成功,返回新的Set集合大小;如果添加失败,返回-1。 + * 通过日志记录添加操作,将key和value序列化后记录在日志中。 + * 使用RedisUtils的addSet方法向Redis的Set中添加元素,并返回新的Set大小。 + */ + public static long addSet(String key, T value){ + log.info("新增缓存 key={}, value={}", key, JSON.toJSONString(value)); + return RedisUtils.addSet(key, value); + } + + /** + * 通过键获取存储的值,需要指定值的类型。 + * @param key 键 + * @param clazz 值的类型 + * @return 类型转换后的值 + */ + public static T get(String key, Class clazz){ + log.info("获取缓存 key={}, clazz={}", key, clazz.getName()); + return RedisUtils.get(key, clazz); + } + + /** + * 通过键获取存储的值,返回原生类型。 + * @param key 键 + * @return 存储的值 + */ + public static Object get(String key){ + log.info("获取缓存 key={}", key); + return RedisUtils.get(key); + } + + /** + * 通过键获取存储的字符串类型值。 + * @param key 键 + * @return 字符串类型的值 + */ + public static String getString(String key){ + log.info("获取缓存 key={}", key); + return RedisUtils.get(key, String.class); + } + + /** + * 通过键获取存储的整数类型值。 + * @param key 键 + * @return 整数类型的值 + */ + public static Integer getInteger(String key){ + log.info("获取缓存 key={}", key); + return RedisUtils.get(key, Integer.class); + } + + /** + * 通过键获取存储的长整数类型值。 + * @param key 键 + * @return 长整数类型的值 + */ + public static Long getLong(String key){ + log.info("获取缓存 key={}", key); + return RedisUtils.get(key, Long.class); + } + + /** + * 通过键获取存储的列表类型值。 + * @param key 键 + * @return 列表类型的值 + */ + public static List getList(String key){ + log.info("获取缓存 key={}", key); + return RedisUtils.getList(key); + } + + public static Set getSet(String key){ + log.info("获取缓存 key={}", key); + return RedisUtils.getSet(key); + } + + /** + * 删除指定的键及其对应的值。 + * @param key 键 + * @return 删除操作是否成功 + */ + public static Boolean delete(String key){ + log.info("删除缓存 key={}", key); + return RedisUtils.delete(key); + } + + /** + * 批量删除指定的键。 + * @param keys 键集合 + * @return 删除操作是否成功 + */ + public static Boolean delete(Collection keys){ + log.info("删除缓存 keys={}", keys); + return RedisUtils.delete(keys); + } + + /** + * 检查指定的键是否存在。 + * @param key 键 + * @return 键是否存在 + */ + public static Boolean exists(String key){ + log.info("检查缓存 key={}", key); + return RedisUtils.exists(key); + } + + /** + * 获取当前数据库中的所有键。 + * @return 键列表 + */ + public static List keys(){ + log.info("获取缓存所有键"); + return RedisUtils.keys(); + } + + /** + * 根据模式匹配获取键列表。 + * @param pattern 模式 + * @return 符合模式的键列表 + */ + public static List keys(String pattern){ + log.info("获取缓存键列表 pattern={}", pattern); + return RedisUtils.keys(pattern); + } + + /** + * 根据前缀删除键。 + * @param prefix 键的前缀 + * @return 删除操作是否成功 + */ + public static Boolean deleteByPrefix(String prefix){ + log.info("删除缓存前缀 prefix={}", prefix); + return RedisUtils.deleteByPrefix(prefix); + } + + /** + * 清空Redis缓存。 + * @return 清空操作是否成功 + */ + public static Boolean flushAll(){ + log.info("清空缓存"); + return RedisUtils.deleteCache(); + } + + /** + * 在Redis中放入哈希表数据 + * + * @param key 哈希表的键 + * @param hashKey 哈希表中的字段键 + * @param value 哈希表中的字段值 + * @param 字段值的类型 + */ + public static void putHash(String key, String hashKey, T value) { + RedisUtils.putHash(key, hashKey, value); + } + + /** + * 在Redis中放入哈希表数据 + * + * @param key 哈希表的键 + * @param map 包含多个字段键值对的映射 + * @param 字段值的类型 + */ + public static void putHash(String key, Map map) { + RedisUtils.putHash(key, map); + } + + /** + * 从Redis中获取哈希表中的特定字段值 + * + * @param key 哈希表的键 + * @param hashKey 哈希表中的字段键 + * @param 字段值的类型 + * @return 字段值,如果不存在则返回null + */ + public static T getHash(String key, String hashKey) { + return RedisUtils.getHash(key, hashKey); + } + + /** + * 从Redis中获取整个哈希表的数据 + * + * @param key 哈希表的键 + * @param 字段值的类型 + * @return 包含所有字段键值对的映射 + */ + public static Map getHash(String key) { + return RedisUtils.getHash(key); + } + + /** + * 获取哈希表中的所有字段键 + * + * @param key 哈希表的键 + * @return 包含所有字段键的集合 + */ + public static Set getAllHash(String key){ + return RedisUtils.getAllHash(key); + } + + /** + * 获取哈希表中的所有字段值 + * + * @param key 哈希表的键 + * @param 字段值的类型 + * @return 包含所有字段值的列表 + */ + public static List getAllHashValue(String key) { + return RedisUtils.getAllHashValue(key); + } + + /** + * 删除Redis中的哈希表 + * + * @param key 哈希表的键 + * @return 如果哈希表存在且删除成功返回true,否则返回false + */ + public static Boolean deleteHash(String key) { + return RedisUtils.deleteHash(key); + } + + /** + * 删除哈希表中的一个或多个字段 + * + * @param key 哈希表的键 + * @param hashKeys 要删除的字段键数组 + * @return 如果字段存在且删除成功返回true,否则返回false + */ + public static Boolean deleteHash(String key, String... hashKeys) { + return RedisUtils.deleteHash(key, hashKeys); + } + + /** + * 发送消息到Redis消息队列 + * @param topic Redis消息队列的Topic + * @param msg 要发送的消息 + */ + public static void sendMsg(String topic, T msg) { + RedisUtils.sendMsg(topic,msg); + } +} + diff --git a/src/main/java/com/mikufufu/core/cache/SettingCache.java b/src/main/java/com/mikufufu/core/cache/SettingCache.java new file mode 100644 index 0000000..c4ba15d --- /dev/null +++ b/src/main/java/com/mikufufu/core/cache/SettingCache.java @@ -0,0 +1,57 @@ +package com.mikufufu.core.cache; + +import com.mikufufu.common.constant.RedisKey; +import com.mikufufu.core.utils.RedisUtils; +import com.mikufufu.core.utils.SpringUtils; +import com.mikufufu.modules.system.model.entity.Setting; +import com.mikufufu.modules.system.service.ISettingService; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Map; + +@Slf4j +public class SettingCache { + + private static final String SETTING_CACHE_KEY = RedisKey.SETTING; + + public static void init(){ + log.info("初始化系统设置缓存"); + List list = SpringUtils.getBean(ISettingService.class).list(); + log.info("系统设置缓存初始化完成,共有 {} 条数据",list.size()); + if (list.isEmpty()){ + log.warn("系统设置缓存为空,请检查数据库"); + return; + } + list.forEach(setting -> { + setSetting(setting.getCode(),setting.getValue()); + }); + } + + public static void setSetting(String key,String value){ + log.info("设置系统设置缓存:{} = {}",key,value); + RedisUtils.putHash(SETTING_CACHE_KEY,key,value); + } + + public static String getSetting(String key){ + log.info("获取系统设置缓存:{}",key); + return RedisUtils.getHash(SETTING_CACHE_KEY,key); + } + + public static String getSetting(String key,String defaultValue){ + log.info("获取系统设置缓存:{}",key); + if (RedisUtils.hasHashKey(SETTING_CACHE_KEY,key)){ + return getSetting(key); + } + return defaultValue; + } + + public static void flush(){ + log.info("刷新系统设置缓存"); + Map cachedSettings = RedisUtils.getAllHashEntity(SETTING_CACHE_KEY); + if (!cachedSettings.isEmpty()) { + log.info("准备将 {} 条缓存数据写入数据库", cachedSettings.size()); + SpringUtils.getBean(ISettingService.class).saveOrUpdateSetting(cachedSettings); + } + } +} diff --git a/src/main/java/com/mikufufu/core/cache/UserCache.java b/src/main/java/com/mikufufu/core/cache/UserCache.java new file mode 100644 index 0000000..88ac1b1 --- /dev/null +++ b/src/main/java/com/mikufufu/core/cache/UserCache.java @@ -0,0 +1,51 @@ +package com.mikufufu.core.cache; + +import com.alibaba.fastjson2.JSON; +import com.mikufufu.common.constant.RedisKey; +import com.mikufufu.core.utils.RedisUtils; +import com.mikufufu.core.utils.SpringUtils; +import com.mikufufu.modules.system.model.entity.User; +import com.mikufufu.modules.system.service.IUserService; +import lombok.extern.slf4j.Slf4j; + +/** + * 用户缓存 + * + */ +@Slf4j +public class UserCache { + + private static final String USER_CACHE_KEY = RedisKey.USER; + + public static void setUser(User user){ + log.info("设置用户缓存={}", JSON.toJSONString(user)); + RedisUtils.set(USER_CACHE_KEY + user.getId(), user); + } + + public static User getUser(Integer userId) { + log.info("获取用户缓存={}", userId); + String key = USER_CACHE_KEY + userId; + if (RedisUtils.exists(key)) { + return RedisUtils.get(key, User.class); + }else { + log.info("用户缓存不存在,从数据库获取"); + User user = SpringUtils.getBean(IUserService.class).getById(userId); + if (user == null) { + log.info("用户不存在"); + throw new RuntimeException("用户不存在"); + } + setUser(user); + return user; + } + } + + public static Boolean delete(Integer userId) { + log.info("删除指定id的用户缓存={}", userId); + return RedisUtils.delete(USER_CACHE_KEY + userId); + } + + public static Boolean flushAll() { + log.info("清空用户缓存"); + return RedisUtils.deleteByPrefix(USER_CACHE_KEY); + } +} diff --git a/src/main/java/com/mikufufu/core/exception/GlobalExceptionHandler.java b/src/main/java/com/mikufufu/core/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..46d084b --- /dev/null +++ b/src/main/java/com/mikufufu/core/exception/GlobalExceptionHandler.java @@ -0,0 +1,172 @@ +package com.mikufufu.core.exception; + +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.enums.ResultCode; +import com.mikufufu.common.exception.AuthException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.web.firewall.RequestRejectedException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * 全局异常处理 + * + *
+ * {@code @RestControllerAdvice}等价于{@code @ControllerAdvice}和{@code @ResponseBody} + * {@code @ControllerAdvice}用于全局异常处理,{@code @ResponseBody}返回json数据 + * + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 全局异常处理 + * @param request 请求对象 + * @param e 异常对象 + * @return 错误信息 + */ + @ExceptionHandler(value = {Exception.class}) + public AjaxResult exceptionHandler(HttpServletRequest request, Exception e) { + log.error("未知异常,请求地址:{}", request.getRequestURI(), e); + return AjaxResult.error(e.getMessage()); + } + + @ExceptionHandler(value = {AuthException.class}) + public AjaxResult authExceptionHandler(HttpServletRequest request, AuthException e) { + log.error("权限异常,请求地址:{},错误信息:{}", request.getRequestURI(), e.getMessage()); + return AjaxResult.error(ResultCode.FORBIDDEN.getCode(),e.getMessage()); + } + + @ExceptionHandler(value = {NullPointerException.class}) + public AjaxResult nullPointExceptionHandler(HttpServletRequest request, NullPointerException e) { + log.error("空指针异常,请求地址:{}", request.getRequestURI(), e); + return AjaxResult.error("空指针异常"); + } + + /** + * 参数解析失败异常处理 如{@code @RequestBody} + * @param ex 异常对象 + * @return 错误信息 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public AjaxResult handleHttpMessageNotReadableException(HttpServletRequest request,HttpMessageNotReadableException ex) { + log.error("参数解析失败异常,请求地址:{},错误信息:{}", request.getRequestURI(), ex.getMessage()); + return AjaxResult.error("参数解析失败异常: " + ex.getMessage()); + } + + /** + * 参数校验异常处理 + * @param e 异常对象 + * @return 错误信息 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public AjaxResult> handleValidationExceptions(HttpServletRequest request,MethodArgumentNotValidException e) { + String requestUri = request.getRequestURI(); + log.error("参数校验失败异常, 请求地址: {}, 错误详情: {}", requestUri, e.getMessage()); + Map errors = new HashMap<>(); + e.getBindingResult().getAllErrors().forEach(error -> { + String errorMessage = error.getDefaultMessage(); + String errorType = error.getClass().getSimpleName(); + Map fieldError = new HashMap<>(); + fieldError.put("message", errorMessage); + fieldError.put("type", errorType); + if (error instanceof FieldError) { + String fieldName = ((FieldError) error).getField(); + errors.put(fieldName, fieldError); + } else { + String objectName = error.getObjectName(); + errors.put(objectName, fieldError); + } + }); + return AjaxResult.error("参数校验异常处理",errors); + } + + /** + * 参数验证失败异常处理 如{@code @Validated} + * @param request 请求对象 + * @param e 错误对象 + * @return 错误信息 + */ + @ExceptionHandler(value = {BindException.class, ConstraintViolationException.class}) + public AjaxResult handleMethodArgumentNotValidException(HttpServletRequest request, Exception e) { + String requestUri = request.getRequestURI(); + log.error("参数验证失败异常, 请求地址: {}, 错误详情: {}", requestUri, e.getMessage(), e); + + String message = "未知参数错误"; + if (e instanceof BindException) { + BindingResult bindingResult = ((BindException) e).getBindingResult(); + if (bindingResult.hasErrors()) { + message = Optional.ofNullable(bindingResult.getFieldError()) + .map(FieldError::getDefaultMessage) + .orElse("未知参数错误"); + } + } else if (e instanceof ConstraintViolationException) { + Set> violations = ((ConstraintViolationException) e).getConstraintViolations(); + if (!violations.isEmpty()) { + message = violations.iterator().next().getMessage(); + } + } + return AjaxResult.error("参数验证失败异常: " + message); + } + + /** + * 缺少请求参数异常处理 如{@code @RequestParam} + * @param request 请求对象 + * @param e 错误对象 + * @return 错误信息 + */ + @ExceptionHandler(value = {MissingServletRequestParameterException.class}) + public AjaxResult missingServletRequestParameterExceptionHandler(HttpServletRequest request, MissingServletRequestParameterException e) { + log.error("缺少请求参数异常,请求地址:{},错误信息:{}", request.getRequestURI(), e.getMessage()); + return AjaxResult.error("缺少请求参数异常: " + e.getMessage()); + } + + /** + * 请求方法不支持异常处理 如{@code @RequestMapping(method = RequestMethod.POST)} + * @param request 请求对象 + * @param e 错误对象 + * @return 错误信息 + */ + @ExceptionHandler(value = {HttpRequestMethodNotSupportedException.class}) + public AjaxResult httpRequestMethodNotSupportedExceptionHandler(HttpServletRequest request, HttpRequestMethodNotSupportedException e) { + log.error("请求方法不支持异常,请求地址:{},错误信息:{}", request.getRequestURI(), e.getMessage()); + return AjaxResult.error("请求方法不支持异常: " + e.getMessage()); + } + + /** + * 请求路径不存在异常处理 + * @param request 请求对象 + * @param e 错误对象 + * @return 错误信息 + */ + @ExceptionHandler(value = {NoHandlerFoundException.class}) + public AjaxResult noHandlerFoundExceptionHandler(HttpServletRequest request, NoHandlerFoundException e) { + log.error("请求路径不存在异常,请求地址:{},错误信息:{}", request.getRequestURI(), e.getMessage()); + return AjaxResult.error("请求路径不存在异常: " + e.getMessage()); + } + + @ExceptionHandler(RequestRejectedException.class) + public AjaxResult handleRequestRejectedException(RequestRejectedException ex, HttpServletRequest request) { + log.warn("Security firewall blocked request: {}", request.getRequestURI()); + return AjaxResult.error("Security firewall blocked request: " + ex.getMessage()); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/mikufufu/core/listener/LogEvenListener.java b/src/main/java/com/mikufufu/core/listener/LogEvenListener.java new file mode 100644 index 0000000..f34fbfe --- /dev/null +++ b/src/main/java/com/mikufufu/core/listener/LogEvenListener.java @@ -0,0 +1,23 @@ +package com.mikufufu.core.listener; + +import com.mikufufu.common.entity.LogEvent; +import com.mikufufu.modules.system.service.IOperateLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class LogEvenListener { + + @Autowired + private IOperateLogService operateLogService; + + @Async("logTaskExecutor") // 指定线程池 + @EventListener + public void onApplicationEvent(LogEvent event) { + operateLogService.save(event.getOperateLog()); + } +} diff --git a/src/main/java/com/mikufufu/core/serializer/FastJson2JsonRedisSerializer.java b/src/main/java/com/mikufufu/core/serializer/FastJson2JsonRedisSerializer.java new file mode 100644 index 0000000..95d7573 --- /dev/null +++ b/src/main/java/com/mikufufu/core/serializer/FastJson2JsonRedisSerializer.java @@ -0,0 +1,59 @@ +package com.mikufufu.core.serializer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONException; +import com.alibaba.fastjson2.JSONReader; +import com.alibaba.fastjson2.JSONWriter; +import com.alibaba.fastjson2.filter.Filter; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.SerializationException; + +import java.nio.charset.StandardCharsets; + +/** + * Redis使用FastJson序列化 + * + */ +public class FastJson2JsonRedisSerializer implements RedisSerializer { + + private final Class clazz; + + private static final Filter AUTO_TYPE_FILTER = JSONReader.autoTypeFilter( + "com.mikufufu.model.", + "com.mikufufu.modules.", + "com.mikufufu.common." + ); + + public FastJson2JsonRedisSerializer(Class clazz) { + super(); + this.clazz = clazz; + } + + @Override + public byte[] serialize(T t) throws SerializationException { + // 如果对象为空,则返回空字节数组 + if (t == null) + { + return new byte[0]; + } + // 使用FastJson将对象序列化为JSON字符串,并转换为字节数组 + return JSON.toJSONString(t, JSONWriter.Feature.WriteClassName).getBytes(StandardCharsets.UTF_8); + } + + @Override + public T deserialize(byte[] bytes) throws SerializationException { + // 如果字节数组为空或长度为0,则返回空对象 + if (bytes == null || bytes.length <= 0) + { + return null; + } + // 将字节数组转换为字符串 + String str = new String(bytes, StandardCharsets.UTF_8); + // 使用FastJson将字符串反序列化为对象 + try { + return JSON.parseObject(str, clazz, AUTO_TYPE_FILTER, JSONReader.Feature.FieldBased, JSONReader.Feature.SupportClassForName); + } catch (JSONException e) { + throw new SerializationException("将JSON字符串反序列化为对象时出错", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mikufufu/core/utils/AesUtils.java b/src/main/java/com/mikufufu/core/utils/AesUtils.java new file mode 100644 index 0000000..a6cd391 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/AesUtils.java @@ -0,0 +1,95 @@ +package com.mikufufu.core.utils; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class AesUtils { + + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + + private static final String KEY = "MIKUFUFU1010MENG"; + private static final String IV = "MOXIAOLIMIKUFUFU"; + + /** + * 使用默认密钥和IV加密明文数据 + * + * @param plaintext 待加密的原始文本(UTF-8编码) + * @return Base64编码的加密结果字符串 + * @throws Exception 当加密过程失败时抛出异常 + */ + public static String encrypt(String plaintext) throws Exception { + return encrypt(plaintext, KEY, IV); + } + + /** + * 使用默认密钥和IV解密密文数据 + * + * @param ciphertext Base64编码的加密结果字符串 + * @return 解密后的原始文本(UTF-8编码) + * @throws Exception 当解密过程失败时抛出异常 + */ + public static String decrypt(String ciphertext) throws Exception { + return decrypt(ciphertext, KEY, IV); + } + + /** + * 使用AES算法加密明文数据 + * + * @param plaintext 待加密的原始文本(UTF-8编码) + * @param key AES密钥,长度必须为16/24/32字节(对应128/192/256位) + * @param iv 初始化向量(IV),长度必须与算法块大小一致(通常16字节) + * @return Base64编码的加密结果字符串 + * @throws Exception 当密钥不符合规范或加密过程失败时抛出异常 + */ + public static String encrypt(String plaintext, String key, String iv) throws Exception { + validateKey(key); + // 初始化密钥材料和加密参数 + SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES"); + IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes()); + + // 配置加密器并执行加密操作 + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + return Base64.getEncoder().encodeToString(encrypted); + } + + /** + * 解密AES加密的Base64字符串 + * + * @param ciphertext Base64编码的加密字符串 + * @param key AES密钥,必须与加密时使用的密钥一致 + * @param iv 初始化向量,必须与加密时使用的IV一致 + * @return 解密后的原始文本(UTF-8编码) + * @throws Exception 当密钥不符合规范或解密过程失败时抛出异常 + */ + public static String decrypt(String ciphertext, String key, String iv) throws Exception { + validateKey(key); + // 准备解密所需的密钥材料和参数 + SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES"); + IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes()); + + // 配置解密器并执行解密流程 + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); + byte[] decoded = Base64.getDecoder().decode(ciphertext); + + return new String(cipher.doFinal(decoded), StandardCharsets.UTF_8); + } + + /** + * 验证AES密钥长度的有效性 + * @param key 待验证的密钥字符串 + * @throws IllegalArgumentException 当密钥长度不符合AES标准时抛出 + */ + private static void validateKey(String key) { + if (key.length() != 16 && key.length() != 24 && key.length() != 32) { + throw new IllegalArgumentException("密钥长度必须为16/24/32字节"); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/mikufufu/core/utils/BeanUtil.java b/src/main/java/com/mikufufu/core/utils/BeanUtil.java new file mode 100644 index 0000000..f84d972 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/BeanUtil.java @@ -0,0 +1,52 @@ +package com.mikufufu.core.utils; + +import org.springframework.beans.BeanUtils; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Bean工具类,用于复制对象属性值到目标对象。 + * + */ +public class BeanUtil { + + /** + * 复制对象属性值到目标对象。 + * + * @param source 源对象,其属性值将被复制。 + * @param target 目标对象,将接收源对象的属性值。 + * @param 目标对象的类型。 + * @return 返回经过属性值复制后的目标对象。 + */ + public static T copy(Object source, T target) { + // 使用BeanUtils工具类的copyProperties方法,将source对象的属性值复制到target对象 + BeanUtils.copyProperties(source, target); + return target; + } + + /** + * 将源对象复制到目标类型的新对象中。 + * + * @param source 源对象,需要被复制的内容。 + * @param target 目标对象的类型,用于创建新对象并复制源对象的内容。 + * @return 复制后的新对象,其类型为参数target指定的类型。 + * @param 目标对象的类型。 + */ + public static T copy(Object source, Class target) { + return copy(source, BeanUtils.instantiateClass(target)); + } + + /** + * 将源对象列表复制到目标类型的新列表中。 + * + * @param source 源对象列表,每个对象都需要被复制到目标类型的新对象中。 + * @param target 目标对象的类型,用于创建新对象并复制源对象的内容。 + * @return 复制后的新对象列表,其中每个对象的类型为参数target指定的类型。 + * @param 目标对象的类型。 + */ + public static List copyList(List source, Class target) { + // 通过流处理源对象列表,对每个对象进行复制,并收集到新的列表中 + return source.stream().map(s -> copy(s, target)).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/mikufufu/core/utils/CollUtils.java b/src/main/java/com/mikufufu/core/utils/CollUtils.java new file mode 100644 index 0000000..e4ac914 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/CollUtils.java @@ -0,0 +1,64 @@ +package com.mikufufu.core.utils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class CollUtils { + + /** + * 转为一个可变 List(Arrays.asList 不可变) + */ + public static List asList(T[] ary) { + if (ary == null) { + return null; + } else { + List list = new ArrayList<>(ary.length); + Collections.addAll(list, ary); + return list; + } + } + + /** + * 创建一个可变 List + * @SafeVarargs注解用于抑制编译器对可变参数数组的警告。由于Java泛型的类型擦除机制, + * 使用泛型可变参数时编译器会警告可能存在类型安全问题。该注解告诉编译器这个方法内部不会对可变参数数组进行不安全的操作,可以安全地抑制相关警告。 + */ + @SafeVarargs + public static List newArrayList(E... elements) { + if (elements == null) { + throw new NullPointerException(); + } else { + List list = new ArrayList<>(elements.length); + Collections.addAll(list, elements); + return list; + } + } + + /** + * 创建一个新的ArrayList实例,包含指定可迭代元素的所有元素 + * + * @param elements 要添加到新列表中的可迭代元素,可以为null + * @param 列表元素的类型 + * @return 包含所有指定元素的新ArrayList实例,如果输入为null则返回null + */ + public static List newArrayList(Iterable elements) { + if (elements == null) { + throw new NullPointerException(); + } else { + // 如果输入是Collection类型,直接使用Collection构造函数创建ArrayList + if (elements instanceof Collection) { + return new ArrayList<>((Collection) elements); + } else { + // 如果输入是Iterable但不是Collection,逐个添加元素 + List list = new ArrayList<>(); + for (E e : elements) { + list.add(e); + } + return list; + } + } + } + +} diff --git a/src/main/java/com/mikufufu/core/utils/EasyExcelExportUtil.java b/src/main/java/com/mikufufu/core/utils/EasyExcelExportUtil.java new file mode 100644 index 0000000..b52da74 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/EasyExcelExportUtil.java @@ -0,0 +1,162 @@ +package com.mikufufu.core.utils; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.write.metadata.WriteSheet; +import com.alibaba.excel.write.metadata.style.WriteCellStyle; +import com.alibaba.excel.write.style.HorizontalCellStyleStrategy; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.util.List; +import java.util.function.Consumer; + +public class EasyExcelExportUtil { + + /** + * 通用 Excel 导出方法(适用于 Web 下载) + * @param response HttpServletResponse 对象 + * @param fileName 文件名(无需后缀) + * @param data 数据集合 + * @param head 表头定义(List> 结构) + * @param customStyle 自定义样式处理(可为null) + */ + public static void exportToWeb(HttpServletResponse response, + String fileName, + List data, + List> head, + Consumer customStyle) throws IOException { + configureResponse(response, fileName); + + try (OutputStream outputStream = response.getOutputStream()) { + ExcelWriter excelWriter = EasyExcel.write(outputStream) + .head(head) + .registerWriteHandler(createDefaultStyleStrategy()) + .build(); + + if (customStyle != null) { + customStyle.accept(excelWriter); + } + + WriteSheet writeSheet = EasyExcel.writerSheet("Sheet1").build(); + excelWriter.write(data, writeSheet); + excelWriter.finish(); + } + } + + /** + * 基于实体类注解的导出(自动生成表头) + * @param response HttpServletResponse + * @param fileName 文件名 + * @param data 数据集合 + * @param clazz 实体类类型 + */ + public static void exportByAnnotation(HttpServletResponse response, + String fileName, + List data, + Class clazz) throws IOException { + configureResponse(response, fileName); + + EasyExcel.write(response.getOutputStream(), clazz) + .registerWriteHandler(createDefaultStyleStrategy()) + .sheet("Sheet1") + .doWrite(data); + } + + /** + * 写入本地文件(服务端生成文件) + * @param filePath 完整文件路径(包含后缀) + * @param data 数据集合 + * @param head 表头定义 + */ + public static void exportToFile(String filePath, + List data, + List> head) { + EasyExcel.write(filePath) + .head(head) + .registerWriteHandler(createDefaultStyleStrategy()) + .sheet("Sheet1") + .doWrite(data); + } + + /** + * 带模板的导出(复杂报表场景) + * @param templatePath 模板路径 + * @param outputStream 输出流 + * @param data 填充数据 + */ + public static void exportWithTemplate(String templatePath, + OutputStream outputStream, + Object data) { + EasyExcel.write(outputStream) + .withTemplate(templatePath) + .registerWriteHandler(createDefaultStyleStrategy()) + .sheet() + .doFill(data); + } + + // 创建默认样式策略(表头居中,内容左对齐) + private static HorizontalCellStyleStrategy createDefaultStyleStrategy() { + WriteCellStyle headStyle = new WriteCellStyle(); + headStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); + + WriteCellStyle contentStyle = new WriteCellStyle(); + contentStyle.setHorizontalAlignment(HorizontalAlignment.LEFT); + + return new HorizontalCellStyleStrategy(headStyle, contentStyle); + } + + // 配置响应头信息 + private static void configureResponse(HttpServletResponse response, + String fileName) throws IOException { + String encodedFileName = URLEncoder.encode(fileName, "UTF-8") + .replaceAll("\\+", "%20"); + + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + response.setHeader("Content-disposition", + "attachment;filename*=utf-8''" + encodedFileName + ".xlsx"); + } + + /** + * 并发安全的大数据量导出(分页查询模式) + * @param response HttpServletResponse + * @param fileName 文件名 + * @param head 表头 + * @param pageSize 每页大小 + * @param dataSupplier 分页数据提供函数(接收页码,返回数据列表) + */ + public static void exportLargeData(HttpServletResponse response, + String fileName, + List> head, + int pageSize, + PageDataSupplier dataSupplier) throws IOException { + configureResponse(response, fileName); + + try (OutputStream outputStream = response.getOutputStream()) { + ExcelWriter excelWriter = EasyExcel.write(outputStream) + .head(head) + .registerWriteHandler(createDefaultStyleStrategy()) + .build(); + + WriteSheet writeSheet = EasyExcel.writerSheet("Sheet1").build(); + int pageNo = 1; + while (true) { + List data = dataSupplier.getPage(pageNo, pageSize); + if (data == null || data.isEmpty()) { + break; + } + excelWriter.write(data, writeSheet); + pageNo++; + } + excelWriter.finish(); + } + } + + @FunctionalInterface + public interface PageDataSupplier { + List getPage(int pageNo, int pageSize); + } +} diff --git a/src/main/java/com/mikufufu/core/utils/EncryptionUtils.java b/src/main/java/com/mikufufu/core/utils/EncryptionUtils.java new file mode 100644 index 0000000..42e605e --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/EncryptionUtils.java @@ -0,0 +1,95 @@ +package com.mikufufu.core.utils; + +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * 加密工具类 + * + */ +@Slf4j +public class EncryptionUtils { + + /** + * MD5加密 + * @param source 需要加密的字符串 + * @return 结果 String 加密后的字符串 + */ + public static String md5(String source){ + try { + // 获取 MD5 算法实例对象 + MessageDigest messageDigest = MessageDigest.getInstance("MD5"); + byte[] md5 = messageDigest.digest(source.getBytes(StandardCharsets.UTF_8)); + // 将处理后的字节转成 16 进制,得到最终 32 个字符 + StringBuilder stringBuilder = new StringBuilder(); + for (byte b : md5) { + stringBuilder.append(String.format("%02x", b)); + } + return stringBuilder.toString(); + } catch (NoSuchAlgorithmException e) { + log.error("MD5 加密失败", e); + } + return null; + } + + /** + * MD5加密 + * @param source 需要加密的字符串 + * @param salt 盐 + * @return 结果 String 加密后的字符串 + */ + public static String md5(String source, String salt) { + return md5(source + salt); + } + + + /** + * 检查给定的源字符串和盐值经过MD5加密后是否与给定的编码字符串匹配。 + * + * @param sourceStr 源字符串,即将被加密的字符串。 + * @param salt 盐值,用于加强加密的字符串。 + * @param encodedStr 加密字符串,用于比对的已编码字符串。 + * @return boolean 如果给定的源字符串和盐值加密后与编码字符串匹配,则返回true;否则返回false。 + */ + public static boolean md5Matches(String sourceStr, String salt, String encodedStr) { + // 根据源字符串和盐值计算MD5值,并与给定的编码字符串进行比较 + return encodedStr.equals(md5(sourceStr, salt)); + } + + /** + * SHA-256加密 + * @param source 需要加密的字符串 + * @return 结果 String 加密后的字符串 + */ + public static String sha256(String source){ + try { + // 获取 SHA-256 算法实例对象 + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + byte[] sha256 = messageDigest.digest(source.getBytes(StandardCharsets.UTF_8)); + // 将处理后的字节转成 16 进制,得到最终 32 个字符 + StringBuilder stringBuilder = new StringBuilder(); + for (byte b : sha256) { + stringBuilder.append(String.format("%02x", b)); + } + return stringBuilder.toString(); + + } catch (NoSuchAlgorithmException e) { + log.error("SHA-256 加密失败", e); + } + return null; + } + + /** + * 判断是否与加密值相同 SHA-256 + * @param sourceStr 源字符串 + * @param encodedStr 加密后字符 + * @return 结果 + */ + public static boolean sha256Matches(String sourceStr, String encodedStr) { + return encodedStr.equals(sha256(sourceStr)); + } + +} diff --git a/src/main/java/com/mikufufu/core/utils/FileUtils.java b/src/main/java/com/mikufufu/core/utils/FileUtils.java new file mode 100644 index 0000000..a39a886 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/FileUtils.java @@ -0,0 +1,136 @@ +package com.mikufufu.core.utils; + +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.FileNameMap; +import java.net.URLConnection; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; + +/** + * 文件工具类 + * 用于文件操作 + * 比如文件大小转换等 + * + */ +@Slf4j +public class FileUtils { + + private static final FileNameMap mimeMap = URLConnection.getFileNameMap(); + + // 使用ThreadLocal来存储每个线程独有的DecimalFormat实例,避免多线程下的并发问题 + private static final ThreadLocal DECIMAL_FORMAT_THREAD_LOCAL = ThreadLocal.withInitial(() -> { + // 初始化DecimalFormat,使用美国 Locale 以确保小数分隔符为点 + DecimalFormat decimalFormat = new DecimalFormat("#.##", new DecimalFormatSymbols(Locale.US)); + // 设置不使用千位分隔符 + decimalFormat.setGroupingUsed(false); + return decimalFormat; + }); + + /** + * 将字节大小转换为更易读的文件大小格式 + * 此方法根据文件大小选择合适的单位(B,KB,MB,GB),并格式化为两位小数 + * 注意:此方法不支持超过1TB的文件大小转换 + * + * @param size 字节大小 + * @return {@link String} 转换后的文件大小 + * @throws IllegalArgumentException 如果文件大小超过1TB + */ + public static String convertFileSize(long size) { + // 检查文件大小是否超过1TB,如果超过则抛出异常 + if (size > 1024L * 1024 * 1024 * 1024) { + throw new IllegalArgumentException("文件超过1TB"); + } + + // 获取当前线程的DecimalFormat实例 + DecimalFormat decimalFormat = DECIMAL_FORMAT_THREAD_LOCAL.get(); + + // 根据文件大小,选择合适的单位进行转换 + if (size < 1024) { + return size + "B"; + } else if (size < 1024 * 1024) { + return decimalFormat.format(size / 1024.0) + "KB"; + } else if (size < 1024 * 1024 * 1024) { + return decimalFormat.format(size / (1024.0 * 1024)) + "MB"; + } else { + return decimalFormat.format(size / (1024.0 * 1024 * 1024)) + "GB"; + } + } + + /** + * 将输入流内容转换为字符串 + * + * @param inputStream 输入流对象,方法执行完毕后会自动关闭 + * @param charset 用于解码字节数据的字符集 + * @return 使用指定字符集解码后的字符串 + * @throws IOException 当发生I/O错误时抛出 + * 实现逻辑: + * 1. 使用try-with-resources自动管理输入流 + * 2. 通过缓冲区循环读取输入流内容 + * 3. 将读取的字节数据转换为字符串 + */ + public static String readFileToString(InputStream inputStream, Charset charset) throws IOException { + try (InputStream is = inputStream) { // 使用 try-with-resources + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + + // 循环读取数据直到流结束 + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + // 将缓存的字节数据转换为字符串 + return new String(outputStream.toByteArray(), charset); + } // 自动关闭 InputStream + } + + /** + * 使用UTF-8编码将输入流内容转换为字符串 + * + * @param inputStream 输入流对象,方法执行完毕后会自动关闭 + * @return 使用UTF-8字符集解码后的字符串 + * @throws IOException 当发生I/O错误时抛出 + */ + public static String readFileToString(InputStream inputStream) throws IOException { + return readFileToString(inputStream, StandardCharsets.UTF_8); + } + + + /** + * 读取文件内容为字符串(默认 UTF-8 编码) + */ + public static String readFileToString(Path path) throws IOException { + return readFileToString(path, StandardCharsets.UTF_8); + } + + /** + * 读取文件内容为字符串(支持自定义编码) + */ + public static String readFileToString(Path path, Charset charset) throws IOException { + byte[] bytes = Files.readAllBytes(path); + return new String(bytes, charset); + } + + /** + * 获取MIME + * + * @param fileName 文件名 + */ + public static String mime(String fileName) { + String tmp = mimeMap.getContentTypeFor(fileName); + if (tmp == null) { + return "application/octet-stream"; + } else { + return tmp; + } + } +} diff --git a/src/main/java/com/mikufufu/core/utils/IpUtil.java b/src/main/java/com/mikufufu/core/utils/IpUtil.java new file mode 100644 index 0000000..f488182 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/IpUtil.java @@ -0,0 +1,116 @@ +package com.mikufufu.core.utils; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +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.lang.reflect.Method; +import java.net.*; + +@Slf4j +@Component +public class IpUtil { + + private static Method method; + + + /** + * 从HttpServletRequest中获取客户端真实IP地址 + * + * 方法按优先级尝试从多个代理服务器标准头字段中获取IP地址,处理多层代理和本地访问场景。 + * 注意:当存在多个IP时会截取第一个有效IP(按逗号分隔) + * + * @param request HTTP请求对象,用于获取请求头信息和远程地址 + * @return 处理后的客户端IP地址字符串(可能去除端口和多余代理信息) + */ + public static String getIpAddress(HttpServletRequest request) { + String ipAddress = request.getHeader("X-Real-IP"); + // 按优先级检查代理服务器头信息 + if (StringUtils.isBlank(ipAddress) || "unknown".equalsIgnoreCase(ipAddress)) { + // 处理x-forwarded-for头信息,常用在Nginx代理服务器上 + ipAddress = request.getHeader("x-forwarded-for"); + } + if (StringUtils.isBlank(ipAddress) || "unknown".equalsIgnoreCase(ipAddress)) { + // 处理Proxy-Client-IP头信息,常用于Apache代理服务器上 + ipAddress = request.getHeader("Proxy-Client-IP"); + } + if (StringUtils.isBlank(ipAddress) || "unknown".equalsIgnoreCase(ipAddress)) { + // 处理WL-Proxy-Client-IP头信息,常用于WebLogic代理服务器上 + ipAddress = request.getHeader("WL-Proxy-Client-IP"); + } + if (StringUtils.isBlank(ipAddress) || "unknown".equalsIgnoreCase(ipAddress)) { + // 处理HTTP_CLIENT_IP头信息,用于Apache代理服务器上 + ipAddress = request.getHeader("HTTP_CLIENT_IP"); + } + if (StringUtils.isBlank(ipAddress) || "unknown".equalsIgnoreCase(ipAddress)) { + // 处理HTTP_X_FORWARDED_FOR头信息,用于Nginx代理服务器上 + ipAddress = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + + // 当所有代理头都不存在时,使用远程地址并处理本地访问场景 + if (StringUtils.isBlank(ipAddress) || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getRemoteAddr(); + if ("127.0.0.1".equals(ipAddress) || "0:0:0:0:0:0:0:1".equals(ipAddress)) { + // 处理本地回环地址,尝试获取本机实际网络IP + InetAddress inet = null; + try { + inet = InetAddress.getLocalHost(); + } catch (UnknownHostException e) { + log.error("getIpAddress exception:", e); + } + assert inet != null; + ipAddress = inet.getHostAddress(); + } + } + + // 处理可能存在的多个IP地址(如x-forwarded-for的多值情况) + return StringUtils.substringBefore(ipAddress, ","); + } + + /** + * 从HttpServletRequest中获取客户端真实IP地址 + * + * 方法按优先级尝试从多个代理服务器标准头字段中获取IP地址,处理多层代理和本地访问场景。 + * 注意:当存在多个IP时会截取第一个有效IP(按逗号分隔) + * @return 处理后的客户端IP地址字符串(可能去除端口和多余代理信息) + */ + public static String getIpAddress() { + // 从当前请求中获取RequestAttributes + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes instanceof ServletRequestAttributes) { + return getIpAddress(((ServletRequestAttributes) requestAttributes).getRequest()); + } + return ""; + } + + /** + * Ping 一个地址 + * + * @param address (例:192.168.1.1 或 192.168.1.1:8080) + */ + public static boolean ping(String address) throws Exception { + if (address.contains(":")) { + String host = address.split(":")[0]; + int port = Integer.parseInt(address.split(":")[1]); + + try (Socket socket = new Socket()) { + SocketAddress addr = new InetSocketAddress(host, port); + socket.connect(addr, 3000); + return true; + } catch (IOException e) { + return false; + } + } else { + return InetAddress.getByName(address).isReachable(3000); + } + } + + public static String getUserAgent(HttpServletRequest request) { + return request.getHeader("User-Agent"); + } + +} diff --git a/src/main/java/com/mikufufu/core/utils/MailUtils.java b/src/main/java/com/mikufufu/core/utils/MailUtils.java new file mode 100644 index 0000000..1714adb --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/MailUtils.java @@ -0,0 +1,182 @@ +package com.mikufufu.core.utils; + +import jakarta.mail.*; +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.angus.mail.util.MailSSLSocketFactory; +import jakarta.validation.constraints.NotNull; + +import java.security.GeneralSecurityException; +import java.util.Date; +import java.util.List; +import java.util.Properties; + +/** + * Java邮件发送工具类(支持SSL/TLS加密) + * 使用示例: + * {@code MailUtils.sendEmail("smtp.qq.com", 465, true,"发件邮箱@qq.com", "授权码","邮件标题", "

HTML内容

",Lists.newArrayList("xxx@qq.com"),Lists.newArrayList("xxx@qq.com"),Lists.newArrayList("xxx@qq.com"));} + */ +@Slf4j +public class MailUtils { + + /** + * 发送邮件核心方法 + * @param host SMTP服务器地址(如smtp.qq.com) + * @param port SMTP端口(465/587) + * @param sslEnabled 是否启用SSL加密 + * @param username 发件邮箱(完整地址) + * @param password 邮箱密码/授权码 + * @param subject 邮件标题 + * @param content 邮件内容(支持HTML) + * @param recipients 收件人列表 + * @param ccRecipients 抄送人列表 + * @param bccRecipients 密送人列表 + */ + public static void sendEmail(String host, int port, boolean sslEnabled, + String username, String password, + String subject, String content, + List recipients, + List ccRecipients, + List bccRecipients + ) throws MessagingException { + Properties props = getProps(host, port, sslEnabled); + + sendEmail(props, username, password, subject, content, recipients, ccRecipients, bccRecipients); + } + + /** + * 构建邮件内容 + * @param props 邮件配置 + * @param username 发件邮箱(完整地址) + * @param password 邮箱密码/授权码 + * @param subject 邮件标题 + * @param content 邮件内容(支持HTML) + * @param recipients 收件人列表 + * @param ccRecipients 抄送人列表 + * @param bccRecipients 密送人列表 + */ + public static void sendEmail(Properties props, + final String username,final String password, + String subject, String content, + List recipients, + List ccRecipients, + List bccRecipients) throws MessagingException{ + Session session = Session.getInstance(props, new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + + // 构建邮件内容 + MimeMessage message = new MimeMessage(session); + message.setFrom(new InternetAddress(username)); + message.setSubject(subject, "UTF-8"); + message.setSentDate(new Date()); + + // 设置收件人(支持多个) + InternetAddress[] addresses = recipients.stream() + .map(email -> { + try { + return new InternetAddress(email); + } catch (AddressException e) { + throw new RuntimeException("邮箱地址格式错误: " + email); + } + }) + .toArray(InternetAddress[]::new); + message.setRecipients(Message.RecipientType.TO, addresses); + + // 抄送(支持多个) + if (ccRecipients != null && !ccRecipients.isEmpty()) { + InternetAddress[] ccAddresses = ccRecipients.stream() + .map(email -> { + try { + return new InternetAddress(email); + } catch (AddressException e) { + throw new RuntimeException("邮箱地址格式错误: " + email); + } + }) + .toArray(InternetAddress[]::new); + message.setRecipients(Message.RecipientType.CC, ccAddresses); + } + + // 密送(支持多个) + if (bccRecipients != null && !bccRecipients.isEmpty()) { + InternetAddress[] bccAddresses = bccRecipients.stream() + .map(email -> { + try { + return new InternetAddress(email); + } catch (AddressException e) { + throw new RuntimeException("邮箱地址格式错误: " + email); + } + }) + .toArray(InternetAddress[]::new); + message.setRecipients(Message.RecipientType.BCC, bccAddresses); + } + + // 设置HTML内容 + message.setContent(content, "text/html;charset=UTF-8"); + + // 发送邮件 + Transport.send(message); + } + + /** + * 发送邮件核心方法 + * @param host SMTP服务器地址(如smtp.qq.com) + * @param port SMTP端口(465/587) + * @param sslEnabled 是否启用SSL加密 + * @param username 发件邮箱(完整地址) + * @param password 邮箱密码/授权码 + * @param subject 邮件标题 + * @param content 邮件内容(支持HTML) + * @param recipients 收件人列表 + */ + public static void sendEmail(String host, int port, boolean sslEnabled, + String username, String password, + String subject, String content, + List recipients) throws MessagingException { + sendEmail(host, port, sslEnabled, username, password, subject, content, recipients, null, null); + } + + /** + * 获取邮件配置 + * @param host SMTP服务器地址(如smtp.qq.com) + * @param port SMTP端口(465/587) + * @param sslEnabled 是否启用SSL加密 + * @return 邮件配置 + */ + @NotNull + private static Properties getProps(String host, int port, boolean sslEnabled) { + Properties props = new Properties(); + // 基础配置 + props.put("mail.smtp.host", host); + props.put("mail.smtp.port", port); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.connectiontimeout", 5000); + props.put("mail.smtp.timeout", 5000); + + // SSL加密配置 + if (sslEnabled) { + props.put("mail.smtp.ssl.enable", "true"); +// props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory"); + try { + MailSSLSocketFactory sf = new MailSSLSocketFactory(); + sf.setTrustAllHosts(true); + props.put("mail.smtp.ssl.socketFactory", sf); + } catch (GeneralSecurityException e) { + log.error("创建MailSSLSocketFactory失败", e); + } + props.put("mail.smtp.socketFactory.port", port); + } + + // TLS加密配置 + if (!sslEnabled) { + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.smtp.starttls.required", "true"); + } + return props; + } +} \ No newline at end of file diff --git a/src/main/java/com/mikufufu/core/utils/RedisUtils.java b/src/main/java/com/mikufufu/core/utils/RedisUtils.java new file mode 100644 index 0000000..5f95c87 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/RedisUtils.java @@ -0,0 +1,423 @@ +package com.mikufufu.core.utils; + +import com.alibaba.fastjson2.JSON; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * Redis工具类,提供对Redis简单操作的方法封装。 + *

+ * 含{@code @SuppressWarnings}注解的代码段。value 该参数指定了要抑制的警告类型,可以是多个类型。 + * 例如,{@code "unchecked"}用于抑制未检查类型转换的警告,{@code "rawtypes"}用于抑制使用原始类型而不是泛型的警告。 + * 该注释的目的是解释为什么需要抑制这些特定的警告,以及在没有其他更好解决方法的情况下,这种做法的合理性。 + * 如果不希望抑制这些警告,可以使用其他方法来避免它们。 + *

+ * + */ +@SuppressWarnings(value = { "unchecked", "rawtypes" }) +public class RedisUtils { + + // RedisTemplate实例,用于执行Redis操作。 + private final static RedisTemplate redisTemplate = (RedisTemplate) SpringUtils.getBean("redisTemplate"); + + /** + * 将给定的值设置到指定的键上。 + * @param key 键名 + * @param value 要设置的值 + */ + public static void set(String key, T value) { + redisTemplate.opsForValue().set(key, value); + } + + /** + * 将给定的值设置到指定的键上,并设定过期时间。 + * @param key 键名 + * @param value 要设置的值 + * @param time 过期时间, 单位为秒 + */ + public static void set(String key, T value, long time) { + redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS); + } + + /** + * 将给定的值设置到指定的键上,并设定过期时间。 + * @param key 键名 + * @param value 要设置的值 + * @param time 过期时间 long + * @param timeUnit 过期时间单位 + */ + public static void set(String key, T value, long time, TimeUnit timeUnit) { + redisTemplate.opsForValue().set(key, value, time, timeUnit); + } + + /** + * 获取指定键的值。 + * @param key 键名 + * @return 键对应的值 + */ + public static Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + /** + * 获取指定键的值,并将其转换为指定类型。 + * @param key 键名 + * @param clazz 需要转换成的目标类型 + * @param 目标类型 + * @return 键对应的值,转换后的类型为clazz指定的类型 + */ + public static T get(String key, Class clazz) { + return StringUtils.convert(redisTemplate.opsForValue().get(key), clazz); + } + + /** + * 将指定列表值添加到Redis列表的末尾(右侧)。 + * + * @param key Redis中列表的键名。 + * @param value 要添加到列表中的值,该值是一个泛型列表。 + * @return 添加成功后列表的长度,如果添加前列表不存在则返回0。 + */ + public static long setList(String key, List value) { + // 使用RedisTemplate向指定key的列表末尾添加所有value的元素 + Long count = redisTemplate.opsForList().rightPushAll(key, value); + // 如果count为null,表示添加前列表不存在,返回0;否则返回添加后的列表长度 + return count == null ? 0: count; + } + + /** + * 将指定列表值添加到Redis列表的末尾(右侧)。 + * + * @param key Redis中列表的键名。 + * @param value 要添加到列表中的值,该值是一个泛型列表。 + * @param time 添加后过期时间,单位为秒。 + * @return 添加成功后列表的长度,如果添加前列表不存在则返回0。 + */ + public static long setList(String key, List value, Long time) { + // 使用RedisTemplate向指定key的列表末尾添加所有value的元素 + Long count = redisTemplate.opsForList().rightPushAll(key, value); + if (time != null && time > 0) { + // 如果time大于0,则设置过期时间 + redisTemplate.expire(key, time, TimeUnit.SECONDS); + } + // 如果count为null,表示添加前列表不存在,返回0;否则返回添加后的列表长度 + return count == null ? 0: count; + } + + /** + * 将指定列表值添加到Redis列表的末尾(右侧)。 + * + * @param key Redis中列表的键名。 + * @param value 要添加到列表中的值,该值是一个泛型列表。 + * @param time 添加后过期时间,单位为秒。 + * @param timeUnit 添加后过期时间单位。 + * @return 添加成功后列表的长度,如果添加前列表不存在则返回0。 + */ + public static long setList(String key, List value, Long time, TimeUnit timeUnit) { + // 使用RedisTemplate向指定key的列表末尾添加所有value的元素 + Long count = redisTemplate.opsForList().rightPushAll(key, value); + if (time != null && time > 0) { + // 如果time大于0,则设置过期时间 + redisTemplate.expire(key, time, timeUnit); + } + // 如果count为null,表示添加前列表不存在,返回0;否则返回添加后的列表长度 + return count == null ? 0: count; + } + + /** + * 向Redis的列表类型数据中添加元素。 + * + * @param key Redis中列表的键名。 + * @param value 要添加到列表中的元素。 + * @return 如果添加前列表不存在,返回0;否则返回添加后的列表长度。 + * @param 列表中元素的类型。 + */ + public static long addList(String key, T value) { + // 向指定key的列表中添加value元素 + Long count = redisTemplate.opsForList().rightPush(key, value); + // 判断添加结果,若为null表示列表之前不存在,返回0;否则返回添加的元素数量 + return count == null ? 0: count; + } + + /** + * 向Redis的Set类型数据中添加元素。 + * + * @param key Redis中Set的键名。 + * @param value 要添加到Set中的元素集合。 + * @return 如果添加前Set不存在,返回0;否则返回添加后的Set长度。 + * @param Set中元素的类型。 + */ + public static long setSet(String key, Set value) { + // 向指定key的Set中添加所有value的元素 + Long count = redisTemplate.opsForSet().add(key, value.toArray()); + // 判断添加结果,若为null表示Set之前不存在,返回0;否则返回添加的元素数量 + return count == null ? 0: count; + } + + public static long addSet(String key, T value) { + // 向指定key的Set中添加value元素 + Long count = redisTemplate.opsForSet().add(key, value); + // 判断添加结果,若为null表示Set之前不存在,返回0;否则返回添加的元素数量 + return count == null ? 0: count; + } + + /** + * 从Redis列表中获取指定键的所有元素。 + * + * @param key 列表的键名,对应Redis中的键。 + * @return 返回一个包含列表所有元素的List,类型为泛型T。 + */ + public static List getList(String key) { + // 通过redisTemplate操作Redis列表,获取key对应列表的所有元素 + return redisTemplate.opsForList().range(key, 0, -1); + } + + /** + * 根据给定的键从Redis中获取集合并返回。 + * + * @param key 用于获取集合的Redis键。 + * @return 返回与给定键关联的集合。如果键不存在,返回空集合。 + * @param 集合元素的类型。 + */ + public static Set getSet(String key) { + // 从Redis中获取指定key的集合成员 + return redisTemplate.opsForSet().members(key); + } + + /** + * 删除指定键的值。 + * @param key 键名 + */ + public static Boolean delete(String key) { + return redisTemplate.delete(key); + } + + /** + * 批量删除Redis中的键值对。 + * + * @param keys 要删除的键的集合,键的类型为String。 + * @return 返回一个布尔值,如果删除成功(即删除的键数量大于0),则返回true;否则返回false。 + */ + public static Boolean delete(Collection keys) { + // 从Redis中删除指定的键,并返回删除的键的数量 + Long count = redisTemplate.delete(keys); + // 判断删除的键的数量是否大于0,若大于0则返回true,否则返回false + return count > 0; + } + + /** + * 获取所有匹配给定模式的键名。 + * + * @param pattern 键名模式,可以使用通配符,例如"*"表示所有键。 + * @return 匹配模式的键名列表。 + */ + public static List keys(String pattern) { + // 通过RedisTemplate获取所有匹配pattern的键名,转换为ArrayList并返回 + return new ArrayList<>(Objects.requireNonNull(redisTemplate.keys(pattern))); + } + + /** + * 获取所有键名。 + * + * @return 所有键名的列表。 + */ + public static List keys() { + // 调用keys方法,传入"*"作为模式,以获取所有键名 + return keys("*"); + } + + /** + * 在Redis中放入哈希表数据 + * + * @param key 哈希表的键 + * @param hashKey 哈希表中的字段键 + * @param value 哈希表中的字段值 + * @param 字段值的类型 + */ + public static void putHash(String key, String hashKey, T value) { + redisTemplate.opsForHash().put(key, hashKey, value); + } + + /** + * 在Redis中放入哈希表数据 + * + * @param key 哈希表的键 + * @param map 包含多个字段键值对的映射 + * @param 字段值的类型 + */ + public static void putHash(String key, Map map) { + redisTemplate.opsForHash().putAll(key, map); + } + + /** + * 从Redis中获取哈希表中的特定字段值 + * + * @param key 哈希表的键 + * @param hashKey 哈希表中的字段键 + * @param 字段值的类型 + * @return 字段值,如果不存在则返回null + */ + public static T getHash(String key, String hashKey) { + return (T) redisTemplate.opsForHash().get(key, hashKey); + } + + /** + * 从Redis中获取整个哈希表的数据 + * + * @param key 哈希表的键 + * @param 字段值的类型 + * @return 包含所有字段键值对的映射 + */ + public static Map getHash(String key) { + return redisTemplate.opsForHash().entries(key); + } + + /** + * 获取哈希表中的所有字段键 + * + * @param key 哈希表的键 + * @return 包含所有字段键的集合 + */ + public static Set getAllHash(String key){ + return redisTemplate.opsForHash().keys(key); + } + + /** + * 获取哈希表中的所有字段值 + * + * @param key 哈希表的键 + * @param 字段值的类型 + * @return 包含所有字段值的列表 + */ + public static List getAllHashValue(String key) { + return redisTemplate.opsForHash().values(key); + } + + /** + * 获取哈希表中的所有字段键值对 + * + * @param key 哈希表的键 + * @param 字段值的类型 + * @return 包含所有字段键值对的映射 + */ + public static Map getAllHashEntity(String key) { + return redisTemplate.opsForHash().entries(key); + } + + /** + * 删除Redis中的哈希表 + * + * @param key 哈希表的键 + * @return 如果哈希表存在且删除成功返回true,否则返回false + */ + public static Boolean deleteHash(String key) { + return redisTemplate.opsForHash().delete(key) > 0; + } + + /** + * 删除哈希表中的一个或多个字段 + * + * @param key 哈希表的键 + * @param hashKeys 要删除的字段键数组 + * @return 如果字段存在且删除成功返回true,否则返回false + */ + public static Boolean deleteHash(String key, String... hashKeys) { + return redisTemplate.opsForHash().delete(key, (Object) hashKeys) > 0; + } + + /** + * 发送消息到Redis消息队列 + * + * @param topic Redis消息队列的Topic + * @param msg 要发送的消息 + * @param 消息的类型 + */ + public static void sendMsg(String topic, T msg) { + redisTemplate.convertAndSend(topic, JSON.toJSONString(msg)); + } + + /** + * 根据指定前缀删除键值对 + * @param prefix 键名的前缀 + * @return 如果删除成功则返回true,否则返回false + */ + public static Boolean deleteByPrefix(String prefix) { + // 获取所有匹配指定前缀的键名 + List keys = keys(prefix + "*"); + return delete(keys); + } + + /** + * 删除使用的Redis缓存。 + * @return true 删除成功 false删除失败 + */ + public static boolean deleteCache() { + // 获取所有键名 + List keys = keys("*"); + // 删除所有键值对 + return delete(keys); + } + + /** + * 设置Redis键的过期时间 + * + * @param key Redis的键 + * @param time 过期时间,单位为秒 + * @return 返回是否成功设置过期时间 + */ + public static Boolean setExpirationTime(String key, long time){ + return redisTemplate.expire(key, time, TimeUnit.SECONDS); + } + + /** + * 设置Redis键的过期时间 + * + * @param key Redis的键 + * @param time 过期时间,单位为秒 + * @return 返回是否成功设置过期时间 + */ + public static Boolean setExpirationTime(String key, long time, TimeUnit timeUnit){ + return redisTemplate.expire(key, time, timeUnit); + } + + /** + * 获取Redis键的剩余过期时间 + * + * @param key Redis的键 + * @return 剩余过期时间,单位为秒,如果键不存在或没有设置过期时间,则返回-2;如果键设置了过期时间,则返回剩余过期时间。 + */ + public static Long getExpirationTime(String key){ + return redisTemplate.getExpire(key); + } + + /** + * 获取Redis键的剩余过期时间 + * + * @param key Redis的键 + * @param timeUnit 时间单位 + * @return 剩余过期时间,单位为秒,如果键不存在或没有设置过期时间,则返回-2;如果键设置了过期时间,则返回剩余过期时间。 + */ + public static Long getExpirationTime(String key, TimeUnit timeUnit){ + return redisTemplate.getExpire(key,timeUnit); + } + + /** + * 判断缓存中是否有对应的value + * @return true 存在 false不存在 + */ + public static boolean exists(String key) { + return redisTemplate.hasKey(key); + } + + /** + * 判断缓存中是否有对应的value + * @param key 缓存的key + * @param hashKey 哈希key + * @return true 存在 false不存在 + */ + public static boolean hasHashKey(String key, String hashKey) { + return redisTemplate.opsForHash().hasKey(key, hashKey); + } + +} diff --git a/src/main/java/com/mikufufu/core/utils/RegexUtils.java b/src/main/java/com/mikufufu/core/utils/RegexUtils.java new file mode 100644 index 0000000..af654e7 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/RegexUtils.java @@ -0,0 +1,145 @@ +package com.mikufufu.core.utils; + +import com.mikufufu.common.constant.RegexStr; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.MatchResult; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class RegexUtils { + + // 使用 ConcurrentHashMap 缓存编译后的正则表达式对象,以提高性能 + private static final ConcurrentHashMap patternCache = new ConcurrentHashMap<>(); + + /** + * 检查字符串是否与给定的正则表达式匹配 + * + * @param str 要匹配的字符串 + * @param regex 正则表达式 + * @return 如果字符串与正则表达式匹配,则返回 true;否则返回 false + * @throws IllegalArgumentException 如果正则表达式无效 + */ + public static boolean match(String str, String regex) { + // 检查输入参数是否为空,为空则直接返回 false + if (StringUtils.isBlank(str) || StringUtils.isBlank(regex)) { + return false; + } + + // 简单的正则表达式验证,防止注入攻击 + if (!regex.matches("^[a-zA-Z0-9.*?+@_{}()|\\[\\]^$\\\\,%-]*$")) { + throw new IllegalArgumentException("Invalid regex pattern"); + } + + // 从缓存中获取或编译正则表达式对象 + Pattern pattern = getPattern(regex); + // 使用正则表达式对象匹配字符串,并返回匹配结果 + return pattern.matcher(str).matches(); + } + + /** + * 替换标识符的值 + * + * @param destinationString 目标字符串 + * @param valueMap 标识符和值映射 + * @return 替换后的字符串 + */ + public static String replaceIdentifierValue(String destinationString, Map valueMap) { + // 检查输入参数是否为空,为空则直接返回 + if (StringUtils.isEmpty(destinationString) || valueMap == null || valueMap.isEmpty()) { + return destinationString; + } + + // 使用正则表达式进行匹配和替换 + Pattern pattern = getPattern("\\$\\{([^}]+)}"); + Matcher matcher = pattern.matcher(destinationString); + + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + String key = matcher.group(1); + String replacement = valueMap.getOrDefault(key, ""); + matcher.appendReplacement(result, replacement); + } + matcher.appendTail(result); + + return result.toString(); + } + + public static String findFirstMatch(String regex, String input) { + Pattern pattern = getPattern(regex); + Matcher matcher = pattern.matcher(input); + if (matcher.find()) { + return matcher.group(); + } + return null; + } + + public static String[] findAllMatch(String regex, String input) { + Pattern pattern = getPattern(regex); + Matcher matcher = pattern.matcher(input); + return matcher.results().map(MatchResult::group).toArray(String[]::new); + } + + + /** + * 从缓存中获取或编译正则表达式对象 + * + * @param regex 正则表达式 + * @return 编译后的正则表达式对象 + */ + private static Pattern getPattern(String regex) { + // 如果缓存中不存在该正则表达式,则编译并添加到缓存中 + return patternCache.computeIfAbsent(regex, Pattern::compile); + } + + + /** + * 验证手机号码是否合法 + * @param mobile 待验证的手机号码 + * @return 如果手机号码合法,返回true;否则返回false。 + */ + public static boolean isMobile(String mobile) { + // 使用正则表达式进行手机号码的匹配 + return match(mobile, RegexStr.MOBILE); + } + + /** + * 验证邮箱地址是否合法 + * @param email 待验证的邮箱地址 + * @return 如果邮箱地址合法,返回true;否则返回false。 + */ + public static boolean isEmail(String email) { + // 使用正则表达式进行邮箱的匹配 + return match(email, RegexStr.EMAIL); + } + + /** + * 验证密码是否合法 + * @param password 待验证的密码 + * @return 如果密码合法,返回true;否则返回false。 + */ + public static boolean isPassword(String password) { + return match(password, RegexStr.PASSWORD); + } + + /** + * 验证URL地址是否合法 + * @param url 待验证的URL地址 + * @return 如果URL地址合法,返回true;否则返回false。 + */ + public static boolean isUrl(String url) { + // 使用正则表达式进行URL的匹配 + return match(url, RegexStr.URL); + } + + /** + * 验证QQ号码是否合法 + * @param qq 待验证的QQ号码 + * @return 如果QQ号码合法,返回true;否则返回false。 + */ + public static boolean isQQ(String qq) { + // 使用正则表达式进行QQ号的匹配 + return match(qq, RegexStr.QQ); + } +} diff --git a/src/main/java/com/mikufufu/core/utils/RsaUtils.java b/src/main/java/com/mikufufu/core/utils/RsaUtils.java new file mode 100644 index 0000000..e26e5d4 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/RsaUtils.java @@ -0,0 +1,76 @@ +package com.mikufufu.core.utils; + +import javax.crypto.Cipher; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +public class RsaUtils { + + // 密钥算法类型 + private static final String ALGORITHM = "RSA"; + // 加密填充方式 + private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; + // 密钥长度(推荐2048位) + private static final int KEY_SIZE = 2048; + + /** + * 生成RSA密钥对 + * @return 包含Base64编码公钥私钥的Map + */ + public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(ALGORITHM); + keyPairGen.initialize(KEY_SIZE, new SecureRandom()); + return keyPairGen.generateKeyPair(); + } + + /** + * 公钥加密 + * @param plainText 明文 + * @param publicKey Base64编码的公钥字符串 + * @return Base64编码的密文 + */ + public static String encrypt(String plainText, String publicKey) throws Exception { + byte[] keyBytes = java.util.Base64.getDecoder().decode(publicKey); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + PublicKey pubKey = KeyFactory.getInstance(ALGORITHM).generatePublic(keySpec); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, pubKey); + return java.util.Base64.getEncoder().encodeToString(cipher.doFinal(plainText.getBytes())); + } + + /** + * 私钥解密 + * @param cipherText Base64编码的密文 + * @param privateKey Base64编码的私钥字符串 + * @return 解密后的明文 + */ + public static String decrypt(String cipherText, String privateKey) throws Exception { + byte[] keyBytes = java.util.Base64.getDecoder().decode(privateKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + PrivateKey priKey = KeyFactory.getInstance(ALGORITHM).generatePrivate(keySpec); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, priKey); + byte[] decrypted = cipher.doFinal(java.util.Base64.getDecoder().decode(cipherText)); + return new String(decrypted, StandardCharsets.UTF_8); + } + + /** + * 获取Base64编码的公钥字符串 + */ + public static String getPublicKeyString(KeyPair keyPair) { + return java.util.Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + } + + /** + * 获取Base64编码的私钥字符串 + */ + public static String getPrivateKeyString(KeyPair keyPair) { + return Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + } + +} diff --git a/src/main/java/com/mikufufu/core/utils/SpringUtils.java b/src/main/java/com/mikufufu/core/utils/SpringUtils.java new file mode 100644 index 0000000..94c2d55 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/SpringUtils.java @@ -0,0 +1,129 @@ +package com.mikufufu.core.utils; + +import jakarta.validation.constraints.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.lang.annotation.Annotation; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Date; +import java.util.Map; + +/** + * 这个类用于提供一个全局的获取Spring ApplicationContext的方法 + * + */ +@Component // 标记为Spring组件 +public class SpringUtils implements ApplicationContextAware { + + // 静态的ApplicationContext引用 + private static ApplicationContext context; + + // 提供一个全局的获取ApplicationContext的方法 + public static ApplicationContext getApplicationContext() { + return context == null ? null : context; + } + + // 实现ApplicationContextAware接口的方法,Spring会自动调用这个方法,传入ApplicationContext + @Override + public void setApplicationContext(@NotNull ApplicationContext applicationContext) throws BeansException { + // 将ApplicationContext保存到静态变量中 + context = applicationContext; + } + + /** + * 根据bean的class获取bean + * @param clazz bean的class + * @return bean + */ + public static T getBean(Class clazz) { + return clazz == null ? null : context.getBean(clazz); + } + + /** + * 根据bean的id获取bean + * @param beanId bean的id + * @return bean + */ + public static Object getBean(String beanId) { + return beanId == null ? null : context.getBean(beanId); + } + + /** + * 根据bean的id和class获取bean + * @param beanName bean的id + * @param clazz bean的class + * @return bean + */ + public static T getBean(String beanName, Class clazz) { + if (null != beanName && !beanName.trim().isEmpty()) { + return clazz == null ? null : context.getBean(beanName, clazz); + } else { + return null; + } + } + + /** + * 获取包含指定注解的所有Bean的映射。 + * + * @param clazz 指定的注解类,用于查询包含该注解的所有Bean。 + * @return 返回一个映射,其中键是Bean的名称,值是Bean的实例。如果指定的注解类为null,则返回null。 + */ + public static Map getBeansWithAnnotation(Class clazz) { + // 判断注解类是否为null,为null则直接返回null,否则查询并返回包含该注解的所有Bean + return clazz == null ? null : context.getBeansWithAnnotation(clazz); + } + + /** + * 获取当前环境 + * @return 当前环境 + */ + public static String getActiveProfile() { + return context.getEnvironment().getActiveProfiles()[0]; + } + + /** + * 获取当前属性名对应的属性值 + * @param key 属性名 + * @return 属性值 + */ + public static String getProperty(String key) { + return context.getEnvironment().getProperty(key); + } + + /** + * 获取当前的环境对象。 + * + * @return Environment 返回当前的环境对象,该对象包含了系统的环境配置信息。 + */ + public static Environment getEnvironment() { + // 从上下文中获取环境对象 + return context.getEnvironment(); + } + + + /** + * 获取当前环境的ip + * @return 当前ip + */ + public static String getHost(){ + try { + return InetAddress.getLocalHost().getHostAddress() + ":" + getProperty("server.port"); + } catch (UnknownHostException e) { + throw new RuntimeException("获取当前环境的ip失败"); + } + } + + /** + * 获取系统启动时间 + * @return 返回系统启动的时间戳,单位为毫秒。 + */ + public static Date getStartupTime() { + return new Date(context.getStartupDate()); + } + +} diff --git a/src/main/java/com/mikufufu/core/utils/StringUtils.java b/src/main/java/com/mikufufu/core/utils/StringUtils.java new file mode 100644 index 0000000..29b69d0 --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/StringUtils.java @@ -0,0 +1,415 @@ +package com.mikufufu.core.utils; + +import jakarta.validation.constraints.NotNull; +import org.springframework.lang.Nullable; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 字符串工具类 + * + */ +public class StringUtils { + + /** + * 判断给定对象是否为空。 + * @param object 待检查的对象,可以是null,或者以下类型之一:String、Collection、Map、CharSequence、数组。 + * @return 如果对象为null,或者为空字符串(String)、空集合(Collection)、空映射(Map)、空字符序列(CharSequence)、空数组,返回true;否则返回false。 + */ + public static boolean isEmpty(@Nullable Object object) { + if (object == null) { + // 对象为null时,认为为空 + return true; + } else if (object instanceof String) { + // 对象为String类型时,使用String的isEmpty()方法判断 + return ((String) object).isEmpty(); + } else if (object instanceof Collection) { + // 对象为Collection类型时,使用Collection的isEmpty()方法判断 + return ((Collection) object).isEmpty(); + } else if (object instanceof Map) { + // 对象为Map类型时,使用Map的isEmpty()方法判断 + return ((Map) object).isEmpty(); + } else if (object instanceof CharSequence) { + // 对象为CharSequence类型时,判断其长度是否为0 + return ((CharSequence) object).isEmpty(); + } else if (object.getClass().isArray()){ + // 对象为数组时,判断数组长度是否为0 + return ((Object[])object).length == 0; + } else { + // 对于其他类型,均不认为为空 + return false; + } + } + + /** + * 判断实体类是否不为空 + * @param object 实体类 + * @return true=实体类不为空;false=实体类为空 + */ + public static boolean isNotEmpty(@Nullable Object object) { + return !isEmpty(object); + } + + /** + * 判断字符串是否为空白 + * @param str 字符串 + * @return true=字符串为空白;false=字符串不为空白 + */ + public static boolean isBlank(String str) { + // 此处的str.trim().isEmpty()的写法等同于str.trim().length() == 0 + return str == null || str.trim().isEmpty(); + } + + /** + * 判断字符串是否不为空白 + * @param str 字符串 + * @return true=字符串不为空白;false=字符串为空白 + */ + public static boolean isNotBlank(String str) { + return !isBlank(str); + } + + /** + * 将list列表转换成字符串,以逗号分隔 + * @param list 列表 + * @return 字符串 + */ + public static String join(List list) { + return join(list, ","); + } + + /** + * 将list列表转换成字符串 + * @param list 列表 + * @param separator 分隔符 + * @return 字符串 + */ + public static String join(List list, String separator) { + return join(list, separator, null,null); + } + + /** + * 将list列表转换成字符串 + * @param list 列表 + * @param separator 分隔符 + * @param prefix 前缀 + * @param suffix 后缀 + * @return 字符串 + */ + public static String join(List list, String separator, String prefix, String suffix) { + StringBuilder sb = new StringBuilder(); + if (isNotEmpty(prefix)) { + sb.append(prefix); + } + if (isNotEmpty(list)) { + for (Object obj : list) { + sb.append(obj); + sb.append(separator); + } + // 删除最后一个分隔符 + sb.delete(sb.length() - separator.length(), sb.length()); + } + if (isNotEmpty(suffix)) { + sb.append(suffix); + } + return sb.toString(); + } + + + + /** + * 将字符串形式的列表转换成字符串列表。 + * @param listStr 表示列表的字符串,各个元素通过","分隔。 + * @return 转换后的字符串列表。 + */ + public static List toStrList(String listStr) { + return toStrList(listStr,","); + } + + /** + * 将字符串形式的列表转换成字符串列表。 + * @param listStr 表示列表的字符串,各个元素通过separator分隔。 + * @param separator 用于分割字符串列表元素的字符。 + * @return 转换后的字符串列表。 + */ + public static List toStrList(String listStr, String separator) { + return toList(listStr, separator,String.class); + } + + /** + * 将字符串形式的列表转换成指定类型的实体列表。 + * @param listStr 表示列表的字符串,各个元素通过separator分隔。 + * @param clazz 要转换成的实体类型。 + * @return 转换后的实体列表。 + */ + public static List toList(String listStr, Class clazz) { + return toList(listStr, ",",clazz); + } + + /** + * 将字符串形式的列表转换成指定类型的实体列表。 + * @param listStr 表示列表的字符串,各个元素通过separator分隔。 + * @param separator 用于分割字符串列表元素的字符。 + * @param clazz 要转换成的实体类型。 + * @return 转换后的实体列表。 + */ + public static List toList(String listStr, String separator,Class clazz) { + return toList(listStr, separator,null,null,clazz); + } + + /** + * 将字符串形式的列表转换成指定类型的实体列表。 + * @param listStr 表示列表的字符串,各个元素通过separator分隔。 + * @param separator 用于分割字符串列表元素的字符。 + * @param prefix 每个元素前可能存在的前缀。 + * @param suffix 每个元素后可能存在的后缀。 + * @param clazz 要转换成的实体类型。 + * @return 转换后的实体列表。 + */ + public static List toList(String listStr,String separator, String prefix, String suffix ,Class clazz) { + List list = new ArrayList<>(); + // 如果输入的字符串不为空,则进行分割和转换 + if (isNotEmpty(listStr)) { + String[] strArray = listStr.split(separator); + for (String str : strArray) { + // 如果存在前缀,则移除 + if (isNotEmpty(prefix) && str.length() >= prefix.length()) { + str = str.substring(prefix.length()); + } + // 如果存在后缀,则移除 + if (isNotEmpty(suffix) && str.length() >= suffix.length()) { + str = str.substring(0, str.length() - suffix.length()); + } + // 将字符串转换成指定类型并添加到列表中 + list.add(convert(str,clazz)); + } + } + return list; + } + + /** + * 将源对象转换为指定类型。 + * + * @param source 源对象,需要转换的对象。 + * @param clazz 目标类型,指定要将源对象转换成的类型。 + * @return 转换后的对象。如果源对象不为null,则返回转换后的对象;如果源对象为null,则返回null。 + */ + public static T convert(Object source, Class clazz){ + if(source != null){ + // 源对象不为null时,尝试将其转换为指定类型并返回 + return clazz.cast(source); + }else { + // 源对象为null时,直接返回null + return null; + } + } + + + /** + * 将字符串形式的列表转换成长整型列表。 + * @param listStr 表示列表的字符串,各个元素通过","分隔。 + * @return 转换后的字符串列表。 + */ + public static List toLongList(String listStr) { + return toLongList(listStr,","); + } + + /** + * 将字符串形式的列表转换成长整型列表。 + * @param listStr 表示列表的字符串,各个元素通过separator分隔。 + * @param separator 用于分割字符串列表元素的字符。 + * @return 转换后的字符串列表。 + */ + public static List toLongList(String listStr, String separator) { + return toList(listStr, separator,Long.class); + } + + /** + * 将字符串形式的列表转换成长整型列表。 + * @param listStr 表示列表的字符串,各个元素通过","分隔。 + * @return 转换后的字符串列表。 + */ + public static List toIntegerList(String listStr) { + return toIntegerList(listStr,","); + } + + /** + * 将字符串形式的列表转换成长整型列表。 + * @param listStr 表示列表的字符串,各个元素通过separator分隔。 + * @param separator 用于分割字符串列表元素的字符。 + * @return 转换后的字符串列表。 + */ + public static List toIntegerList(String listStr, String separator) { + return toList(listStr, separator,Integer.class); + } + + /** + * 将标识符替换成对应的值 + * @param destinationString 目标字符串 + * @param valueMap 存储标识符和对应值的映射关系的Map + * @return 替换标识符后的结果字符串 + */ + public static String replaceIdentifierValue(String destinationString, Map valueMap){ + //正则表达式匹配规则的字符串,这里用来匹配${}字符串 + String regex = "\\$\\{(.+?)}"; + //匹配模式 符合regex中正则表达式匹配规则的匹配模式 + Pattern pattern = Pattern.compile(regex); + //匹配器 对目标字符串destinationString,使用pattern匹配模式得到的匹配器 + Matcher matcher = pattern.matcher(destinationString); + //缓存字符串 + StringBuffer stringBuffer = new StringBuffer(); + while (matcher.find()){ + // 获取匹配到的标识符 group(0)就是指的整个串,group(1)指的是第一个括号里的东西,以此类推 + String key = matcher.group(1); + // 根据标识符从valueMap中获取对应的值 + String value = valueMap.get(key); + if(value == null){ + matcher.appendReplacement(stringBuffer,""); + }else { + value.replaceAll("\\$","RDS_CHAR_DOLLAR"); + // 将匹配到的标识符替换为对应的值,并将结果追加到stringBuffer中 + matcher.appendReplacement(stringBuffer,value); + } + } + // 将匹配到的剩余部分追加到stringBuffer中 + matcher.appendTail(stringBuffer); + return stringBuffer.toString().replaceAll("RDS_CHAR_DOLLAR","\\$"); + } + + /** + * 将字符串转换成驼峰命名法 + * @param str 字符串 + * @return 驼峰命名法 + */ + public static String toCamelCase(String str) { + if (str == null) { + return null; + } + // 将字符串中的下划线替换成驼峰命名法 + StringBuilder sb = new StringBuilder(); + // 用于标记是否需要将下一个字符转换成大写 + boolean flag = false; + return getCamelCaseString(str, sb, flag); + } + + /** + * 将字符串转换成下划线命名法 + * @param str 字符串 + * @return 下划线命名法 + */ + public static String toUnderlineCase(String str) { + if (str == null) { + return null; + } + // 将字符串中的驼峰命名法替换成下划线命名法 + StringBuilder sb = new StringBuilder(); + for (char c : str.toCharArray()) { + if (Character.isUpperCase(c)) { + sb.append('_'); + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** + * 将字符串转换成首字母大写的驼峰命名法 + * @param str 字符串 + * @return 首字母大写的驼峰命名法 + */ + public static String toCapitalizeCamelCase(String str) { + if (str == null) { + return null; + } + // 将字符串中的下划线替换成驼峰命名法 + StringBuilder sb = new StringBuilder(); + // 用于标记是否需要将下一个字符转换成大写 + boolean flag = true; + return getCamelCaseString(str, sb, flag); + } + + /** + * 将字符串转换成驼峰命名法 + * @param str 字符串 + * @param sb StringBuilder + * @param flag 用于标记是否需要将下一个字符转换成大写 true=需要;false=不需要 + * @return 驼峰命名法 + */ + @NotNull + private static String getCamelCaseString(String str, StringBuilder sb, boolean flag) { + for (char c : str.toCharArray()) { + if (c == '_') { + flag = true; + } else { + if (flag) { + sb.append(Character.toUpperCase(c)); + flag = false; + } else { + sb.append(c); + } + } + } + return sb.toString(); + } + + /** + * 随机生成6位数验证码 + * @return 生成的6位数验证码 + */ + public static String randomCode() { + // 生成6位数的随机数 + return String.valueOf((int) ((Math.random() * 9 + 1) * 100000)); + } + + /** + * 获取随机数 普通短信验证码 性能好,线程安全 + * @return 随机数 + */ + public static String threadLocalRandom(){ + ThreadLocalRandom threadLocalRandom = ThreadLocalRandom.current(); + return String.valueOf(threadLocalRandom.nextInt(100_000, 1_000_000)); + } + + /** + * 获取随机数 密码学安全随机 性能一般,线程安全 + * 支付/安全验证 + * @return 随机数 + */ + public static String secureRandom() { + SecureRandom secureRandom = new SecureRandom(); + return String.format("%06d", secureRandom.nextInt(1_000_000)); + } + + + + /** + * 从字符串中截取指定分隔符之前的子字符串。 + * + * @param str 原始字符串 + * @param separator 分隔符 + * @return 截取后的子字符串,如果没有找到分隔符,则返回原始字符串 + */ + public static String substringBefore(String str, String separator) { + if (isEmpty(str) || separator == null) { + return str; + } + if (separator.isEmpty()) { + return ""; + } + int pos = str.indexOf(separator); + // 如果没有找到分隔符,则返回原始字符串 + if (pos == -1) { + return str; + } + return str.substring(0, pos); + } +} diff --git a/src/main/java/com/mikufufu/core/utils/TreeUtils.java b/src/main/java/com/mikufufu/core/utils/TreeUtils.java new file mode 100644 index 0000000..09c368d --- /dev/null +++ b/src/main/java/com/mikufufu/core/utils/TreeUtils.java @@ -0,0 +1,45 @@ +package com.mikufufu.core.utils; + +import com.mikufufu.common.entity.Node; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 树形工具类 + * + */ +public class TreeUtils { + + public static Node buildTree(Node root, Node node) { + if (node.getParentId().equals(root.getId())) { + root.addChild(node); + } else { + for (Node child : root.getChildren()) { + buildTree(child, node); + } + } + return root; + } + + public static List> listToTree(List> list,E rootId) { + // 检查输入列表是否为空,若为空则直接返回空列表 + if (StringUtils.isEmpty(list)) { + return Collections.emptyList(); + } + + // 遍历列表 + Map>> nodeMap = list.stream().collect(Collectors.groupingBy(Node::getParentId)); + + // 返回根节点 + return list.stream().peek(node -> { + List> children = nodeMap.get(node.getId()); + if (StringUtils.isNotEmpty(children)) { + node.setChildren(children); + } + }).filter(node -> node.getParentId().equals(rootId)).collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/mikufufu/mapper/MenuMapper.java b/src/main/java/com/mikufufu/mapper/MenuMapper.java new file mode 100644 index 0000000..d795e3f --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/MenuMapper.java @@ -0,0 +1,16 @@ +package com.mikufufu.mapper; + +import com.mikufufu.modules.system.model.entity.Menu; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + *

+ * 菜单信息表 Mapper 接口 + *

+ * + * + * @since 2024-12-16 + */ +public interface MenuMapper extends BaseMapper { + +} diff --git a/src/main/java/com/mikufufu/mapper/OperateLogMapper.java b/src/main/java/com/mikufufu/mapper/OperateLogMapper.java new file mode 100644 index 0000000..0da09aa --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/OperateLogMapper.java @@ -0,0 +1,12 @@ +package com.mikufufu.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.mikufufu.modules.system.model.dto.LogDto; +import com.mikufufu.modules.system.model.entity.OperateLog; +import org.apache.ibatis.annotations.Param; + +public interface OperateLogMapper extends BaseMapper { + + IPage getPage(@Param("logDto") LogDto logDto, IPage page); +} \ No newline at end of file diff --git a/src/main/java/com/mikufufu/mapper/PermissionMapper.java b/src/main/java/com/mikufufu/mapper/PermissionMapper.java new file mode 100644 index 0000000..885c0bf --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/PermissionMapper.java @@ -0,0 +1,24 @@ +package com.mikufufu.mapper; + +import com.mikufufu.modules.system.model.entity.Permission; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mikufufu.modules.system.model.vo.PermissionRoleVO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + *

+ * 权限表 Mapper 接口 + *

+ * + * + * @since 2024-12-16 + */ +public interface PermissionMapper extends BaseMapper { + + List getPermission(@Param("roleId") Integer roleId); + + List getPermissionRoleList(); + +} diff --git a/src/main/java/com/mikufufu/mapper/RoleMapper.java b/src/main/java/com/mikufufu/mapper/RoleMapper.java new file mode 100644 index 0000000..2cc6bf6 --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/RoleMapper.java @@ -0,0 +1,16 @@ +package com.mikufufu.mapper; + +import com.mikufufu.modules.system.model.entity.Role; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + *

+ * 角色表 Mapper 接口 + *

+ * + * + * @since 2024-12-06 + */ +public interface RoleMapper extends BaseMapper { + +} diff --git a/src/main/java/com/mikufufu/mapper/RoleMenuMapper.java b/src/main/java/com/mikufufu/mapper/RoleMenuMapper.java new file mode 100644 index 0000000..fe27121 --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/RoleMenuMapper.java @@ -0,0 +1,16 @@ +package com.mikufufu.mapper; + +import com.mikufufu.modules.system.model.entity.RoleMenu; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + *

+ * 角色导航关系表 Mapper 接口 + *

+ * + * + * @since 2024-12-16 + */ +public interface RoleMenuMapper extends BaseMapper { + +} diff --git a/src/main/java/com/mikufufu/mapper/RolePermissionMapper.java b/src/main/java/com/mikufufu/mapper/RolePermissionMapper.java new file mode 100644 index 0000000..f2a3b94 --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/RolePermissionMapper.java @@ -0,0 +1,16 @@ +package com.mikufufu.mapper; + +import com.mikufufu.modules.system.model.entity.RolePermission; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + *

+ * 角色权限关系表 Mapper 接口 + *

+ * + * + * @since 2024-12-16 + */ +public interface RolePermissionMapper extends BaseMapper { + +} diff --git a/src/main/java/com/mikufufu/mapper/SettingMapper.java b/src/main/java/com/mikufufu/mapper/SettingMapper.java new file mode 100644 index 0000000..26aea92 --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/SettingMapper.java @@ -0,0 +1,16 @@ +package com.mikufufu.mapper; + +import com.mikufufu.modules.system.model.entity.Setting; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + *

+ * 系统设置表 Mapper 接口 + *

+ * + * + * @since 2024-12-16 + */ +public interface SettingMapper extends BaseMapper { + +} diff --git a/src/main/java/com/mikufufu/mapper/StorageMapper.java b/src/main/java/com/mikufufu/mapper/StorageMapper.java new file mode 100644 index 0000000..f14ad73 --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/StorageMapper.java @@ -0,0 +1,16 @@ +package com.mikufufu.mapper; + +import com.mikufufu.modules.storage.model.entity.Storage; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + *

+ * 存储桶表 Mapper 接口 + *

+ * + * + * @since 2024-12-16 + */ +public interface StorageMapper extends BaseMapper { + +} diff --git a/src/main/java/com/mikufufu/mapper/UserMapper.java b/src/main/java/com/mikufufu/mapper/UserMapper.java new file mode 100644 index 0000000..e3a4f3b --- /dev/null +++ b/src/main/java/com/mikufufu/mapper/UserMapper.java @@ -0,0 +1,16 @@ +package com.mikufufu.mapper; + +import com.mikufufu.modules.system.model.entity.User; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + *

+ * 用户表 Mapper 接口 + *

+ * + * + * @since 2024-12-06 + */ +public interface UserMapper extends BaseMapper { + +} diff --git a/src/main/java/com/mikufufu/modules/auth/controller/LoginController.java b/src/main/java/com/mikufufu/modules/auth/controller/LoginController.java new file mode 100644 index 0000000..6a00c8f --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/controller/LoginController.java @@ -0,0 +1,121 @@ +package com.mikufufu.modules.auth.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mikufufu.common.annotation.AnonymousApi; +import com.mikufufu.common.annotation.OperationLog; +import com.mikufufu.common.constant.RedisKey; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.enums.*; +import com.mikufufu.core.cache.RedisCache; +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.auth.model.dto.ForgetPasswordParam; +import com.mikufufu.modules.auth.model.dto.LoginEmailParam; +import com.mikufufu.modules.auth.model.dto.LoginParam; +import com.mikufufu.modules.auth.model.dto.RegisterParam; +import com.mikufufu.modules.system.model.entity.User; +import com.mikufufu.modules.system.service.EmailService; +import com.mikufufu.modules.system.service.IUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Email; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 登录注册 + */ +@Tag(name = "登录注册",description = "登录注册接口") +@RestController +@Validated +public class LoginController { + + @Autowired + private IUserService userService; + + @Autowired + private EmailService emailService; + + @Operation(summary = "c端注册") + @PostMapping("/register") + @OperationLog(module = ModuleType.USER,type = OperationType.INSERT,description = "c端用户注册") + public AjaxResult register(@RequestBody @Validated RegisterParam registerParam) { + return AjaxResult.data(userService.register(registerParam)); + } + + @Operation(summary = "c端登录") + @PostMapping("/login") + @OperationLog(module = ModuleType.USER,type = OperationType.LOGIN,description = "c端用户登录") + public AjaxResult login(@RequestBody @Validated LoginParam loginParam) { + return AjaxResult.data(userService.login(loginParam, Channel.WEB)); + } + + @Operation(summary = "管理端登录") + @PostMapping("/admin/login") + @OperationLog(module = ModuleType.USER,type = OperationType.LOGIN,description = "管理端用户登录") + public AjaxResult adminLogin(@RequestBody @Validated LoginParam loginParam) { + return AjaxResult.data(userService.login(loginParam,Channel.ADMIN)); + } + + @PostMapping("/admin/loginByEmail") + @Operation(summary = "管理端登录") + @OperationLog(module = ModuleType.USER,type = OperationType.LOGIN, description = "管理端登录") + public AjaxResult adminLoginByEmail(@RequestBody LoginEmailParam loginEmailParam) { + return AjaxResult.data(userService.loginEmail(loginEmailParam,Channel.ADMIN)); + } + + @Operation(summary = "退出登录") + @GetMapping("/logout") + public AjaxResult logout() { + return AjaxResult.status(userService.logout()); + } + + @AnonymousApi + @GetMapping("/sendMailCode") + @Operation(summary = "发送邮箱验证码") + @OperationLog(module = ModuleType.SYSTEM,type = OperationType.SEND_EMAIL, description = "发送邮箱验证码") + public AjaxResult sendMailCode(@Email(message = "请输入正确的邮箱地址") String email) { + + // 判断是否发送过验证码 + if (RedisCache.exists(RedisKey.EMAIL_CODE_EXPIRATION_TIME + email)){ + return AjaxResult.error("请勿频繁发送验证码"); + }else if (RedisCache.exists(RedisKey.EMAIL_CODE + email)) { + RedisCache.delete(RedisKey.EMAIL_CODE + email); + } + + if(!userService.exists(new LambdaQueryWrapper().select(User::getId).eq(User::getEmail, email).eq(User::getStatus, UserStatus.NORMAL))){ + return AjaxResult.error("该邮箱未注册"); + } + // 生成验证码 + String code = StringUtils.threadLocalRandom(); + + // 保存验证码到redis中 + RedisCache.set(RedisKey.EMAIL_CODE + email, code, 6, TimeUnit.MINUTES); + RedisCache.set(RedisKey.EMAIL_CODE_EXPIRATION_TIME + email, 1, 1, TimeUnit.MINUTES); + + // 构造邮件内容 + Map values = new HashMap<>(); + values.put("code", code); + values.put("expire", "6"); + values.put("time", LocalDateTime.now().plusMinutes(6).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + emailService.sendTemplateEmail("小缡", email, HtmlTemplate.EMAIL_CODE, values); + + return AjaxResult.success(); + } + + @AnonymousApi + @PostMapping("/forgetPassword") + @Operation(summary = "忘记密码") + public AjaxResult forgetPassword(@RequestBody ForgetPasswordParam forgetPasswordParam) { + return AjaxResult.status(userService.forgetPassword(forgetPasswordParam)); + } +} diff --git a/src/main/java/com/mikufufu/modules/auth/model/dto/ForgetPasswordParam.java b/src/main/java/com/mikufufu/modules/auth/model/dto/ForgetPasswordParam.java new file mode 100644 index 0000000..c8bd676 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/model/dto/ForgetPasswordParam.java @@ -0,0 +1,33 @@ +package com.mikufufu.modules.auth.model.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ForgetPasswordParam { + + /** + * 邮箱 + */ + @Email(message = "请输入正确的邮箱地址") + private String email; + + /** + * 验证码 + */ + @NotBlank( message = "验证码不能为空") + private String code; + + /** + * 新密码 + */ + @NotBlank( message = "新密码不能为空") + private String newPassword; + + /** + * 确认密码 + */ + @NotBlank( message = "确认密码不能为空") + private String confirmPassword; +} diff --git a/src/main/java/com/mikufufu/modules/auth/model/dto/LoginEmailParam.java b/src/main/java/com/mikufufu/modules/auth/model/dto/LoginEmailParam.java new file mode 100644 index 0000000..3d00f61 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/model/dto/LoginEmailParam.java @@ -0,0 +1,18 @@ +package com.mikufufu.modules.auth.model.dto; + +import com.mikufufu.common.annotation.Account; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + + +@Data +public class LoginEmailParam { + + @Email(message = "请输入正确的邮箱地址") + @Account + private String email; + + @NotBlank(message = "验证码不能为空") + private String code; +} diff --git a/src/main/java/com/mikufufu/modules/auth/model/dto/LoginParam.java b/src/main/java/com/mikufufu/modules/auth/model/dto/LoginParam.java new file mode 100644 index 0000000..cc2a717 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/model/dto/LoginParam.java @@ -0,0 +1,29 @@ +package com.mikufufu.modules.auth.model.dto; + +import com.mikufufu.common.annotation.Account; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 登录参数 + * + */ +@Schema(description = "登录参数") +@Data +public class LoginParam { + + /** + * 账号 + */ + @Schema(description = "账号") + @NotBlank(message = "账号不能为空") + @Account + private String account; + /** + * 密码 + */ + @Schema(description = "密码") + @NotBlank(message = "密码不能为空") + private String password; +} diff --git a/src/main/java/com/mikufufu/modules/auth/model/dto/RegisterParam.java b/src/main/java/com/mikufufu/modules/auth/model/dto/RegisterParam.java new file mode 100644 index 0000000..c53bcc0 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/model/dto/RegisterParam.java @@ -0,0 +1,54 @@ +package com.mikufufu.modules.auth.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 注册参数 + * + */ +@Schema(description = "注册参数") +@Data +public class RegisterParam { + + /** + * 账号 + */ + @Schema(description = "账号") + @NotBlank(message = "账号不能为空") + private String account; + + /** + * 昵称 + */ + @Schema(description = "昵称") + @NotBlank(message = "昵称不能为空") + private String nickname; + + /** + * 邮箱 + */ + @Schema(description = "邮箱") + private String email; + + /** + * 密码 + */ + @Schema(description = "密码") + @NotBlank(message = "密码不能为空") + private String password; + + /** + * 用户头像 + */ + @Schema(description = "用户头像") + private String avatar; + + /** + * 邀请码 + */ + @Schema(description = "邀请码") + @NotBlank(message = "邀请码不能为空") + private String inviteCode; +} diff --git a/src/main/java/com/mikufufu/modules/auth/model/entity/SysUser.java b/src/main/java/com/mikufufu/modules/auth/model/entity/SysUser.java new file mode 100644 index 0000000..7525ce1 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/model/entity/SysUser.java @@ -0,0 +1,110 @@ +package com.mikufufu.modules.auth.model.entity; + +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Set; + +/** + * 用户鉴权实体类 + * + */ +@Data +public class SysUser implements UserDetails { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户id + */ + private Integer userId; + + /** + * 用户名 + */ + private String username; + + /** + * 密码 + */ + private String password; + + /** + * 角色id + */ + private Integer roleId; + + /** + * 角色 + */ + private String role; + + /** + * 权限列表 + */ + private Set permissions; + + + public SysUser(Integer userId, String username, String password, Integer roleId, String role, Set permissions) { + this.userId = userId; + this.username = username; + this.password = password; + this.roleId = roleId; + this.role = role; + this.permissions = permissions; + } + + /** + * Authorities 权限集合 用于存储用户权限信息 + * @return 权限集合 + */ + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + role); + authorities.add(authority); + return authorities; + } + + /** + * 判断帐号是否已经过期 + * @return boolean + */ + @Override + public boolean isAccountNonExpired() { + return true; + } + + /** + * 判断帐号是否已被锁定 + * @return boolean + */ + @Override + public boolean isAccountNonLocked() { + return true; + } + + /** + * 判断用户凭证是否已经过期 + * @return boolean + */ + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + /** + * 判断用户帐号是否已启用 + * @return boolean + */ + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/mikufufu/modules/auth/security/TokenStore.java b/src/main/java/com/mikufufu/modules/auth/security/TokenStore.java new file mode 100644 index 0000000..369e957 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/security/TokenStore.java @@ -0,0 +1,99 @@ +package com.mikufufu.modules.auth.security; + +import com.mikufufu.core.cache.RedisCache; +import com.mikufufu.common.constant.RedisKey; +import com.mikufufu.modules.auth.model.entity.SysUser; +import com.mikufufu.common.enums.UserStatus; +import com.mikufufu.common.exception.AuthException; +import com.mikufufu.core.utils.SpringUtils; +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.system.model.entity.Permission; +import com.mikufufu.modules.system.model.entity.User; +import com.mikufufu.modules.system.service.IPermissionService; +import com.mikufufu.modules.system.service.IRoleService; +import lombok.extern.slf4j.Slf4j; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Token存储 如果有第三方也在这里实现 + * + */ +@Slf4j +public class TokenStore { + + private final static String TOKEN_PREFIX = RedisKey.USER + RedisKey.TOKEN ; + + /** + * 生成用户token。 + * + * @param user 用户对象 + * @return 生成的token字符串 + */ + public static String generateToken(User user) { + // 生成token + String token = UUID.randomUUID().toString().replace("-", ""); + log.info("生成token: {}", token); + // 将token和对应的用户ID存储到Redis缓存中 + RedisCache.set(TOKEN_PREFIX + token, user, 1, TimeUnit.DAYS); + return token; + } + + /** + * 根据token获取用户对象。 + * + * @param token 用户的token + * @return 用户对象 + * @throws RuntimeException 如果token无效,则抛出异常 + */ + public static User getUserInToken(String token) { + // 从Redis缓存中根据token获取用户ID + User user = RedisCache.get(TOKEN_PREFIX + token,User.class); + if (StringUtils.isEmpty(user)) { + log.info("token无效"); + return null; + } + // 通过用户ID从缓存中获取用户对象 + return user; + } + + /** + * 根据token获取系统用户对象,包括权限信息。 + * + * @param token 用户的token + * @return 系统用户对象,如果用户不存在则返回null + */ + public static SysUser getSysUserInToken(String token) { + // 根据token获取用户对象 + User user = getUserInToken(token); + if (StringUtils.isEmpty(user)) { + log.info("用户不存在"); + return null; + } + if(UserStatus.DISABLED.getCode().equals(user.getStatus())){ + throw new AuthException("用户已封号"); + } + // 获取用户权限集合 + Set permissions = SpringUtils.getBean(IPermissionService.class).getPermissionList(user.getRole()).stream().map(Permission::getSign).collect(Collectors.toSet()); + Integer roleId = user.getRole(); + // 根据用户角色获取角色代码 + String roleCode = SpringUtils.getBean(IRoleService.class).getCodeById(roleId); + // 构建并返回系统用户对象 + return new SysUser(user.getId(), user.getUsername(), user.getPassword(),roleId, roleCode, permissions); + } + + /** + * 删除指定的token。 + * + * @param token 要删除的token + */ + public static void deleteToken(String token) { + log.info("删除token: {}", token); + // 从Redis缓存中删除指定的token + RedisCache.delete(TOKEN_PREFIX + token); + } + +} diff --git a/src/main/java/com/mikufufu/modules/auth/security/filter/IllegalRequestFilter.java b/src/main/java/com/mikufufu/modules/auth/security/filter/IllegalRequestFilter.java new file mode 100644 index 0000000..5a2cec8 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/security/filter/IllegalRequestFilter.java @@ -0,0 +1,38 @@ +package com.mikufufu.modules.auth.security.filter; + +import com.mikufufu.core.utils.IpUtil; +import com.mikufufu.core.utils.StringUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 非发请求过滤器 + * 请求示例: + * 假设 X-miku-verifyCode:123456 + * 则 X-miku-source:123456-127.0.0.1-miku + */ +@Slf4j +public class IllegalRequestFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String ip = IpUtil.getIpAddress(request); + if (StringUtils.isBlank(ip) || StringUtils.isBlank(request.getHeader("X-miku-verifyCode"))){ + log.error("非法请求!!!url:{},method:{},ip:{}", request.getRequestURI(), request.getMethod(), ip); + response.setStatus(404); + } + String verifyCode = request.getHeader("X-miku-verifyCode") + "-" + ip + "-miku"; + if (StringUtils.isBlank(request.getHeader("X-miku-source")) || !request.getHeader("X-miku-source").equals(verifyCode)){ + log.error("非法请求!!!url:{},method:{},ip == {}", request.getRequestURI(), request.getMethod(), ip); + response.setStatus(404); + return; + } + // 继续执行过滤器链 + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/mikufufu/modules/auth/security/filter/TokenFilter.java b/src/main/java/com/mikufufu/modules/auth/security/filter/TokenFilter.java new file mode 100644 index 0000000..2cf6332 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/security/filter/TokenFilter.java @@ -0,0 +1,63 @@ +package com.mikufufu.modules.auth.security.filter; + +import com.alibaba.fastjson2.JSON; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.enums.ResultCode; +import com.mikufufu.modules.auth.security.TokenStore; +import com.mikufufu.modules.auth.model.entity.SysUser; +import com.mikufufu.core.utils.StringUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; + +/** + * Token过滤器 + * + */ +@Slf4j +public class TokenFilter extends OncePerRequestFilter { + + private final String[] loginUrl = {"/login","/admin/login","/register"}; + + @Override + protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain) throws ServletException, IOException { + + // 放行登录接口 + if (Arrays.stream(loginUrl).anyMatch(url -> url.contains(request.getRequestURI()))){ + chain.doFilter(request, response); + return; + } + + String token = request.getHeader("Authorization"); + if (StringUtils.isBlank(token)) { + chain.doFilter(request, response); + // 放行后不在执行后续过滤器 + return; + } + // 从请求头中的token获取用户信息 + SysUser user = TokenStore.getSysUserInToken(token); + if (StringUtils.isNotEmpty(user)) { + // 将认证对象设置到Security上下文中 + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user,token,user.getAuthorities()) + ); + log.info("用户{}认证成功",user.getUsername()); + // 继续执行过滤器链 + chain.doFilter(request, response); + }else { + response.setContentType("application/json;charset=UTF-8"); + log.info("token无效"); + // 将认证失败的信息封装为JSON格式,并写入响应体中 + response.getWriter().write(JSON.toJSONString(AjaxResult.error(ResultCode.UNAUTHORIZED.getCode(), "token无效!!"))); + } + } +} diff --git a/src/main/java/com/mikufufu/modules/auth/security/handler/AccessDeniedHandlerImpl.java b/src/main/java/com/mikufufu/modules/auth/security/handler/AccessDeniedHandlerImpl.java new file mode 100644 index 0000000..fdef602 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/security/handler/AccessDeniedHandlerImpl.java @@ -0,0 +1,40 @@ +package com.mikufufu.modules.auth.security.handler; + +import com.alibaba.fastjson2.JSON; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.enums.ResultCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 权限不足处理器类,用于处理用户权限不足时的访问限制。 + * + */ +@Slf4j // 使用Lombok的日志注解,方便记录日志 +@Component // 标记为Spring组件,使得Spring容器能够自动发现和装配它 +public class AccessDeniedHandlerImpl implements AccessDeniedHandler { + /** + * 处理权限不足的异常。 + * @param request HttpServletRequest对象,代表客户端的HTTP请求。 + * @param response HttpServletResponse对象,用于向客户端发送响应。 + * @param accessDeniedException 访问被拒绝的异常实例。 + * @throws IOException 如果发生I/O错误。 + */ + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + // 设置响应的内容类型为JSON,支持UTF-8编码 + response.setContentType("application/json;charset=UTF-8"); + // 记录权限不足的错误日志 + log.error("权限不足,无法访问{}", request.getRequestURI()); + log.error("异常信息:{}", accessDeniedException.getMessage()); + // 将错误信息封装为JSON格式,并写入响应体中返回给客户端 + response.getWriter().write(JSON.toJSONString(AjaxResult.error(ResultCode.FORBIDDEN.getCode(), accessDeniedException.getMessage()))); + } +} + diff --git a/src/main/java/com/mikufufu/modules/auth/security/handler/AuthenticationEntryPointImpl.java b/src/main/java/com/mikufufu/modules/auth/security/handler/AuthenticationEntryPointImpl.java new file mode 100644 index 0000000..e14d7fa --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/security/handler/AuthenticationEntryPointImpl.java @@ -0,0 +1,42 @@ +package com.mikufufu.modules.auth.security.handler; + +import com.alibaba.fastjson2.JSON; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.enums.ResultCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 认证失败处理类,用于处理未认证的请求,返回未授权的响应。 + * + */ +@Slf4j +@Component +public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { + + /** + * 当访问受保护的资源时,如果用户未通过认证,会调用此方法。 + * @param request HttpServletRequest对象,代表客户端的HTTP请求。 + * @param response HttpServletResponse对象,用于向客户端发送响应。 + * @param authException 认证异常,封装了认证失败的详细信息。 + * @throws IOException 如果在写入响应时发生IO错误。 + */ + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + // 设置响应内容类型为JSON,并使用UTF-8编码 + response.setContentType("application/json;charset=UTF-8"); + + // 记录认证失败的日志 + log.error("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI()); + + // 将认证失败的信息封装为JSON格式,并写入响应体中 + response.getWriter().write(JSON.toJSONString(AjaxResult.error(ResultCode.UNAUTHORIZED.getCode(), "认证失败,无法访问系统资源"))); + } +} + diff --git a/src/main/java/com/mikufufu/modules/auth/security/handler/AuthorizationManagerImpl.java b/src/main/java/com/mikufufu/modules/auth/security/handler/AuthorizationManagerImpl.java new file mode 100644 index 0000000..0ad66e3 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/security/handler/AuthorizationManagerImpl.java @@ -0,0 +1,81 @@ +package com.mikufufu.modules.auth.security.handler; + +import com.mikufufu.common.permission.PermissionHandle; +import com.mikufufu.modules.auth.model.entity.SysUser; +import com.mikufufu.common.enums.RoleCode; +import com.mikufufu.modules.system.model.vo.PermissionRoleVO; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.util.AntPathMatcher; + +import java.util.function.Supplier; + +@Slf4j // 使用Lombok的日志注解,方便记录日志 +public class AuthorizationManagerImpl implements AuthorizationManager { + + /** + * Spring提供的用于匹配路径的匹配器 + */ + private final AntPathMatcher antPathMatcher = new AntPathMatcher(); + + @PostConstruct + public void init() { + PermissionHandle.init(); + } + + @Override + public AuthorizationDecision check(Supplier authentication, RequestAuthorizationContext context) { + // 获取请求的URL和HTTP方法 + String url = context.getRequest().getRequestURI(); + String method = context.getRequest().getMethod(); + // 检查匿名权限 + if (PermissionHandle.hasAnonymousPermission(url,method)){ + return new AuthorizationDecision(true); + } + if (authentication.get() == null){ + // 用户未登录,拒绝访问 + return new AuthorizationDecision(false); + } + // 超管可以访问所有资源 + if (authentication.get().getPrincipal() instanceof UserDetails){ + SysUser user = (SysUser) authentication.get().getPrincipal(); + // 获取用户角色 + String roleCode = user.getRole(); + if (RoleCode.ROLE_SYSTEM_ADMIN.getCode().equals(roleCode)){ + return new AuthorizationDecision(true); + } + } + + if (PermissionHandle.permissionRoleListIsNull()){ + log.info("权限资源与权限的关系为空,重新加载"); + PermissionHandle.loadDataSource(); + } + // 获取权限与角色的关系 + PermissionRoleVO permissionRoleVO = PermissionHandle.getPermissionRoleList().stream().filter(permission -> antPathMatcher.match(permission.getPath(), url) && permission.getMethod().equalsIgnoreCase(method)).findFirst().orElse(null); + log.info("权限权限与角色的关系:{}", permissionRoleVO); + if (permissionRoleVO == null || permissionRoleVO.getRoleList() == null || permissionRoleVO.getRoleList().isEmpty()){ + // 没有权限 + return new AuthorizationDecision(false); + } + // 放行数据库配置的匿名接口 + if (permissionRoleVO.getRoleList().contains(RoleCode.ROLE_VISITOR.getCode())){ + return new AuthorizationDecision(true); + } + if (authentication.get().getPrincipal() instanceof UserDetails){ + // 非匿名用户,获取用户角色信息 + SysUser user = (SysUser) authentication.get().getPrincipal(); + // 获取用户角色 + String roleCode = user.getRole(); + // 判断用户角色是否包含权限的角色 + if (permissionRoleVO.getRoleList().contains(roleCode)){ + return new AuthorizationDecision(true); + } + } + return new AuthorizationDecision(false); + } +} diff --git a/src/main/java/com/mikufufu/modules/auth/service/SecurityService.java b/src/main/java/com/mikufufu/modules/auth/service/SecurityService.java new file mode 100644 index 0000000..823b50b --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/service/SecurityService.java @@ -0,0 +1,16 @@ +package com.mikufufu.modules.auth.service; + +/** + * 权限服务类 + * + */ +public interface SecurityService{ + + /** + * 检查当前用户是否具有指定权限。 + * + * @param permission 需要检查的权限字符串。如果为空或null,则认为没有权限。 + * @return boolean 如果当前用户具有指定权限,则返回true;否则返回false。 + */ + boolean hasPermission(String permission); +} diff --git a/src/main/java/com/mikufufu/modules/auth/service/impl/SecurityServiceImpl.java b/src/main/java/com/mikufufu/modules/auth/service/impl/SecurityServiceImpl.java new file mode 100644 index 0000000..a74b0c0 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/service/impl/SecurityServiceImpl.java @@ -0,0 +1,31 @@ +package com.mikufufu.modules.auth.service.impl; + +import com.mikufufu.modules.auth.model.entity.SysUser; +import com.mikufufu.modules.auth.service.SecurityService; +import com.mikufufu.modules.auth.utils.AuthUtils; +import com.mikufufu.core.utils.StringUtils; +import org.springframework.stereotype.Service; + +/** + * 权限服务实现类 + * + */ +@Service("ss") +public class SecurityServiceImpl implements SecurityService { + + @Override + public boolean hasPermission(String permission){ + // 获取当前登录的用户对象 + SysUser user = AuthUtils.getUser(); + // 检查用户对象是否为空 + if (StringUtils.isEmpty(user)) { + return false; + } + // 检查用户权限集合是否为空 + if (StringUtils.isEmpty(user.getPermissions())){ + return false; + } + // 检查用户权限集合中是否包含指定的权限 + return user.getPermissions().contains(permission); + } +} diff --git a/src/main/java/com/mikufufu/modules/auth/utils/AuthUtils.java b/src/main/java/com/mikufufu/modules/auth/utils/AuthUtils.java new file mode 100644 index 0000000..120dee1 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/auth/utils/AuthUtils.java @@ -0,0 +1,85 @@ +package com.mikufufu.modules.auth.utils; + +import com.mikufufu.modules.auth.model.entity.SysUser; +import com.mikufufu.common.exception.AuthException; +import com.mikufufu.core.utils.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * 权限相关工具类 + * + */ +@Slf4j +public class AuthUtils { + + /** + * 获取当前认证的用户对象 + * @return SysUser 当前认证的用户对象 + */ + public static SysUser getUser() { + Authentication authentication = getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new AuthException("无法获取身份信息!!"); + } + Object principal = authentication.getPrincipal(); + // 假设SysUser实现了UserDetails接口 + if (principal instanceof UserDetails) { + return (SysUser) principal; + } + return null; + } + + /** + * 获取当前用户的用户名 + * @return String 当前用户的用户名 + */ + public static String getUsername() { + SysUser user = getUser(); + if (StringUtils.isEmpty(user)) { +// throw new AuthException("用户对象为空,无法获取用户名"); + // 修复因为获取用户对象为空,导致的报错 + return ""; + } + return user.getUsername(); + } + + /** + * 获取当前用户的用户ID + * @return Integer 当前用户的用户ID + */ + public static Integer getUserId() { + SysUser user = getUser(); + if (StringUtils.isEmpty(user)) { + throw new AuthException("用户对象为空,无法获取用户ID"); + } + return user.getUserId(); + } + + /** + * 获取当前用户的token + * @return String 当前用户的token + */ + public static String getToken() { + Authentication authentication = getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new AuthException("无法获取身份信息!!"); + } + Object credentials = authentication.getCredentials(); + // 假设credentials是一个字符串 + if (credentials instanceof String) { + return (String) credentials; + } + return null; + } + + /** + * 获取当前认证的Authentication对象 + * @return Authentication 当前认证的Authentication对象 + */ + public static Authentication getAuthentication() { + return SecurityContextHolder.getContext().getAuthentication(); + } +} diff --git a/src/main/java/com/mikufufu/modules/storage/controller/FilesController.java b/src/main/java/com/mikufufu/modules/storage/controller/FilesController.java new file mode 100644 index 0000000..57a0b3d --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/controller/FilesController.java @@ -0,0 +1,103 @@ +package com.mikufufu.modules.storage.controller; + +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.storage.service.UploadService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterStyle; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@Slf4j +@RestController +@Tag(name = "文件上传", description = "用户上传文件") +@RequestMapping("/files") +public class FilesController { + + @Autowired + private UploadService uploadService; + + /** + * 上传文件
+ *

@RequestPart("file") 是为了解决上传文件的时候报错,也能让swagger正常使用上传文件的功能

+ * @param file 文件 + * @param pathName 文件路径 + * @return 文件路径 + */ + @Operation(summary = "上传文件") + @Parameters({ + @Parameter(name = "file",description = "文件",style = ParameterStyle.FORM), + @Parameter(name = "pathName",description = "文件路径",style = ParameterStyle.FORM) + }) + @PostMapping("/upload") + public AjaxResult> upload(@RequestPart("file") MultipartFile file, String pathName){ + // 如果文件为空,则返回错误信息 + if (file.isEmpty()){ + return AjaxResult.error("文件不能为空"); + } + // 如果文件路径为空,则使用默认路径 + if (StringUtils.isBlank(pathName)){ + return AjaxResult.data(uploadService.upload(file)); + } + return AjaxResult.data(uploadService.upload(file,pathName)); + } + + @Operation(summary = "删除文件") + @Parameter(name = "fileName",description = "文件名",style = ParameterStyle.FORM,required = true) + @DeleteMapping("/delete") + public AjaxResult delete(String fileName){ + return AjaxResult.status(uploadService.delete(fileName),"文件下载失败","文件下载失败"); + } + + @Operation(summary = "下载文件") + @Parameter(name = "fileName",description = "文件名",style = ParameterStyle.FORM,required = true) + @GetMapping("/download") + public AjaxResult download(String fileName){ + return AjaxResult.data(uploadService.download(fileName)); + } + + @Operation(summary = "下载图片") + @Parameter(name = "fileName",description = "文件名",required = true) + @GetMapping("/downloadImage") + public void downloadImage(String fileName, HttpServletResponse response){ + try { + if (StringUtils.isBlank(fileName)){ + throw new RuntimeException("文件名不能为空"); + } + uploadService.downloadImage(fileName,response.getOutputStream()); + }catch (RuntimeException | IOException e){ + log.error("文件下载失败",e); + } + } + + @Operation(summary = "查询文件列表") + @Parameter(name = "path",description = "文件路径") + @GetMapping("/listNotSubDir") + public AjaxResult>> listNotSubDir(String path){ + return AjaxResult.data(uploadService.listObjects(path,false)); + } + + @Operation(summary = "查询文件列表") + @Parameter(name = "path",description = "文件路径") + @GetMapping("/listAndSubDir") + public AjaxResult>> listAndSubDir(String path){ + return AjaxResult.data(uploadService.listObjects(path,true)); + } + + @Operation(summary = "查询文件列表") + @Parameter(name = "prefix",description = "文件前缀") + @GetMapping("/list") + public AjaxResult> list(String prefix){ + return AjaxResult.data(uploadService.list(prefix)); + } +} diff --git a/src/main/java/com/mikufufu/modules/storage/enums/StorageType.java b/src/main/java/com/mikufufu/modules/storage/enums/StorageType.java new file mode 100644 index 0000000..f68e9e8 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/enums/StorageType.java @@ -0,0 +1,78 @@ +package com.mikufufu.modules.storage.enums; + +import com.mikufufu.modules.storage.strategy.mode.impl.LocalMode; +import com.mikufufu.modules.storage.strategy.mode.impl.MinioMode; +import com.mikufufu.modules.storage.strategy.mode.impl.OssMode; +import com.mikufufu.modules.storage.strategy.mode.StorageMode; +import com.mikufufu.core.utils.StringUtils; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +/** + * 存储类型枚举类 + * + */ +@Slf4j +@Getter +@AllArgsConstructor +public enum StorageType { + + local(0,"local", LocalMode.class), + minio(1,"minio", MinioMode.class), + aliyun(2,"oss", OssMode.class), + ; + + private final Integer type; + + private final String code; + + /** + * 存储模式的类 + */ + private final Class storageMode; + + /** + * + * @param type 存储方式的类型 + * @return {@link StorageType} 存储方式枚举类 + */ + public static StorageType getStorageType(@NotNull Integer type){ + for (StorageType storageType : StorageType.values()){ + if (type.equals(storageType.getType())){ + return storageType; + } + } + return null; + } + + /** + * 获取存储模式 + * @param code 存储方式的code + * @return {@link StorageMode} 存储模式 + */ + public static StorageMode getStorageMode(String code){ + if(StringUtils.isBlank(code)){ + throw new RuntimeException("存储方式不能为空"); + } + try { + // 遍历枚举类 + for (StorageType storageType : StorageType.values()) { + if (storageType.getCode().equals(code)){ +// return storageType.getStorageMode().newInstance(); + // 17 版本以上废弃了 newInstance 方法,可以改用Constructor 的 newInstance 方法来创建对象 + Constructor constructor = storageType.getStorageMode().getDeclaredConstructor(); + return constructor.newInstance(); + } + } + } catch (InvocationTargetException | NoSuchMethodException | InstantiationException | IllegalAccessException e) { + log.error("获取存储方式失败:{}",e.getMessage()); + } + throw new RuntimeException("获取存储方式失败"); + } + +} diff --git a/src/main/java/com/mikufufu/modules/storage/model/entity/Storage.java b/src/main/java/com/mikufufu/modules/storage/model/entity/Storage.java new file mode 100644 index 0000000..21ea29f --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/model/entity/Storage.java @@ -0,0 +1,93 @@ +package com.mikufufu.modules.storage.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serial; +import java.io.Serializable; + +import com.mikufufu.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + *

+ * 存储桶表 + *

+ * + * + * @since 2024-12-16 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@TableName("m_storage") +public class Storage extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 对象存储服务商(目前支持阿里云、minio和本地) + */ + @TableField("storage_code") + private String storageCode; + + /** + * 对象存储服务的类型(1.minio,2.oss,3.local) + */ + @TableField("storage_type") + private Integer storageType; + + /** + * 对象存储服务商名称 + */ + @TableField("storage_name") + private String storageName; + + /** + * 外链访问地址 + */ + @TableField("host") + private String host; + + /** + * API访问地址 + */ + @TableField("endpoint") + private String endpoint; + + /** + * 账号或者用户识别码 + */ + @TableField("access_key") + private String accessKey; + + /** + * 密钥 + */ + @TableField("secret_key") + private String secretKey; + + /** + * 存储桶名称 + */ + @TableField("bucket_name") + private String bucketName; + + /** + * icon的url链接 + */ + @TableField("icon") + private String icon; + + /** + * 是否强制使用路径模式(1.使用2.不使用-默认) + */ + @TableField("forced_path_mode") + private Integer forcedPathMode; +} diff --git a/src/main/java/com/mikufufu/modules/storage/service/IStorageService.java b/src/main/java/com/mikufufu/modules/storage/service/IStorageService.java new file mode 100644 index 0000000..75e4acf --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/service/IStorageService.java @@ -0,0 +1,36 @@ +package com.mikufufu.modules.storage.service; + +import com.mikufufu.modules.storage.model.entity.Storage; +import com.baomidou.mybatisplus.extension.service.IService; + +/** + *

+ * 存储桶表 服务类 + *

+ * + * + * @since 2024-12-16 + */ +public interface IStorageService extends IService { + + /** + * 根据存储服务编码获取对应的存储类型名称 + * @param storageCode 存储服务编码 + * @return 存储类型名称 + */ + String getStorageType(String storageCode); + + /** + * 获取默认存储服务 + * @return 返回默认存储服务 + */ + Storage getStorage(); + + /** + * 根据存储服务编码获取存储服务信息 + * @param storageCode 存储服务编码 + * @return 存储服务信息 + */ + Storage getStorage(String storageCode); + +} diff --git a/src/main/java/com/mikufufu/modules/storage/service/UploadService.java b/src/main/java/com/mikufufu/modules/storage/service/UploadService.java new file mode 100644 index 0000000..3215625 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/service/UploadService.java @@ -0,0 +1,65 @@ +package com.mikufufu.modules.storage.service; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.OutputStream; +import java.util.List; +import java.util.Map; + +/** + * 文件服务接口 + * + */ +public interface UploadService { + + /** + * 文件上传 + * @param multipartFile 文件流 + * @return 文件资源链接 + */ + Map upload(MultipartFile multipartFile); + + /** + * 文件上传 + * @param multipartFile 文件流 + * @param pathName 文件路径 + * @return 文件资源链接 + */ + Map upload(MultipartFile multipartFile,String pathName); + + /** + * 文件下载 外链 + * @param fileName 文件名 + * @return 文件资源链接 + */ + String download(String fileName); + + /** + * 文件删除 外链 + * @param fileName 文件名 + * @return flag 删除成功的标志 + */ + Boolean delete(String fileName); + + /** + * 图片下载二进制流 + * @param fileName 文件名 + * @param outputStream 输出流 + */ + void downloadImage(String fileName, OutputStream outputStream); + + /** + * 获取该前缀的对象列表信息 包括子目录下的对象 + * @param objectNamePrefix 对象名前缀 + * @param isSubDir 是否包含子目录 + * @return 对象列表信息 + */ + List> listObjects(String objectNamePrefix, Boolean isSubDir); + + /** + * 获取该前缀的对象列表信息 + * @param objectNamePrefix 对象名前缀 + * @return 对象列表信息 + */ + List> list(String objectNamePrefix); +} diff --git a/src/main/java/com/mikufufu/modules/storage/service/impl/StorageServiceImpl.java b/src/main/java/com/mikufufu/modules/storage/service/impl/StorageServiceImpl.java new file mode 100644 index 0000000..91e6cd6 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/service/impl/StorageServiceImpl.java @@ -0,0 +1,53 @@ +package com.mikufufu.modules.storage.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mikufufu.modules.storage.enums.StorageType; +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.storage.model.entity.Storage; +import com.mikufufu.mapper.StorageMapper; +import com.mikufufu.modules.storage.service.IStorageService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.mikufufu.modules.system.service.ISettingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + *

+ * 存储桶表 服务实现类 + *

+ * + * + * @since 2024-12-16 + */ +@Service +public class StorageServiceImpl extends ServiceImpl implements IStorageService { + + @Autowired + private ISettingService settingService; + + @Override + public String getStorageType(String storageCode) { + Integer type = getStorage(storageCode).getStorageType(); + StorageType storageType = StorageType.getStorageType(type); + return storageType!=null?storageType.getCode():null; + } + + @Override + public Storage getStorage() { + return getStorage(settingService.getStorageCode()); + } + + @Override + public Storage getStorage(String storageCode) { + if(StringUtils.isBlank(storageCode)){ + throw new RuntimeException("存储服务商编码为空"); + } + Storage storage = getOne(new LambdaQueryWrapper() + .eq(Storage::getStorageCode,storageCode) + ); + if(StringUtils.isEmpty(storage)){ + throw new RuntimeException("未找到存储方式"); + } + return storage; + } +} diff --git a/src/main/java/com/mikufufu/modules/storage/service/impl/UploadServiceImpl.java b/src/main/java/com/mikufufu/modules/storage/service/impl/UploadServiceImpl.java new file mode 100644 index 0000000..c26bd8b --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/service/impl/UploadServiceImpl.java @@ -0,0 +1,115 @@ +package com.mikufufu.modules.storage.service.impl; + +import com.mikufufu.modules.storage.strategy.mode.StorageMode; +import com.mikufufu.common.enums.UploadFileType; +import com.mikufufu.modules.storage.service.UploadService; +import com.mikufufu.modules.storage.strategy.StorageStrategy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 文件处理接口实现类 + * + */ +@Slf4j +@Service +public class UploadServiceImpl implements UploadService { + + @Autowired + private StorageStrategy storageStrategy; + + private StorageMode getStorageMode() { + return storageStrategy.getStorageMode(); + } + + @Override + public Map upload(MultipartFile multipartFile) { + // 将上传文件的类型转换成全大写 + String type = Objects.requireNonNull(multipartFile.getContentType()).split("/")[0].toUpperCase(); + return upload(multipartFile, UploadFileType.valueOf(type).getPath()); + } + + @Override + public Map upload(MultipartFile multipartFile, String pathName) { + String dateString = LocalDate.now().format(StorageMode.DATE_TIME_FORMATTER); + String fileName = dateString + "_" + multipartFile.getOriginalFilename(); +// return StorageMode.upload(multipartFile,pathName,fileName); + InputStream inputStream = null; + try { + inputStream = multipartFile.getInputStream(); + String url = getStorageMode().upload(multipartFile,pathName,fileName); + // TODO 实现缩略图功能 + String thumbUrl = null; +// String thumbUrl = StorageMode.upload(ImageUtils.compressImageToInputStream(multipartFile,200,200),"thumb","thumb_" + fileName); + return new HashMap<>(){{ + put("url",url); + put("thumbUrl",thumbUrl); + }}; + }catch (IOException io){ + log.error(io.getMessage()); + }finally { + if (null != inputStream){ + try { + inputStream.close(); + }catch (Exception e){ + log.error("文件流关闭失败:{}",e.getMessage()); + } + } + } + return null; + } + + @Override + public String download(String fileName) { + return getStorageMode().getObjectUrl(fileName); + } + + @Override + public Boolean delete(String fileName) { + return getStorageMode().delete(fileName); + } + + @Override + public void downloadImage(String fileName, OutputStream outputStream) { + InputStream stream = null; + try { + stream = getStorageMode().download(fileName); + } catch (Exception e) { + log.error("获取对象二进制流失败:{}",e.getMessage()); + } + try { + if (stream == null) { + throw new IOException("文件下载失败"); + } + BufferedImage bufferedImage = ImageIO.read(stream); + String suffix = fileName.substring(fileName.lastIndexOf(".") + 1); +// ImageIO.write(bufferedImage, "jpg", outputStream); + ImageIO.write(bufferedImage, suffix, outputStream); + }catch (IOException e) { + log.error("二进制流转换成图片失败:{}",e.getMessage()); + } + } + + @Override + public List> listObjects(String objectNamePrefix, Boolean isSubDir) { + return getStorageMode().listObjects(objectNamePrefix,isSubDir); + } + + @Override + public List> list(String objectNamePrefix) { + return getStorageMode().listObjects(objectNamePrefix); + } +} diff --git a/src/main/java/com/mikufufu/modules/storage/strategy/StorageStrategy.java b/src/main/java/com/mikufufu/modules/storage/strategy/StorageStrategy.java new file mode 100644 index 0000000..792142a --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/strategy/StorageStrategy.java @@ -0,0 +1,89 @@ +package com.mikufufu.modules.storage.strategy; + +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.storage.strategy.mode.StorageMode; +import com.mikufufu.modules.storage.enums.StorageType; +import com.mikufufu.modules.storage.service.IStorageService; +import com.mikufufu.modules.system.service.ISettingService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class StorageStrategy { + + @Autowired + private ISettingService settingService; + @Autowired + private IStorageService storageService; + + /** + * 存储模式 + */ + private volatile StorageMode storageMode = null; + + /** + * 获取存储类型。该方法首先从设置服务中获取存储编码,然后基于存储编码查询存储类型。 + * 如果存储编码存在但对应的存储类型不存在,则记录错误日志并抛出运行时异常。 + * 如果存储编码不存在,则默认返回"local"作为存储类型。 + * + * @return 返回存储类型字符串。如果无法确定存储类型,则抛出运行时异常。 + */ + private String getStorageType(){ + // 从设置服务获取存储编码 + String storageCode = settingService.getStorageCode(); + if (StringUtils.isNotBlank(storageCode)) { + log.info("当前存储服务编码为:{}",storageCode); + // 根据存储编码获取存储类型 + String storageType = storageService.getStorageType(storageCode); + log.info("当前存储服务类型为:{}",storageType); + if (StringUtils.isBlank(storageType)) { + log.error("当前存储服务商暂时不支持"); + throw new RuntimeException("当前存储服务商暂时不支持"); + } + // 返回获取到的存储类型 + return storageType; + } + // 如果没有获取到存储编码,返回默认的存储类型"local" + return "local"; + } + + /** + * 初始化存储模式。如果存储模式尚未被初始化,则调用此方法。 + * 由于使用了volatile关键字,此方法确保了线程之间的可见性。 + */ + private void initStorageMode() { + if (storageMode == null) { + synchronized (this) { + // 再次检查以避免双重检查锁定(DCL)的问题 + if (storageMode == null) { + storageMode = StorageType.getStorageMode(getStorageType()); + } + } + } + } + + /** + * 更新存储模式。 + * 该方法会检查当前的存储模式是否已经设置。如果已经设置,会根据获取的存储类型来更新存储模式; + * 如果未设置,则会调用initStorageMode方法来初始化存储模式。 + * + * @return 总是返回true,表示存储模式已成功更新或初始化。 + */ + public Boolean updateStorageMode() { + if (storageMode != null){ + // 存储模式已设置,根据当前的存储类型更新存储模式 + storageMode = StorageType.getStorageMode(getStorageType()); + }else { + // 存储模式未设置,进行初始化 + initStorageMode(); + } + return true; + } + + public StorageMode getStorageMode() { + initStorageMode(); + return storageMode; + } +} diff --git a/src/main/java/com/mikufufu/modules/storage/strategy/mode/StorageMode.java b/src/main/java/com/mikufufu/modules/storage/strategy/mode/StorageMode.java new file mode 100644 index 0000000..03c9ffa --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/strategy/mode/StorageMode.java @@ -0,0 +1,120 @@ +package com.mikufufu.modules.storage.strategy.mode; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 存储模式接口类 + * + */ +public interface StorageMode { + + DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * 文件上传 + * @param multipartFile 文件 + * @return 文件资源链接 + */ + String upload(MultipartFile multipartFile); + + /** + * 文件上传 + * @param multipartFile 文件 + * @param objectName 文件路径 + * @return 文件资源链接 + */ + String upload(MultipartFile multipartFile,String objectName); + + /** + * 文件上传 + * @param pathName 文件路径 + * @param multipartFile 文件 + * @return 文件资源链接 + */ + String upload(String pathName,MultipartFile multipartFile); + + /** + * 文件上传 + * @param multipartFile 文件 + * @param pathName 文件路径 + * @param objectName 文件名 + * @return 文件资源链接 + */ + String upload(MultipartFile multipartFile,String pathName,String objectName); + + /** + * 文件上传 + * @param inputStream 文件 + * @param pathName 文件路径 + * @param objectName 文件名 + * @return 文件资源链接 + */ + String upload(InputStream inputStream,String pathName,String objectName); + + /** + * 文件下载 外链 + * @param objectName 文件名 + * @return {@link InputStream} 文件的二进制流 + */ + InputStream download(String objectName); + + /** + * 文件删除 外链 + * @param objectName 文件名 + * @return flag 删除成功的标志 + */ + Boolean delete(String objectName); + + /** + * 获取文件的外链 + * @param objectName 文件名 + * @return 文件的外链 + */ + String getObjectUrl(String objectName); + + /** + * 获取文件的外链 + * @param objectName 文件名 + * @param duration 有效时长 + * @param unit 有效时长单位 + * @return 文件的外链 + */ + String getObjectUrl(String objectName, Integer duration, TimeUnit unit); + + /** + * 获取文件的外链 + * @param objectName 文件名 + * @return 文件的外链 + */ + String getObjectUrlLong(String objectName); + + /** + * 获取该前缀的对象列表信息 包括子目录下的对象 + * @param objectNamePrefix 对象名前缀 + * @param isSubDir 是否包含子目录 + * @return 对象列表信息 + */ + List> listObjects(String objectNamePrefix, Boolean isSubDir); + + /** + * minio对象存储 列出桶的对象列表信息 + * @param objectNamePrefix 对象名前缀 + * @param maxKeys 最大值 + * @param isSubDir 是否包含子目录 + * @return 对象列表信息 + */ + List> listObjects(String objectNamePrefix, Integer maxKeys, Boolean isSubDir); + + /** + * 获取该前缀的对象列表信息 + * @param objectNamePrefix 对象名前缀 + * @return 对象列表信息 + */ + List> listObjects(String objectNamePrefix); +} diff --git a/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/LocalMode.java b/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/LocalMode.java new file mode 100644 index 0000000..b25a89c --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/LocalMode.java @@ -0,0 +1,208 @@ +package com.mikufufu.modules.storage.strategy.mode.impl; + +import com.mikufufu.modules.storage.strategy.mode.StorageMode; +import com.mikufufu.core.utils.FileUtils; +import com.mikufufu.core.utils.SpringUtils; +import com.mikufufu.core.utils.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.InputStream; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 本地存储模式实现类 + * + */ +@Slf4j +public class LocalMode implements StorageMode { + + private final String host = SpringUtils.getHost(); + + private final String uploadPath = SpringUtils.getProperty("web.resource-path"); + + @Override + public String upload(MultipartFile multipartFile) { + return upload(multipartFile, multipartFile.getOriginalFilename()); + } + + @Override + public String upload(MultipartFile multipartFile, String objectName) { + return upload(multipartFile, "", objectName); + } + + @Override + public String upload(String pathName, MultipartFile multipartFile) { + return upload(multipartFile, pathName, multipartFile.getOriginalFilename()); + } + + @Override + public String upload(MultipartFile multipartFile, String pathName, String objectName) { + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + try { + String filePath = uploadPath + pathName; + File file = new File(filePath); + if (!file.exists()) { + file.mkdirs(); + } + String path = filePath + "/" + objectName; + multipartFile.transferTo(new File(path)); + return host + "/" + pathName + "/" + objectName; + } catch (Exception e) { + log.error("文件上传失败", e); + throw new RuntimeException("文件上传失败"); + } + } + + @Override + public String upload(InputStream inputStream, String pathName, String objectName) { + try { + String filePath = uploadPath + pathName; + File file = new File(filePath); + if (!file.exists()) { + file.mkdirs(); + } + String fileName = filePath + "/" + objectName; + // 获取文件名的最后一个点的位置 + int index = objectName.lastIndexOf("."); + if (index == -1) { + throw new RuntimeException("文件名没有后缀"); + } + String suffix = objectName.substring(index + 1); + BufferedImage bufferedImage = ImageIO.read(inputStream); + ImageIO.write(bufferedImage,suffix,new File(fileName)); + return host + "/" + pathName + "/" + objectName; + } catch (Exception e) { + log.error("文件上传失败", e); + throw new RuntimeException("文件上传失败"); + }finally { + if (null != inputStream){ + try { + inputStream.close(); + }catch (Exception e){ + log.error("文件流关闭失败:{}",e.getMessage()); + } + } + } + } + + @Override + public InputStream download(String objectName) { + if (StringUtils.isBlank(objectName)) { + throw new RuntimeException("文件名不能为空"); + } + try { + // 本地文件路径 + return new File(uploadPath + objectName).toURI().toURL().openStream(); + } catch (Exception e) { + log.error("文件下载失败", e); + throw new RuntimeException("文件下载失败"); + } + } + + @Override + public Boolean delete(String objectName) { + if (StringUtils.isBlank(objectName)) { + return false; + } + try { + File file = new File(uploadPath + objectName); + if (file.exists()) { + return file.delete(); + } + return false; + } catch (Exception e) { + log.error("文件删除失败", e); + throw new RuntimeException("文件删除失败"); + } + } + + @Override + public String getObjectUrl(String objectName) { + if (StringUtils.isBlank(objectName)) { + throw new RuntimeException("文件名不能为空"); + } + return host + "/" + objectName; + } + + @Override + public String getObjectUrl(String objectName, Integer duration, TimeUnit unit) { + return getObjectUrl(objectName); + } + + @Override + public String getObjectUrlLong(String objectName) { + return getObjectUrl(objectName); + } + + @Override + public List> listObjects(String objectNamePrefix, Boolean isSubDir) { + if (StringUtils.isBlank(objectNamePrefix)) { + throw new RuntimeException("文件名不能为空"); + } + try { + // 本地文件路径 + File file = new File(uploadPath + objectNamePrefix); + if (!file.exists()) { + throw new RuntimeException("文件不存在"); + } + return Arrays.stream(Objects.requireNonNull(file.list())).map(fileName -> { + Map map = new HashMap<>(); + map.put("name", objectNamePrefix + "/" + fileName); + map.put("url", host + "/" + objectNamePrefix + "/" + fileName); + map.put("size", FileUtils.convertFileSize(new File(uploadPath + objectNamePrefix + "/" + fileName).length())); + // 将时间戳转换为Instant + Instant instant = Instant.ofEpochMilli(new File(uploadPath + objectNamePrefix + fileName).lastModified()); + // 转换Instant到LocalDate,使用默认时区 + map.put("lastModified", DATE_TIME_FORMATTER.format(LocalDate.ofInstant(instant, ZoneId.systemDefault()))); + return map; + }).collect(Collectors.toList()); + } catch (Exception e) { + log.error("文件列表获取失败", e); + throw new RuntimeException("文件列表获取失败"); + } + } + + @Override + public List> listObjects(String objectNamePrefix, Integer maxKeys, Boolean isSubDir) { + if (StringUtils.isBlank(objectNamePrefix)) { + throw new RuntimeException("文件名不能为空"); + } + try { + // 本地文件路径 + File file = new File(uploadPath + objectNamePrefix); + if (!file.exists()) { + throw new RuntimeException("文件不存在"); + } + return Arrays.stream(Objects.requireNonNull(file.list())).map(fileName -> { + Map map = new HashMap<>(); + map.put("name", objectNamePrefix + "/" + fileName); + map.put("url", host + "/" + objectNamePrefix + "/" + fileName); + map.put("size", FileUtils.convertFileSize(new File(uploadPath + objectNamePrefix + "/" + fileName).length())); + // 将时间戳转换为Instant + Instant instant = Instant.ofEpochMilli(new File(uploadPath + objectNamePrefix + fileName).lastModified()); + // 转换Instant到LocalDate,使用默认时区 + map.put("lastModified", DATE_TIME_FORMATTER.format(LocalDate.ofInstant(instant, ZoneId.systemDefault()))); + return map; + }).collect(Collectors.toList()).subList(0, maxKeys); + } catch (Exception e) { + log.error("文件列表获取失败", e); + throw new RuntimeException("文件列表获取失败"); + } + } + + @Override + public List> listObjects(String objectNamePrefix) { + return listObjects(objectNamePrefix, false); + } +} diff --git a/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/MinioMode.java b/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/MinioMode.java new file mode 100644 index 0000000..325004f --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/MinioMode.java @@ -0,0 +1,293 @@ +package com.mikufufu.modules.storage.strategy.mode.impl; + +import com.mikufufu.modules.storage.strategy.mode.StorageMode; +import com.mikufufu.modules.storage.model.entity.Storage; +import com.mikufufu.modules.storage.service.IStorageService; +import com.mikufufu.core.utils.FileUtils; +import com.mikufufu.core.utils.SpringUtils; +import com.mikufufu.core.utils.StringUtils; +import io.minio.*; +import io.minio.http.Method; +import io.minio.messages.Item; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * Minio存储模式实现类 + * + */ +@Slf4j +public class MinioMode implements StorageMode { + + /** + * 获取配置的Minio客户端 + * @return Minio客户端 + */ + public MinioClient getMinioClient(){ + Storage storage = getOssProp(); + if (storage != null) { + return MinioClient.builder() + .endpoint(storage.getEndpoint()) + .credentials(storage.getAccessKey(), storage.getSecretKey()) + .build(); + } + throw new RuntimeException("未配置minio"); + } + + private Storage getOssProp(){ + return SpringUtils.getBean(IStorageService.class).getStorage(); + } + + @Override + public String upload(MultipartFile multipartFile) { + // 判断上传文件是否为空 + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + log.info("打印文件资源名{}",multipartFile.getOriginalFilename()); + return upload(multipartFile,multipartFile.getOriginalFilename()); + } + + @Override + public String upload(MultipartFile multipartFile, String objectName) { + // 判断上传文件是否为空 + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + return upload(multipartFile,"",objectName); + } + + @Override + public String upload(String pathName, MultipartFile multipartFile) { + // 判断上传文件是否为空 + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + return upload(multipartFile,pathName,multipartFile.getOriginalFilename()); + } + + @Override + public String upload(MultipartFile multipartFile, String pathName, String objectName) { + // 判断上传文件是否为空 + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + InputStream inputStream = null; + try { + inputStream = multipartFile.getInputStream(); + //minio的上传文件参数 + PutObjectArgs putObjectArgs = PutObjectArgs.builder() + //设置数据桶 + .bucket(getOssProp().getBucketName()) + //上传的文件名 + .object(pathName + "/" + objectName) + //上传的文件类型 + .contentType(multipartFile.getContentType()) + //上传的文件二进制流 + .stream(inputStream, multipartFile.getSize(), -1) + .build(); + //调用MinioClient的putObject方法上传文件 + getMinioClient().putObject(putObjectArgs); + String bucketName = getOssProp().getForcedPathMode() == 0 ? getOssProp().getBucketName() + "/":""; + if (StringUtils.isBlank(pathName)) { + return getOssProp().getHost() + "/" + bucketName + objectName; + } + return getOssProp().getHost() + "/" + bucketName + pathName + "/" + objectName; + } catch (Exception e) { + log.error(e.getMessage()); + throw new RuntimeException("上传文件失败"); + }finally { + if (null != inputStream){ + try { + inputStream.close(); + }catch (Exception e){ + log.error("文件流关闭失败:{}",e.getMessage()); + } + } + } + } + + @Override + public String upload(InputStream inputStream, String pathName, String objectName) { + try { + PutObjectArgs putObjectArgs = PutObjectArgs.builder() + .bucket(getOssProp().getBucketName()) + .object(pathName + "/" + objectName) + .stream(inputStream,inputStream.available(),-1) + .build(); + getMinioClient().putObject(putObjectArgs); + String bucketName = getOssProp().getForcedPathMode() == 0 ? getOssProp().getBucketName() + "/":""; + if (StringUtils.isBlank(pathName)) { + return getOssProp().getHost() + "/" + bucketName + objectName; + } + return getOssProp().getHost() + "/" + bucketName + pathName + "/" + objectName; + } catch (Exception e) { + log.error(e.getMessage()); + throw new RuntimeException("文件上传失败"); + }finally { + if (null != inputStream){ + try { + inputStream.close(); + }catch (Exception e){ + log.error("文件流关闭失败:{}",e.getMessage()); + } + } + } + } + + @Override + public InputStream download(String objectName) { + try { + //minio的获取文件的参数 + GetObjectArgs getObjectArgs = GetObjectArgs.builder() + .bucket(getOssProp().getBucketName()) + .object(objectName) + .build(); + //调用MinioClient的getObject方法获取文件 + return getMinioClient().getObject(getObjectArgs); + }catch (Exception e){ + log.error("获取文件失败:{}",e.getMessage()); + throw new RuntimeException("文件下载失败"); + } + } + + @Override + public Boolean delete(String objectName) { + try { + RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder() + .bucket(getOssProp().getBucketName()) + .object(objectName) + .build(); + getMinioClient().removeObject(removeObjectArgs); + return true; + }catch (Exception e){ + log.error(e.getMessage()); + return false; + } + } + + @Override + public String getObjectUrl(String objectName) { + return getObjectUrl(objectName,7,TimeUnit.DAYS); + } + + @Override + public String getObjectUrl(String objectName, Integer duration, TimeUnit unit) { + try { + if (null == duration || null == unit){ + duration = 7; + unit = TimeUnit.DAYS; + } + GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder() + .bucket(getOssProp().getBucketName()) + .method(Method.GET) + .expiry(duration, unit) + .object(objectName) + .build(); + return getMinioClient().getPresignedObjectUrl(args); + }catch (Exception e){ + log.error(e.getMessage()); + throw new RuntimeException("获取文件链接失败"); + } + } + + @Override + public String getObjectUrlLong(String objectName) { + return getObjectUrl(objectName,0,null); + } + + @Override + public List> listObjects(String objectNamePrefix, Boolean isSubDir) { + return listObjects(objectNamePrefix, 1000, isSubDir); + } + + @Override + public List> listObjects(String objectNamePrefix, Integer maxKeys, Boolean isSubDir) { + try { + ListObjectsArgs listObjectsArgs = ListObjectsArgs.builder() + .bucket(getOssProp().getBucketName()) + //设置取出数量的最大值 + .maxKeys(maxKeys) + //设置前缀 + .prefix(objectNamePrefix) + .build(); + Iterable> listObjects = getMinioClient().listObjects(listObjectsArgs); + List> list = new ArrayList<>(); + listObjects.forEach(itemResult -> { + try { + Item item = itemResult.get(); + // 当这个对象是文件夹时 + if (item.isDir()){ + // 如果包含子文件夹的对象 + if (isSubDir){ + list.addAll(Objects.requireNonNull(listObjects(item.objectName(), maxKeys, isSubDir))); + } + }else { + Map map = new HashMap<>(); + map.put("name",item.objectName()); + String bucketName = getOssProp().getForcedPathMode() == 0 ? getOssProp().getBucketName() + "/":""; + map.put("url",getOssProp().getHost() + "/" + bucketName + item.objectName()); + map.put("size", FileUtils.convertFileSize(item.size())); + map.put("lastModified",item.lastModified().format(DATE_TIME_FORMATTER)); + list.add(map); + } + // 写法2 + // 当这个对象不是文件夹时 +// if (!item.isDir()){ +// String url = getOssProp().getHost() + "/" + getOssProp().getBucketName() + "/" + item.objectName(); +// list.add(url); +// // 如果包含子文件夹的对象 +// }else if (isSubDir){ +// list.addAll(listObjects(item.objectName(), maxKeys, isSubDir)); +// } + }catch (Exception e){ + log.error(e.getMessage()); + } + }); + return list; + } catch (Exception e) { + log.error(e.getMessage()); + throw new RuntimeException("获取存储对象列表失败"); + } + } + + @Override + public List> listObjects(String objectNamePrefix) { + try { + ListObjectsArgs listObjectsArgs = ListObjectsArgs.builder() + .bucket(getOssProp().getBucketName()) + //设置前缀 + .prefix(objectNamePrefix) + .build(); + Iterable> listObjects = getMinioClient().listObjects(listObjectsArgs); + List> list = new ArrayList<>(); + listObjects.forEach(itemResult -> { + try { + Item item = itemResult.get(); + // 当这个对象是文件夹时 + if (item.isDir()){ + list.addAll(Objects.requireNonNull(listObjects(item.objectName()))); + }else { + Map map = new HashMap<>(); + map.put("name",item.objectName()); + String bucketName = getOssProp().getForcedPathMode() == 0 ? getOssProp().getBucketName() + "/":""; + map.put("url",getOssProp().getHost() + "/" + bucketName + item.objectName()); + map.put("size", FileUtils.convertFileSize(item.size())); + map.put("lastModified",item.lastModified().format(DATE_TIME_FORMATTER)); + list.add(map); + } + }catch (Exception e){ + log.error(e.getMessage()); + } + }); + return list; + } catch (Exception e) { + log.error(e.getMessage()); + throw new RuntimeException("获取存储对象列表失败"); + } + } +} diff --git a/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/OssMode.java b/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/OssMode.java new file mode 100644 index 0000000..b612c70 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/storage/strategy/mode/impl/OssMode.java @@ -0,0 +1,363 @@ +package com.mikufufu.modules.storage.strategy.mode.impl; + +import com.aliyun.oss.ClientException; +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.OSSException; +import com.aliyun.oss.model.ListObjectsRequest; +import com.aliyun.oss.model.OSSObjectSummary; +import com.aliyun.oss.model.ObjectListing; +import com.mikufufu.modules.storage.strategy.mode.StorageMode; +import com.mikufufu.modules.storage.model.entity.Storage; +import com.mikufufu.modules.storage.service.IStorageService; +import com.mikufufu.core.utils.FileUtils; +import com.mikufufu.core.utils.SpringUtils; +import com.mikufufu.core.utils.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 阿里OSS存储模式实现类 + * + */ +@Slf4j +public class OssMode implements StorageMode { + + /** + * 获取配置的阿里OSS客户端 + * @return 阿里OSS客户端 + */ + public OSS getOssClient(){ + Storage storage = getOssProp(); + if (storage != null) { + return new OSSClientBuilder().build( + storage.getEndpoint(), + storage.getAccessKey(), + storage.getSecretKey() + ); + } + throw new RuntimeException("未配置minio"); + } + + private Storage getOssProp(){ + return SpringUtils.getBean(IStorageService.class).getStorage(); + } + + @Override + public String upload(MultipartFile multipartFile) { + // 判断上传文件是否为空 + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + return upload(multipartFile, multipartFile.getOriginalFilename()); + } + + @Override + public String upload(MultipartFile multipartFile, String objectName) { + if(isExist(objectName)){ + return getObjectUrlLong(objectName); + }else { + // 判断上传文件是否为空 + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + return upload(multipartFile, "", objectName); + } + } + + /** + * 阿里OSS 判断文件是否已存在 + * @param objectName 文件名称 + * @return 文件是否存在 如果返回值为true,则文件存在,否则存储空间或者文件不存在。 + */ + public boolean isExist(String objectName){ + try { + // 判断oss上文件是否存在 + return getOssClient().doesObjectExist(getOssProp().getBucketName(), objectName); + }catch (OSSException oe){ + log.error("捕获到OSSException,这意味着您的请求已发送到OSS, " + + "但是由于某种原因以错误响应被拒绝。"); + log.error("Error Message:" + oe.getErrorMessage()); + log.error("Error Code:" + oe.getErrorCode()); + log.error("Request ID:" + oe.getRequestId()); + log.error("Host ID:" + oe.getHostId()); + }catch (ClientException ce){ + log.error("捕获ClientException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ce.getMessage()); + } + return false; + } + + @Override + public String upload(String pathName, MultipartFile multipartFile) { + // 判断上传文件是否为空 + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + return upload(multipartFile, pathName, multipartFile.getOriginalFilename()); + } + + @Override + public String upload(MultipartFile multipartFile, String pathName, String objectName) { + // 判断上传文件是否为空 + if (null == multipartFile || 0 == multipartFile.getSize()) { + throw new RuntimeException("文件不能为空"); + } + InputStream inputStream = null; + try { + inputStream = multipartFile.getInputStream(); + // 上传文件 + getOssClient().putObject( + // 存储空间 + getOssProp().getBucketName(), + // 上传的文件名 + pathName + "/" + objectName, + // 上传文件的输入流 + inputStream + ); + // 返回文件上传路径 + if (StringUtils.isEmpty(pathName)) { + return getOssProp().getHost() + "/" + objectName; + } + return getOssProp().getHost() + "/" + pathName + "/" + objectName; + } catch (OSSException oe){ + log.error("捕获到OSSException,这意味着您的请求已发送到OSS, " + + "但是由于某种原因以错误响应被拒绝。"); + log.error("Error Message:" + oe.getErrorMessage()); + log.error("Error Code:" + oe.getErrorCode()); + log.error("Request ID:" + oe.getRequestId()); + log.error("Host ID:" + oe.getHostId()); + throw new RuntimeException("上传失败"); + }catch (ClientException ce){ + log.error("捕获ClientException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ce.getMessage()); + throw new RuntimeException("上传失败"); + }catch (IOException ioe){ + log.error("捕获IOException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ioe.getMessage()); + throw new RuntimeException("上传失败"); + }finally { + if (null != inputStream){ + try { + inputStream.close(); + }catch (Exception e){ + log.error("文件流关闭失败:{}",e.getMessage()); + } + } + } + } + + @Override + public String upload(InputStream inputStream, String pathName, String objectName) { + try { + getOssClient().putObject( + getOssProp().getBucketName(), + pathName + "/" + objectName, + inputStream + ); + if (StringUtils.isBlank(pathName)) { + return getOssProp().getHost() + "/" + objectName; + } + return getOssProp().getHost() + "/" + pathName + "/" + objectName; + } catch (OSSException oe){ + log.error("捕获到OSSException,这意味着您的请求已发送到OSS, " + + "但是由于某种原因以错误响应被拒绝。"); + log.error("Error Message:" + oe.getErrorMessage()); + log.error("Error Code:" + oe.getErrorCode()); + log.error("Request ID:" + oe.getRequestId()); + log.error("Host ID:" + oe.getHostId()); + throw new RuntimeException("上传失败"); + }catch (ClientException ce){ + log.error("捕获ClientException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ce.getMessage()); + throw new RuntimeException("上传失败"); + } finally { + if (null != inputStream){ + try { + inputStream.close(); + }catch (Exception e){ + log.error("文件流关闭失败:{}",e.getMessage()); + } + } + } + } + + @Override + public InputStream download(String objectName) { + try { + // 下载文件 + return getOssClient().getObject(getOssProp().getBucketName(), objectName).getObjectContent(); + } catch (OSSException oe) { + log.error("捕获到OSSException,这意味着您的请求已发送到OSS, " + + "但是由于某种原因以错误响应被拒绝。"); + log.error("Error Message:" + oe.getErrorMessage()); + log.error("Error Code:" + oe.getErrorCode()); + log.error("Request ID:" + oe.getRequestId()); + log.error("Host ID:" + oe.getHostId()); + throw new RuntimeException("下载文件失败"); + } catch (ClientException ce) { + log.error("捕获ClientException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ce.getMessage()); + throw new RuntimeException("下载文件失败"); + } + } + + @Override + public Boolean delete(String objectName) { + try { + // 删除文件 + getOssClient().deleteObject(getOssProp().getBucketName(), objectName); + return true; + } catch (OSSException oe) { + log.error("捕获到OSSException,这意味着您的请求已发送到OSS, " + + "但是由于某种原因以错误响应被拒绝。"); + log.error("Error Message:" + oe.getErrorMessage()); + log.error("Error Code:" + oe.getErrorCode()); + log.error("Request ID:" + oe.getRequestId()); + log.error("Host ID:" + oe.getHostId()); + } catch (ClientException ce) { + log.error("捕获ClientException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ce.getMessage()); + } + return false; + } + + @Override + public String getObjectUrl(String objectName) { + return getObjectUrl(objectName, 7, TimeUnit.DAYS); + } + + @Override + public String getObjectUrl(String objectName, Integer duration, TimeUnit unit) { + try { + return getOssClient().generatePresignedUrl(getOssProp().getBucketName(), + objectName, + // 设置URL过期时间 + new Date(System.currentTimeMillis() + unit.toMillis(duration)) + ).toString(); + } catch (OSSException oe) { + log.error("捕获到OSSException,这意味着您的请求已发送到OSS, " + + "但是由于某种原因以错误响应被拒绝。"); + log.error("Error Message:" + oe.getErrorMessage()); + log.error("Error Code:" + oe.getErrorCode()); + log.error("Request ID:" + oe.getRequestId()); + log.error("Host ID:" + oe.getHostId()); + throw new RuntimeException("获取外链失败"); + } catch (ClientException ce) { + log.error("捕获ClientException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ce.getMessage()); + throw new RuntimeException("获取外链失败"); + } + } + + @Override + public String getObjectUrlLong(String objectName) { + try { + // 判断文件是否存在。如果返回值为true,则文件存在,否则存储空间或者文件不存在。 + boolean exist = getOssClient().doesObjectExist(getOssProp().getBucketName(), objectName); + if (exist) { + // 获取文件外链 + return getOssProp().getHost() + "/" + objectName; + } + throw new RuntimeException("文件不存在"); + } catch (OSSException oe) { + log.error("捕获到OSSException,这意味着您的请求已发送到OSS, " + + "但是由于某种原因以错误响应被拒绝。"); + log.error("Error Message:" + oe.getErrorMessage()); + log.error("Error Code:" + oe.getErrorCode()); + log.error("Request ID:" + oe.getRequestId()); + log.error("Host ID:" + oe.getHostId()); + throw new RuntimeException("获取外链失败"); + } catch (ClientException ce) { + log.error("捕获ClientException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ce.getMessage()); + throw new RuntimeException("获取外链失败"); + } + } + + @Override + public List> listObjects(String objectNamePrefix, Boolean isSubDir) { + return listObjects(objectNamePrefix, 100, isSubDir); + } + + @Override + public List> listObjects(String objectNamePrefix, Integer maxKeys, Boolean isSubDir) { + try { + ListObjectsRequest listObjectsRequest = new ListObjectsRequest(getOssProp().getBucketName()) + // 列举文件。objectNamePrefix,则列举存储空间下的所有文件。objectNamePrefix,则列举包含指定前缀的文件。 + .withPrefix(objectNamePrefix) + // 设置最大个数。 + .withMaxKeys(maxKeys); + ObjectListing objectListing = getOssClient().listObjects(listObjectsRequest); + // 遍历所有文件。 + List sums = objectListing.getObjectSummaries(); + // 获取该资源空间下所有objectName 例如:[test/1.txt, test/2.txt] + // stream().map()方法是将list中的每一个元素映射成一个新的元素,然后将这些新的元素组成一个Stream流。 + // collect(Collectors.toList())方法是将流中的元素收集到List中。 +// return sums.stream().map(OSSObjectSummary::getKey).collect(Collectors.toList()); + return sums.stream().map(ossObject -> { + Map map = new HashMap<>(); + map.put("name",ossObject.getKey()); + map.put("url",getOssProp().getHost() + "/" + ossObject.getKey()); + map.put("size", FileUtils.convertFileSize(ossObject.getSize())); + Instant instant = ossObject.getLastModified().toInstant(); + LocalDate localDate = instant.atZone(ZoneId.systemDefault()).toLocalDate(); + map.put("lastModified",localDate.format(DATE_TIME_FORMATTER)); + return map; + }).collect(Collectors.toList()); + } catch (OSSException oe) { + log.error("捕获到OSSException,这意味着您的请求已发送到OSS, " + + "但是由于某种原因以错误响应被拒绝。"); + log.error("Error Message:" + oe.getErrorMessage()); + log.error("Error Code:" + oe.getErrorCode()); + log.error("Request ID:" + oe.getRequestId()); + log.error("Host ID:" + oe.getHostId()); + throw new RuntimeException("获取对象存储列表失败"); + } catch (ClientException ce) { + log.error("捕获ClientException,这意味着客户端遇到" + + "在尝试与OSS通信时出现了严重的内部问题," + + "例如不能接入网络。"); + log.error("Error Message:" + ce.getMessage()); + throw new RuntimeException("获取对象存储列表失败"); + } +// finally { +// // 关闭OSSClient。 +// if (ossClient != null) { +// ossClient.shutdown(); +// } +// } + } + + @Override + public List> listObjects(String objectNamePrefix) { + return listObjects(objectNamePrefix, 100,false); + } +} diff --git a/src/main/java/com/mikufufu/modules/system/controller/OperateLogController.java b/src/main/java/com/mikufufu/modules/system/controller/OperateLogController.java new file mode 100644 index 0000000..123f907 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/controller/OperateLogController.java @@ -0,0 +1,26 @@ +package com.mikufufu.modules.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.modules.system.model.dto.LogDto; +import com.mikufufu.modules.system.model.entity.OperateLog; +import com.mikufufu.modules.system.service.IOperateLogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/log") +public class OperateLogController { + + @Autowired + private IOperateLogService operateLogService; + + @PostMapping("/page") + public AjaxResult> page(@RequestBody LogDto logDto) { + return AjaxResult.data(operateLogService.getPage(logDto)); + } + +} diff --git a/src/main/java/com/mikufufu/modules/system/controller/PermissionController.java b/src/main/java/com/mikufufu/modules/system/controller/PermissionController.java new file mode 100644 index 0000000..0813c36 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/controller/PermissionController.java @@ -0,0 +1,80 @@ +package com.mikufufu.modules.system.controller; + +import com.mikufufu.common.annotation.AnonymousApi; +import com.mikufufu.common.annotation.OperationLog; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.enums.ModuleType; +import com.mikufufu.common.enums.OperationType; +import com.mikufufu.modules.system.model.entity.Permission; +import com.mikufufu.modules.system.model.vo.PermissionRoleVO; +import com.mikufufu.modules.system.service.IPermissionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 权限管理 + * + */ +@RestController +@Tag(name = "权限",description = "权限管理") +@RequestMapping("/permission") +public class PermissionController { + + @Autowired + private IPermissionService permissionService; + + @Operation(summary = "获取权限列表") + @Parameter(name = "roleId",description = "角色id",required = true) + @GetMapping("/getSignList") + public AjaxResult> getSignList(Integer roleId) { + return AjaxResult.data(permissionService.getPermissionList(roleId).stream() + .filter(permission -> permission.getSign() != null && permission.getStatus() == 0 ) + .map(Permission::getSign) + .distinct() + .toList()); + } + + @Operation(summary = "获取权限角色列表") + @GetMapping("/getPermissionRoleList") + public AjaxResult> getPermissionRoleList() { + return AjaxResult.data(permissionService.getPermissionRoleList()); + } + + @GetMapping("/addRolePermission") + @Operation(summary = "为指定角色新增权限") + @Parameters({ + @Parameter(name = "roleId", description = "角色id", required = true), + @Parameter(name = "permissionId", description = "权限id", required = true) + }) + public AjaxResult addRolePermission(Integer roleId, Integer permissionId) { + return AjaxResult.status(permissionService.addRolePermission(roleId, permissionId)); + } + + @Operation(summary = "保存所有权限") + @GetMapping("/saveAllPermission") + @OperationLog(module = ModuleType.SYSTEM,type = OperationType.INSERT,description = "保存所有权限") + public AjaxResult saveAllPermissionOfController() { + return AjaxResult.status(permissionService.saveAllPermissionOfController()); + } + + @AnonymousApi + @Operation(summary = "放行所有权限") + @GetMapping("/grantAnonymousPermission") + @OperationLog(module = ModuleType.SYSTEM,type = OperationType.DELETE,description = "释放所有权限") + public AjaxResult grantAnonymousPermission() { + return AjaxResult.status(permissionService.grantAnonymousPermission()); + } + + @Operation(summary = "清除所有权限") + @GetMapping("/cleanPermissionRole") + public AjaxResult clean() { + return AjaxResult.status(permissionService.cleanPermissionRole()); + } + +} diff --git a/src/main/java/com/mikufufu/modules/system/controller/RoleController.java b/src/main/java/com/mikufufu/modules/system/controller/RoleController.java new file mode 100644 index 0000000..6bf794b --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/controller/RoleController.java @@ -0,0 +1,41 @@ +package com.mikufufu.modules.system.controller; + +import com.mikufufu.common.annotation.AnonymousApi; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.core.utils.BeanUtil; +import com.mikufufu.modules.system.model.entity.Role; +import com.mikufufu.modules.system.model.vo.RoleVO; +import com.mikufufu.modules.system.service.IRoleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +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; + +/** + * 角色管理 + * + */ +@RestController +@Tag(name = "角色",description = "角色管理") +@RequestMapping("/role") +public class RoleController { + + @Autowired + private IRoleService roleService; + + + @AnonymousApi + @Operation(summary = "获取角色列表") + @GetMapping("/list") + public AjaxResult> list(){ + List list = roleService.list(); + if(list != null && !list.isEmpty()){ + return AjaxResult.data(list.stream().map(r -> BeanUtil.copy(r, RoleVO.class)).toList()); + } + return AjaxResult.error(); + } +} diff --git a/src/main/java/com/mikufufu/modules/system/controller/SettingController.java b/src/main/java/com/mikufufu/modules/system/controller/SettingController.java new file mode 100644 index 0000000..0b3dc10 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/controller/SettingController.java @@ -0,0 +1,60 @@ +package com.mikufufu.modules.system.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.modules.system.model.entity.Setting; +import com.mikufufu.modules.system.model.vo.EmailConfig; +import com.mikufufu.modules.system.model.vo.WebSiteVO; +import com.mikufufu.modules.system.service.ISettingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/setting") +public class SettingController { + + @Autowired + private ISettingService settingService; + + @GetMapping("/getByCode") + public AjaxResult get(String code){ + // 根据配置编号查询并返回对应的系统设置信息 + return AjaxResult.data(settingService.getOne(new LambdaQueryWrapper().eq(Setting::getCode,code))); + } + + @GetMapping("/getStorageCode") + public AjaxResult getStorageCode() { + return AjaxResult.data(settingService.getStorageCode()); + } + + @PostMapping("/add") + public AjaxResult add(Setting setting) { + return AjaxResult.status(settingService.save(setting)); + } + + @PutMapping("/update") + public AjaxResult update(Setting setting) { + return AjaxResult.status(settingService.updateById(setting)); + } + + @DeleteMapping("/delete") + public AjaxResult delete(String code) { + return AjaxResult.status(settingService.remove(new LambdaQueryWrapper().eq(Setting::getCode,code))); + } + + @PostMapping("/saveWebInfo") + public AjaxResult add(@RequestBody WebSiteVO webSiteVO) { + return AjaxResult.status(settingService.saveWebInfo(webSiteVO)); + } + + @GetMapping("/getEmail") + public AjaxResult getEmail() { + EmailConfig emailConfig = settingService.getMail(); + return AjaxResult.data(emailConfig); + } + + @PostMapping("/setEmail") + public AjaxResult setEmail(@RequestBody EmailConfig emailConfig) { + return AjaxResult.status(settingService.setMail(emailConfig)); + } +} diff --git a/src/main/java/com/mikufufu/modules/system/controller/UserController.java b/src/main/java/com/mikufufu/modules/system/controller/UserController.java new file mode 100644 index 0000000..a8de07a --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/controller/UserController.java @@ -0,0 +1,79 @@ +package com.mikufufu.modules.system.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.entity.Query; +import com.mikufufu.modules.auth.utils.AuthUtils; +import com.mikufufu.modules.system.model.entity.User; +import com.mikufufu.modules.system.model.vo.UserVO; +import com.mikufufu.modules.system.service.IUserService; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/user") +@Tag(name = "用户接口") +public class UserController { + + @Autowired + private IUserService userService; + + @GetMapping("/detail") + @Operation(summary = "查询用户详情") + @Parameter(name = "id", description = "用户id", required = true) + public AjaxResult detail(Integer id) { + User user = userService.getById(id); + return AjaxResult.data(UserVO.convert(user)); + } + + /** + * 通过token查询用户数据 + * @return 用户数据 + */ + @Operation(summary = "通过token查询用户数据", description = "通过token查询用户数据") + @GetMapping("/info") + public AjaxResult getUserInfo() { + Integer id = AuthUtils.getUserId(); + User user = userService.getById(id); + return AjaxResult.data(UserVO.convert(user)); + } + + @GetMapping("/page") + @Operation(summary = "获取用户列表") + public AjaxResult> page(Query query) { + return AjaxResult.data(userService.page(query.getPage(),new LambdaQueryWrapper().eq(User::getStatus, 0))); + } + + @PostMapping("/add") + @Operation(summary = "添加用户") + public AjaxResult add(@RequestBody User user) { + return AjaxResult.data(userService.save(user)); + } + + @PutMapping("/update") + @Operation(summary = "更新用户") + public AjaxResult update(@RequestBody User user) { + return AjaxResult.data(userService.updateById(user)); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除用户") + @Parameter(name = "id", description = "用户id", required = true) + public AjaxResult delete(Integer id) { + return AjaxResult.data(userService.removeById(id)); + } + + @GetMapping("/export") + @Operation(summary = "导出用户数据") + public void export(HttpServletResponse response) { + userService.export(response); + } + +} diff --git a/src/main/java/com/mikufufu/modules/system/model/dto/LogDto.java b/src/main/java/com/mikufufu/modules/system/model/dto/LogDto.java new file mode 100644 index 0000000..a13a8d5 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/dto/LogDto.java @@ -0,0 +1,57 @@ +package com.mikufufu.modules.system.model.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.mikufufu.common.entity.Query; +import com.mikufufu.common.enums.OperationType; +import lombok.Data; +import lombok.EqualsAndHashCode; + + +import java.time.LocalDate; + +@EqualsAndHashCode(callSuper = true) +@Data +public class LogDto extends Query { + + /** + * 模块 + */ + private Integer module; + + /** + * 操作类型 {@link OperationType} 默认 插入 + */ + private Integer operateType; + + /** + * 操作人 + */ + private String operator; + + /** + * 操作方法 + */ + private String methodName; + + /** + * 操作时间 开始 + */ + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private LocalDate startDate; + + /** + * 操作时间 结束 + */ + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private LocalDate endDate; + + /** + * 操作状态 1. 成功 0. 失败 + */ + private String status; + + /** + * ip地址 + */ + private String ip; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/entity/Menu.java b/src/main/java/com/mikufufu/modules/system/model/entity/Menu.java new file mode 100644 index 0000000..9db3989 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/entity/Menu.java @@ -0,0 +1,77 @@ +package com.mikufufu.modules.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.mikufufu.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; + +/** + *

+ * 菜单信息表 + *

+ * + * + * @since 2024-12-16 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@TableName("m_menu") +public class Menu extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 菜单id + */ + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 菜单的名字 + */ + @TableField("menu_name") + private String menuName; + + /** + * 菜单的路由地址 + */ + @TableField("path") + private String path; + + /** + * 菜单对应组件地址 + */ + @TableField("component") + private String component; + + /** + * 父菜单id + */ + @TableField("parent_id") + private Integer parentId; + + /** + * 菜单图标 + */ + @TableField("icon") + private String icon; + + /** + * 是否隐藏(0.显示,1.隐藏) + */ + @TableField("status") + private Integer status; + + /** + * 排序 + */ + @TableField("sort") + private String sort; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/entity/OperateLog.java b/src/main/java/com/mikufufu/modules/system/model/entity/OperateLog.java new file mode 100644 index 0000000..43a7ca4 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/entity/OperateLog.java @@ -0,0 +1,90 @@ +package com.mikufufu.modules.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Data; + +/** + * 操作日志表 + */ +@Data +@TableName(value = "m_operate_log") +public class OperateLog implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 功能模块 + */ + @TableField(value = "`module`") + private Integer module; + + /** + * 接口地址 + */ + @TableField(value = "url") + private String url; + + /** + * 请求参数 + */ + @TableField(value = "params") + private String params; + + /** + * 操作类型 + */ + @TableField(value = "operate_type") + private Integer operateType; + + /** + * 方法名 + */ + @TableField(value = "method_name") + private String methodName; + + /** + * 操作人 + */ + @TableField(value = "`operator`") + private String operator; + + /** + * ip地址 + */ + @TableField(value = "ip") + private String ip; + + /** + * 操作时间 + */ + @TableField(value = "operate_time") + private LocalDateTime operateTime; + + /** + * 附加信息 + */ + @TableField(value = "remark") + private String remark; + + /** + * 状态(1.成功,0.失败) + */ + @TableField(value = "`status`") + private Integer status; + + /** + * 错误信息 + */ + @TableField(value = "error_message") + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/mikufufu/modules/system/model/entity/Permission.java b/src/main/java/com/mikufufu/modules/system/model/entity/Permission.java new file mode 100644 index 0000000..b70c7bd --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/entity/Permission.java @@ -0,0 +1,66 @@ +package com.mikufufu.modules.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serial; +import java.io.Serializable; + +import com.mikufufu.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + *

+ * 权限表 + *

+ * + * + * @since 2024-12-16 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@TableName("m_permission") +public class Permission extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 权限主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 请求的方法 + */ + @TableField("method") + private String method; + + /** + * 接口名称 + */ + @TableField("name") + private String name; + + /** + * 接口地址 + */ + @TableField("path") + private String path; + + /** + * 接口签名 + */ + @TableField("sign") + private String sign; + + /** + * 接口状态(0.启用,1.停用) + */ + @TableField("status") + private Integer status; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/entity/Role.java b/src/main/java/com/mikufufu/modules/system/model/entity/Role.java new file mode 100644 index 0000000..f882776 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/entity/Role.java @@ -0,0 +1,54 @@ +package com.mikufufu.modules.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serial; +import java.io.Serializable; + +import com.mikufufu.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + *

+ * 角色表 + *

+ * + * + * @since 2024-12-06 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@TableName("m_role") +public class Role extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 角色主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 角色名称 + */ + @TableField("role_name") + private String roleName; + + /** + * 角色编号 + */ + @TableField("role_code") + private String roleCode; + + /** + * 角色状态(0.启用,1.停用) + */ + @TableField("status") + private Integer status; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/entity/RoleMenu.java b/src/main/java/com/mikufufu/modules/system/model/entity/RoleMenu.java new file mode 100644 index 0000000..47c0e0f --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/entity/RoleMenu.java @@ -0,0 +1,48 @@ +package com.mikufufu.modules.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serial; +import java.io.Serializable; + +import com.mikufufu.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + *

+ * 角色导航关系表 + *

+ * + * + * @since 2024-12-16 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@TableName("m_role_menu") +public class RoleMenu extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 角色id + */ + @TableField("role_id") + private Integer roleId; + + /** + * 导航id + */ + @TableField("menu_id") + private Integer menuId; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/entity/RolePermission.java b/src/main/java/com/mikufufu/modules/system/model/entity/RolePermission.java new file mode 100644 index 0000000..4547fc0 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/entity/RolePermission.java @@ -0,0 +1,48 @@ +package com.mikufufu.modules.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serial; +import java.io.Serializable; + +import com.mikufufu.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + *

+ * 角色权限关系表 + *

+ * + * + * @since 2024-12-16 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@TableName("m_role_permission") +public class RolePermission extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 权限id + */ + @TableField("permission_id") + private Integer permissionId; + + /** + * 角色id + */ + @TableField("role_id") + private Integer roleId; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/entity/Setting.java b/src/main/java/com/mikufufu/modules/system/model/entity/Setting.java new file mode 100644 index 0000000..2471aba --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/entity/Setting.java @@ -0,0 +1,63 @@ +package com.mikufufu.modules.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serial; +import java.io.Serializable; + +import com.mikufufu.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + *

+ * 系统设置表 + *

+ * + * + * @since 2024-12-16 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@TableName("m_setting") +public class Setting extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 配置编号 + */ + @TableField("code") + private String code; + + /** + * 配置名称 + */ + @TableField("config_name") + private String configName; + + /** + * 配置描述 + */ + @TableField("description") + private String description; + + /** + * 配置的值 + */ + @TableField("value") + private String value; + + /** + * 是否启用(0.启用,1.停用) + */ + @TableField("status") + private Integer status; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/entity/User.java b/src/main/java/com/mikufufu/modules/system/model/entity/User.java new file mode 100644 index 0000000..49023e1 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/entity/User.java @@ -0,0 +1,102 @@ +package com.mikufufu.modules.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serial; +import java.io.Serializable; + +import com.mikufufu.common.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + *

+ * 用户表 + *

+ * + * + * @since 2024-12-06 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@TableName("m_user") +@Schema(description = "用户表") +public class User extends BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户id + */ + @Schema(description = "用户id") + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 用户名 + */ + @Schema(description = "用户名") + @TableField("username") + private String username; + + /** + * 密码 + */ + @Schema(description = "密码") + @TableField("password") + private String password; + + /** + * 用户昵称 + */ + @Schema(description = "用户昵称") + @TableField("nickname") + private String nickname; + + /** + * 用户头像 + */ + @Schema(description = "用户头像") + @TableField("avatar") + private String avatar; + + /** + * 角色 + */ + @Schema(description = "角色") + @TableField("role") + private Integer role; + + /** + * 邮箱 + */ + @Schema(description = "邮箱") + @TableField("email") + private String email; + + /** + * 电话号码 + */ + @Schema(description = "电话号码") + @TableField("phone_number") + private String phoneNumber; + + /** + * 性别(0.保密,1.男,2女) + */ + @Schema(description = "性别(0.保密,1.男,2女)") + @TableField("gender") + private Integer gender; + + /** + * 用户状态(0.正常,1.封号) + */ + @Schema(description = "用户状态(0.正常,1.封号)") + @TableField("status") + private Integer status; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/excel/UserExcel.java b/src/main/java/com/mikufufu/modules/system/model/excel/UserExcel.java new file mode 100644 index 0000000..5b056c1 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/excel/UserExcel.java @@ -0,0 +1,79 @@ +package com.mikufufu.modules.system.model.excel; + + +import com.alibaba.excel.annotation.ExcelIgnore; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.alibaba.excel.annotation.write.style.ContentFontStyle; +import com.alibaba.excel.annotation.write.style.HeadFontStyle; +import lombok.Data; + +@Data +// 头背景设置成红色 IndexedColors.RED.getIndex() +@HeadFontStyle(fontName = "宋体", fontHeightInPoints = 14,color = 10) +public class UserExcel { + + /** + * 用户id + */ + @ExcelProperty(value = "用户id") + private Integer id; + + /** + * 用户名 + */ + @ExcelProperty(value = "用户名") + private String username; + + /** + * 密码 + */ + @ExcelIgnore + private String password; + + /** + * 用户昵称 + */ + @ExcelProperty(value = "用户昵称") + private String nickname; + + /** + * 用户头像 + */ + @ExcelIgnore + private String avatar; + + /** + * 角色 + */ + @ExcelProperty(value = "角色") + private String role; + + /** + * 邮箱 + */ + @ColumnWidth(20) + @ExcelProperty(value = "邮箱") + private String email; + + /** + * 电话号码 + */ + @ColumnWidth(20) + @ExcelProperty(value = "电话号码") + private String phoneNumber; + + /** + * 性别(0.保密,1.男,2女) + */ + @ExcelProperty(value = "性别") + private String gender; + + /** + * 用户状态(0.正常,1.封号) + */ + @ExcelProperty(value = "用户状态") + // @ContentFontStyle(color = IndexedColors.RED.getIndex()) + @ContentFontStyle(color = 17) + private String status; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/vo/EmailConfig.java b/src/main/java/com/mikufufu/modules/system/model/vo/EmailConfig.java new file mode 100644 index 0000000..389ffca --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/vo/EmailConfig.java @@ -0,0 +1,37 @@ +package com.mikufufu.modules.system.model.vo; + +import lombok.Data; + +@Data +public class EmailConfig { + + /** + * 邮箱服务器 + */ + private String host; + + /** + * 端口 + */ + private Integer port; + + /** + * 邮箱账号 + */ + private String username; + + /** + * 邮箱密码 + */ + private String password; + + /** + * 是否启用ssl 1 启用 0 不启用 + */ + private Integer sslEnable; + + /** + * 默认发件人 + */ + private String defaultSender; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/vo/PermissionRoleVO.java b/src/main/java/com/mikufufu/modules/system/model/vo/PermissionRoleVO.java new file mode 100644 index 0000000..cc71a04 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/vo/PermissionRoleVO.java @@ -0,0 +1,38 @@ +package com.mikufufu.modules.system.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 角色权限视图对象 + * + */ +@Data +public class PermissionRoleVO { + + /** + * 请求的方法 + */ + @Schema(description = "请求的方法") + private String method; + + /** + * 接口名称 + */ + @Schema(description = "接口名称") + private String name; + + /** + * 接口地址 + */ + @Schema(description = "接口地址") + private String path; + + /** + * 角色列表 + */ + @Schema(description = "角色列表") + private List roleList; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/vo/RoleVO.java b/src/main/java/com/mikufufu/modules/system/model/vo/RoleVO.java new file mode 100644 index 0000000..6f407c2 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/vo/RoleVO.java @@ -0,0 +1,20 @@ +package com.mikufufu.modules.system.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class RoleVO { + + /** + * 角色名称 + */ + @Schema(description = "角色名称") + private String roleName; + + /** + * 角色编号 + */ + @Schema(description = "角色编号") + private String roleCode; +} diff --git a/src/main/java/com/mikufufu/modules/system/model/vo/UserVO.java b/src/main/java/com/mikufufu/modules/system/model/vo/UserVO.java new file mode 100644 index 0000000..f012580 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/vo/UserVO.java @@ -0,0 +1,80 @@ +package com.mikufufu.modules.system.model.vo; + +import com.mikufufu.common.enums.SexType; +import com.mikufufu.modules.system.model.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "用户信息") +@Data +@NoArgsConstructor +public class UserVO { + + /** + * 用户id + */ + @Schema(description = "用户id") + private Integer id; + + /** + * 用户名 + */ + @Schema(description = "用户名") + private String username; + + /** + * 用户昵称 + */ + @Schema(description = "用户昵称") + private String nickname; + + /** + * 用户头像 + */ + @Schema(description = "用户头像") + private String avatar; + + /** + * 角色 + */ + @Schema(description = "角色") + private String role; + + /** + * 邮箱 + */ + @Schema(description = "邮箱") + private String email; + + /** + * 电话号码 + */ + @Schema(description = "电话号码") + private String phone; + + /** + * 性别 + */ + @Schema(description = "性别") + private String gender; + + private UserVO(User user) { + this.id = user.getId(); + this.username = user.getUsername(); + this.nickname = user.getNickname(); + this.email = user.getEmail(); + this.phone = user.getPhoneNumber(); + this.avatar = user.getAvatar(); + this.gender = SexType.getNameByType(user.getGender()); + } + + /** + * 转换 + * @param user {@link User} + * @return {@link UserVO} + */ + public static UserVO convert(User user) { + return new UserVO(user); + } +} diff --git a/src/main/java/com/mikufufu/modules/system/model/vo/WebSiteVO.java b/src/main/java/com/mikufufu/modules/system/model/vo/WebSiteVO.java new file mode 100644 index 0000000..42ad548 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/model/vo/WebSiteVO.java @@ -0,0 +1,50 @@ +package com.mikufufu.modules.system.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; + +/** + * 网站信息 + * + */ +@Data +public class WebSiteVO{ + + @Schema(description = "网站标题") + private String title; + + @Schema(description = "网站描述") + private String description; + + @Schema(description = "版权信息") + private String copyright; + + @Schema(description = "备案信息") + private String icp; + + @Schema(description = "数据库") + @Size(max = 255) + private String database; + + @Schema(description = "服务器服务商") + @Size(max = 255) + private String server; + + @Schema(description = "网站介绍") + private String websiteInfo; + + @Schema(description = "网站部署完成时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime deployTime; + + @Schema(description = "网站部署的操作系统") + private String system; +} diff --git a/src/main/java/com/mikufufu/modules/system/service/EmailService.java b/src/main/java/com/mikufufu/modules/system/service/EmailService.java new file mode 100644 index 0000000..2127e4f --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/EmailService.java @@ -0,0 +1,28 @@ +package com.mikufufu.modules.system.service; + +import com.mikufufu.common.enums.HtmlTemplate; + +import java.util.Map; + +public interface EmailService { + + void sendSimpleMail(String to, String subject, String text); + + /** + * 发送html邮件 + * @param sender 发送人 + * @param to 接收人 + * @param subject 主题 + * @param content 内容 html格式 + */ + void sendHtmlEmail(String sender,String to, String subject, String content); + + /** + * 发送模板邮件 + * @param sender 发送人 + * @param to 接收人 + * @param template 模板名称 + * @param values 模板参数 + */ + void sendTemplateEmail(String sender, String to, HtmlTemplate template, Map values); +} diff --git a/src/main/java/com/mikufufu/modules/system/service/IMenuService.java b/src/main/java/com/mikufufu/modules/system/service/IMenuService.java new file mode 100644 index 0000000..83d71a5 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/IMenuService.java @@ -0,0 +1,16 @@ +package com.mikufufu.modules.system.service; + +import com.mikufufu.modules.system.model.entity.Menu; +import com.baomidou.mybatisplus.extension.service.IService; + +/** + *

+ * 菜单信息表 服务类 + *

+ * + * + * @since 2024-12-16 + */ +public interface IMenuService extends IService { + +} diff --git a/src/main/java/com/mikufufu/modules/system/service/IOperateLogService.java b/src/main/java/com/mikufufu/modules/system/service/IOperateLogService.java new file mode 100644 index 0000000..040f965 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/IOperateLogService.java @@ -0,0 +1,11 @@ +package com.mikufufu.modules.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.mikufufu.modules.system.model.dto.LogDto; +import com.mikufufu.modules.system.model.entity.OperateLog; +import com.baomidou.mybatisplus.extension.service.IService; + +public interface IOperateLogService extends IService{ + + IPage getPage(LogDto logDto); +} diff --git a/src/main/java/com/mikufufu/modules/system/service/IPermissionService.java b/src/main/java/com/mikufufu/modules/system/service/IPermissionService.java new file mode 100644 index 0000000..a317559 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/IPermissionService.java @@ -0,0 +1,58 @@ +package com.mikufufu.modules.system.service; + +import com.mikufufu.modules.system.model.entity.Permission; +import com.baomidou.mybatisplus.extension.service.IService; +import com.mikufufu.modules.system.model.vo.PermissionRoleVO; + +import java.util.List; + +/** + *

+ * 权限表 服务类 + *

+ * + * + * @since 2024-12-16 + */ +public interface IPermissionService extends IService { + + /** + * 根据角色ID获取权限集合。 + * + * @param roleId 角色的唯一标识符。 + * @return 返回一个权限集合,集合中的每个元素都是Permission类型。 + */ + List getPermissionList(Integer roleId); + + /** + * 获取角色权限列表。 + * + * @return 返回一个包含角色权限信息的列表,每个元素都是RolePermissionVO类型。 + */ + List getPermissionRoleList(); + + /** + * 为指定角色添加权限。 + * + * @param roleId 角色的唯一标识符。 + * @param permissionId 权限的唯一标识符。 + * @return 添加成功返回true,失败返回false。 + */ + Boolean addRolePermission(Integer roleId, Integer permissionId); + + /** + * 添加所有控制器权限。 + * + * @return 添加成功返回true,失败返回false。 + */ + Boolean saveAllPermissionOfController(); + + /** + * 清除在本地缓存中的权限列表 + * + * @return 清除成功返回true,失败返回false。 + */ + Boolean cleanPermissionRole(); + + Boolean grantAnonymousPermission(); +} diff --git a/src/main/java/com/mikufufu/modules/system/service/IRoleMenuService.java b/src/main/java/com/mikufufu/modules/system/service/IRoleMenuService.java new file mode 100644 index 0000000..93b5f36 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/IRoleMenuService.java @@ -0,0 +1,16 @@ +package com.mikufufu.modules.system.service; + +import com.mikufufu.modules.system.model.entity.RoleMenu; +import com.baomidou.mybatisplus.extension.service.IService; + +/** + *

+ * 角色导航关系表 服务类 + *

+ * + * + * @since 2024-12-16 + */ +public interface IRoleMenuService extends IService { + +} diff --git a/src/main/java/com/mikufufu/modules/system/service/IRolePermissionService.java b/src/main/java/com/mikufufu/modules/system/service/IRolePermissionService.java new file mode 100644 index 0000000..df29867 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/IRolePermissionService.java @@ -0,0 +1,16 @@ +package com.mikufufu.modules.system.service; + +import com.mikufufu.modules.system.model.entity.RolePermission; +import com.baomidou.mybatisplus.extension.service.IService; + +/** + *

+ * 角色权限关系表 服务类 + *

+ * + * + * @since 2024-12-16 + */ +public interface IRolePermissionService extends IService { + +} diff --git a/src/main/java/com/mikufufu/modules/system/service/IRoleService.java b/src/main/java/com/mikufufu/modules/system/service/IRoleService.java new file mode 100644 index 0000000..8cec686 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/IRoleService.java @@ -0,0 +1,22 @@ +package com.mikufufu.modules.system.service; + +import com.mikufufu.modules.system.model.entity.Role; +import com.baomidou.mybatisplus.extension.service.IService; + +/** + *

+ * 角色表 服务类 + *

+ * + * + * @since 2024-12-06 + */ +public interface IRoleService extends IService { + + /** + * 根据角色id获取角色code + * @param roleId 角色id + * @return 角色 + */ + String getCodeById(Integer roleId); +} diff --git a/src/main/java/com/mikufufu/modules/system/service/ISettingService.java b/src/main/java/com/mikufufu/modules/system/service/ISettingService.java new file mode 100644 index 0000000..9dd64be --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/ISettingService.java @@ -0,0 +1,44 @@ +package com.mikufufu.modules.system.service; + +import com.mikufufu.modules.system.model.entity.Setting; +import com.baomidou.mybatisplus.extension.service.IService; +import com.mikufufu.modules.system.model.vo.EmailConfig; +import com.mikufufu.modules.system.model.vo.WebSiteVO; + +import java.util.Map; + +/** + *

+ * 系统设置表 服务类 + *

+ * + * + * @since 2024-12-16 + */ +public interface ISettingService extends IService { + + /** + * 获取存储方式 + * @return 存储方式 + */ + String getStorageCode(); + + /** + * 修改邮件设置 + * + * @param emailConfig 新的邮件设置 + * @return 返回操作结果,成功为true,失败为false + */ + Boolean setMail(EmailConfig emailConfig); + + /** + * 获取邮件设置 + * + * @return 返回邮件设置 + */ + EmailConfig getMail(); + + Boolean saveWebInfo(WebSiteVO webSiteVO); + + boolean saveOrUpdateSetting(Map cachedSettings); +} diff --git a/src/main/java/com/mikufufu/modules/system/service/IUserService.java b/src/main/java/com/mikufufu/modules/system/service/IUserService.java new file mode 100644 index 0000000..384a121 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/IUserService.java @@ -0,0 +1,38 @@ +package com.mikufufu.modules.system.service; + +import com.mikufufu.common.enums.Channel; +import com.mikufufu.modules.auth.model.dto.ForgetPasswordParam; +import com.mikufufu.modules.auth.model.dto.LoginEmailParam; +import com.mikufufu.modules.auth.model.dto.LoginParam; +import com.mikufufu.modules.auth.model.dto.RegisterParam; +import com.mikufufu.modules.system.model.entity.User; +import com.baomidou.mybatisplus.extension.service.IService; + +import jakarta.servlet.http.HttpServletResponse; + +/** + *

+ * 用户表 服务类 + *

+ * + * + * @since 2024-12-06 + */ +public interface IUserService extends IService { + + String register(RegisterParam registerParam); + + String login(LoginParam loginParam, Channel channel); + + /** + * 用户退出 + * @return 是否退出成功 + */ + Boolean logout(); + + String loginEmail(LoginEmailParam loginEmailParam, Channel channel); + + boolean forgetPassword(ForgetPasswordParam forgetPasswordParam); + + void export(HttpServletResponse response); +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/EmailServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/EmailServiceImpl.java new file mode 100644 index 0000000..422fcc2 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/EmailServiceImpl.java @@ -0,0 +1,136 @@ +package com.mikufufu.modules.system.service.impl; + +import com.mikufufu.common.enums.HtmlTemplate; +import com.mikufufu.modules.system.model.vo.EmailConfig; +import com.mikufufu.modules.system.service.EmailService; +import com.mikufufu.modules.system.service.ISettingService; +import freemarker.template.Configuration; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.io.StringWriter; +import java.io.UnsupportedEncodingException; +import java.util.Map; +import java.util.Properties; + +@Slf4j +@Service +public class EmailServiceImpl implements EmailService { + + @Autowired + private ISettingService settingsService; + + private JavaMailSender mailSender() { + + EmailConfig emailConfig = settingsService.getMail(); + + if (emailConfig == null) { + throw new RuntimeException("请先设置邮件配置"); + } + + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(emailConfig.getHost()); + mailSender.setPort(emailConfig.getPort()); + mailSender.setUsername(emailConfig.getUsername()); + mailSender.setPassword(emailConfig.getPassword()); + mailSender.setDefaultEncoding("UTF-8"); + // ▼ 缺失关键SSL配置 ▼ + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", true); + boolean sslEnable = emailConfig.getSslEnable() == 1; + props.put("mail.smtp.ssl.enable", sslEnable); + props.put("mail.smtp.connectiontimeout", 5000); + props.put("mail.smtp.timeout", 5000); + return mailSender; + } + + @Override + public void sendSimpleMail(String to, String subject, String text) { + + EmailConfig emailConfig = settingsService.getMail(); + + if (emailConfig == null) { + throw new RuntimeException("请先设置邮件配置"); + } + + JavaMailSender mailSender = mailSender(); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(emailConfig.getDefaultSender()); + message.setTo(to); + message.setSubject(subject); + message.setText(text); + mailSender.send(message); + } + + @Override + public void sendHtmlEmail(String sender,String to, String subject, String content) { + + JavaMailSender mailSender = mailSender(); + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message); + + EmailConfig emailConfig = settingsService.getMail(); + + if (emailConfig == null) { + throw new RuntimeException("请先设置邮件配置"); + } + + try { + // 设置发件人与收件人 + // setFrom()方法可以设置发件人的别名第一个参数是发件人邮箱,第二个参数是发件人别名 + helper.setFrom(emailConfig.getDefaultSender(),sender); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, true); // 支持HTML + mailSender.send(message); + } catch (MessagingException e) { + throw new RuntimeException("邮件发送失败", e); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("该编码不支持",e); + } + } + + @Override + public void sendTemplateEmail(String sender, String to, HtmlTemplate template, Map values) { + // 使用Freemarker处理模板 + String html = processTemplateWithFreemarker(template.getFileName(), values); + // 发送邮件 + sendHtmlEmail(sender, to, template.getSubject(), html); + } + + /** + * 使用Freemarker处理模板 + * + * @param template 模板名称 + * @param values 模板变量值 + * @return 处理后的HTML内容 + */ + private String processTemplateWithFreemarker(String template, Map values) { + try { + // 创建Configuration对象 + Configuration cfg = new Configuration(Configuration.VERSION_2_3_34); + cfg.setDefaultEncoding("UTF-8"); + // 设置模板路径 + ClassPathResource resource = new ClassPathResource("template/"); + cfg.setDirectoryForTemplateLoading(resource.getFile()); + + // 处理模板 + StringWriter writer = new StringWriter(); + cfg.getTemplate(template).process(values, writer); + return writer.toString(); + } catch (Exception e) { + log.error("使用Freemarker处理模板失败", e); + throw new RuntimeException("模板处理失败", e); + } + } +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/MenuServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/MenuServiceImpl.java new file mode 100644 index 0000000..20b6bc4 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/MenuServiceImpl.java @@ -0,0 +1,20 @@ +package com.mikufufu.modules.system.service.impl; + +import com.mikufufu.modules.system.model.entity.Menu; +import com.mikufufu.mapper.MenuMapper; +import com.mikufufu.modules.system.service.IMenuService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; + +/** + *

+ * 菜单信息表 服务实现类 + *

+ * + * + * @since 2024-12-16 + */ +@Service +public class MenuServiceImpl extends ServiceImpl implements IMenuService { + +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/OperateLogServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/OperateLogServiceImpl.java new file mode 100644 index 0000000..e16768b --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/OperateLogServiceImpl.java @@ -0,0 +1,18 @@ +package com.mikufufu.modules.system.service.impl; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.mikufufu.modules.system.model.dto.LogDto; +import org.springframework.stereotype.Service; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.mikufufu.mapper.OperateLogMapper; +import com.mikufufu.modules.system.model.entity.OperateLog; +import com.mikufufu.modules.system.service.IOperateLogService; + +@Service +public class OperateLogServiceImpl extends ServiceImpl implements IOperateLogService{ + + @Override + public IPage getPage(LogDto logDto) { + return baseMapper.getPage(logDto,logDto.getPage()); + } +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/PermissionServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/PermissionServiceImpl.java new file mode 100644 index 0000000..c6964af --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/PermissionServiceImpl.java @@ -0,0 +1,217 @@ +package com.mikufufu.modules.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mikufufu.common.enums.RoleCode; +import com.mikufufu.common.permission.PermissionHandle; +import com.mikufufu.modules.auth.security.handler.AuthorizationManagerImpl; +import com.mikufufu.core.utils.SpringUtils; +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.system.model.entity.Permission; +import com.mikufufu.mapper.PermissionMapper; +import com.mikufufu.modules.system.model.entity.Role; +import com.mikufufu.modules.system.model.entity.RolePermission; +import com.mikufufu.modules.system.model.vo.PermissionRoleVO; +import com.mikufufu.modules.system.service.IPermissionService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.mikufufu.modules.system.service.IRolePermissionService; +import com.mikufufu.modules.system.service.IRoleService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition; +import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.lang.reflect.Method; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + *

+ * 权限表 服务实现类 + *

+ * + * + * @since 2024-12-16 + */ +@Slf4j +@Service +public class PermissionServiceImpl extends ServiceImpl implements IPermissionService { + + @Autowired + private IRolePermissionService rolePermissionService; + + @Autowired + private IRoleService roleService; + + // 定义目标包名 + @Value("${client-api.target-package}") + private String targetPackage; + + @Autowired + private RequestMappingHandlerMapping requestMappingHandlerMapping; + + @Override + public List getPermissionList(Integer roleId) { + if (StringUtils.isEmpty(roleId)) { + throw new RuntimeException("roleId不能为空"); + } + return baseMapper.getPermission(roleId).stream().distinct().toList(); + } + + + @Override + public List getPermissionRoleList() { + return baseMapper.getPermissionRoleList(); + } + + @Override + public Boolean addRolePermission(Integer roleId, Integer permissionId) { + Permission permission = this.getById(permissionId); + if (StringUtils.isEmpty(permission)){ + throw new RuntimeException("权限不存在"); + } + RolePermission rolePermission = new RolePermission(); + rolePermission.setRoleId(roleId); + rolePermission.setPermissionId(permissionId); + if(rolePermissionService.save(rolePermission)){ + // 清空权限系统缓存 + cleanPermissionRole(); + return true; + } + return false; + } + + /** + * 为所有控制器方法添加权限信息。 + * 此方法会遍历所有控制器方法,根据方法上的注解(如@ApiOperation和@PreAuthorize)来构建Permission对象, + * 然后将这些Permission对象批量保存到数据库中。如果方法上有@ApiOperation注解,则会使用其value值作为权限名称; + * 如果方法上有@PreAuthorize注解,则会截取其value值中特定的字符串作为权限标识。 + * + * @return 返回批量保存权限信息是否成功的布尔值。 + */ + @Transactional(rollbackFor = Exception.class) + @Override + public Boolean saveAllPermissionOfController() { + List permissionList = new ArrayList<>(); + List existingPermissions = this.list(); + // 创建权限标识映射 + Map existingPermissionMap = existingPermissions.stream() + .collect(Collectors.toMap( + p -> p.getPath() + ":" + p.getMethod().toUpperCase(), + Function.identity() + )); + Set permissionPaths = new HashSet<>(); + // 获取所有映射的处理方法 + Map handlerMethods = requestMappingHandlerMapping.getHandlerMethods(); + // 遍历处理方法,解析URL和请求方法,并检查是否有@Anonymous注解 + handlerMethods.forEach((k, v) -> { + Permission permission = new Permission(); + // 解析URL + PathPatternsRequestCondition pathPatternsCondition = k.getPathPatternsCondition(); + if (pathPatternsCondition == null){ + return; + } + pathPatternsCondition.getPatterns().forEach(path -> permission.setPath(path.getPatternString())); + RequestMethodsRequestCondition methodsCondition = k.getMethodsCondition(); + if (methodsCondition.getMethods().isEmpty()){ + return; + } + // 解析请求方法 + methodsCondition.getMethods().forEach(method -> permission.setMethod(method.name())); + if (StringUtils.isEmpty(permission.getPath()) || StringUtils.isEmpty(permission.getMethod())){ + return; + } + Method method = v.getMethod(); + if (method.isAnnotationPresent(PreAuthorize.class)){ + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + permission.setSign(preAuthorize.value().replace("@ss.hasPermission('", "").replace("')", "")); + } + permission.setStatus(0); + if (permission.getMethod() != null){ + // 设置复合键 + String compositeKey = permission.getPath() + ":" + permission.getMethod().toUpperCase(); + if (!existingPermissionMap.containsKey(compositeKey)){ + permissionList.add(permission); + } + permissionPaths.add(compositeKey); + } + }); + log.info("permissionList = {}", permissionList); + List removePermissions = existingPermissions.stream() + .filter(p -> !permissionPaths.contains(p.getPath() + ":" + p.getMethod().toUpperCase())) + .map(Permission::getId) + .toList(); + log.info("removePermissions = {}", removePermissions); + boolean flag = true; + if (!removePermissions.isEmpty()){ + flag = removeByIds(removePermissions); + } + if (!permissionList.isEmpty()){ + flag = flag && saveBatch(permissionList); + } + return flag; + } + + @Override + public Boolean cleanPermissionRole() { + try { + PermissionHandle.clearDataSource(); + return true; + }catch (Exception e){ + log.error("清空权限系统缓存失败", e); + return false; + } + } + + // 实现获取controller下的web下的接口 + @Override + public Boolean grantAnonymousPermission() { + // 获取所有映射的处理方法 + Map handlerMethods = requestMappingHandlerMapping.getHandlerMethods(); + List webPermissionPaths = new ArrayList<>(); + Map permissionMap = list().stream().collect(Collectors.toMap(Permission::getPath, p -> p)); + + // 遍历处理方法,提取 web 接口 + handlerMethods.forEach((requestMappingInfo, handlerMethod) -> { + // 检查处理方法是否属于目标包 + String packageName = handlerMethod.getBeanType().getPackage().getName(); + if (packageName.startsWith(targetPackage)) { +// log.info("packageName = {}", packageName); + webPermissionPaths.addAll(requestMappingInfo.getPatternValues()); + } + }); + + log.info("webPermissionPaths = {}", webPermissionPaths); + + try { + Role role = roleService.getOne(new LambdaQueryWrapper().eq(Role::getRoleCode, RoleCode.ROLE_VISITOR.getCode())); + Integer roleId = role.getId(); + + // 这里可以将 webPermissions 保存到数据库或进行其他处理 + List rolePermissions = webPermissionPaths.stream().filter(path -> permissionMap.containsKey(path) && permissionMap.get(path) != null) + .map(path -> { + RolePermission rolePermission = new RolePermission(); + rolePermission.setRoleId(roleId); + rolePermission.setPermissionId(permissionMap.get(path).getId()); + return rolePermission; + }).collect(Collectors.toList()); + // 删除该角色下的所有权限 + rolePermissionService.remove(new LambdaQueryWrapper().eq(RolePermission::getRoleId, roleId)); + rolePermissionService.saveBatch(rolePermissions); + cleanPermissionRole(); + } catch (Exception e) { + log.error("保存web权限失败", e); + return false; + } + + return true; // 返回操作成功的布尔值 + } + +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/RoleMenuServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/RoleMenuServiceImpl.java new file mode 100644 index 0000000..0a1364a --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/RoleMenuServiceImpl.java @@ -0,0 +1,20 @@ +package com.mikufufu.modules.system.service.impl; + +import com.mikufufu.modules.system.model.entity.RoleMenu; +import com.mikufufu.mapper.RoleMenuMapper; +import com.mikufufu.modules.system.service.IRoleMenuService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; + +/** + *

+ * 角色导航关系表 服务实现类 + *

+ * + * + * @since 2024-12-16 + */ +@Service +public class RoleMenuServiceImpl extends ServiceImpl implements IRoleMenuService { + +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/RolePermissionServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/RolePermissionServiceImpl.java new file mode 100644 index 0000000..7f76166 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/RolePermissionServiceImpl.java @@ -0,0 +1,20 @@ +package com.mikufufu.modules.system.service.impl; + +import com.mikufufu.modules.system.model.entity.RolePermission; +import com.mikufufu.mapper.RolePermissionMapper; +import com.mikufufu.modules.system.service.IRolePermissionService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; + +/** + *

+ * 角色权限关系表 服务实现类 + *

+ * + * + * @since 2024-12-16 + */ +@Service +public class RolePermissionServiceImpl extends ServiceImpl implements IRolePermissionService { + +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/RoleServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..836d7a0 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/RoleServiceImpl.java @@ -0,0 +1,33 @@ +package com.mikufufu.modules.system.service.impl; + +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.system.model.entity.Role; +import com.mikufufu.mapper.RoleMapper; +import com.mikufufu.modules.system.service.IRoleService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; + +/** + *

+ * 角色表 服务实现类 + *

+ * + * + * @since 2024-12-06 + */ +@Service +public class RoleServiceImpl extends ServiceImpl implements IRoleService { + + @Override + public String getCodeById(Integer roleId) { + if (StringUtils.isEmpty(roleId)) { + throw new RuntimeException("角色ID不能为空"); + } + Role role = this.getById(roleId); + if (StringUtils.isEmpty(role)) { + throw new RuntimeException("角色不存在"); + } + return role.getRoleCode(); + } + +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/SettingServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/SettingServiceImpl.java new file mode 100644 index 0000000..9d14447 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/SettingServiceImpl.java @@ -0,0 +1,122 @@ +package com.mikufufu.modules.system.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.mikufufu.common.constant.Constant; +import com.mikufufu.core.utils.AesUtils; +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.system.model.entity.Setting; +import com.mikufufu.mapper.SettingMapper; +import com.mikufufu.modules.system.model.vo.EmailConfig; +import com.mikufufu.modules.system.model.vo.WebSiteVO; +import com.mikufufu.modules.system.service.ISettingService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + *

+ * 系统设置表 服务实现类 + *

+ * + * + * @since 2024-12-16 + */ +@Service +public class SettingServiceImpl extends ServiceImpl implements ISettingService { + + /** + * 获取存储编码。首先尝试从缓存中获取存储编码,如果缓存中不存在,则从数据库中查询,并将查询结果存入缓存。 + * + * @return 返回存储编码,如果不存在则返回null。 + */ + @Override + public String getStorageCode() { + + // 从数据库中查询存储编码 + Setting storageSetting = getOne(new QueryWrapper().lambda() + .eq(Setting::getCode, Constant.STORAGE_SETTING_CODE) + .eq(Setting::getStatus, 0) + ); + if (StringUtils.isNotEmpty(storageSetting)) { + return storageSetting.getValue(); + } + return null; + } + + @Override + public Boolean setMail(EmailConfig emailConfig) { + try { + emailConfig.setPassword(AesUtils.encrypt(emailConfig.getPassword())); + } catch (Exception e) { + log.error("邮件密码加密失败", e); + throw new RuntimeException(e); + } + Setting sysSetting = getOne(new QueryWrapper().lambda() + .eq(Setting::getCode, Constant.SYS_EMAIL_SETTING_CODE) + .eq(Setting::getStatus, 0) + ); + if (StringUtils.isNotEmpty(sysSetting)) { + sysSetting.setValue(JSON.toJSONString(emailConfig)); + return updateById(sysSetting); + } + sysSetting = new Setting(); + sysSetting.setCode(Constant.SYS_EMAIL_SETTING_CODE); + sysSetting.setValue(JSON.toJSONString(emailConfig)); + sysSetting.setConfigName("系统邮件设置"); + sysSetting.setDescription("系统邮件设置"); + sysSetting.setStatus(0); + save(sysSetting); + return true; + } + + @Override + public EmailConfig getMail() { + Setting sysSetting = getOne(new QueryWrapper().lambda() + .eq(Setting::getCode, Constant.SYS_EMAIL_SETTING_CODE) + .eq(Setting::getStatus, 0) + ); + if (StringUtils.isNotEmpty(sysSetting)) { + EmailConfig emailConfig = JSON.parseObject(sysSetting.getValue(), EmailConfig.class); + try { + emailConfig.setPassword(AesUtils.decrypt(emailConfig.getPassword())); + } catch (Exception e) { + log.error("邮件密码解密失败", e); + throw new RuntimeException(e); + } + return emailConfig; + } + return null; + } + + @Override + public Boolean saveWebInfo(WebSiteVO webSiteVO) { + Setting setting = this.getOne(new LambdaQueryWrapper().eq(Setting::getCode, Constant.WEB_SITE_CODE)); + if (StringUtils.isEmpty(setting)) { + setting = new Setting(); + } + setting.setCode(Constant.WEB_SITE_CODE); + setting.setConfigName("网站信息"); + setting.setDescription("网站信息"); + setting.setStatus(0); + setting.setValue(JSON.toJSONString(webSiteVO)); + return this.saveOrUpdate(setting); + } + + @Override + public boolean saveOrUpdateSetting(Map cachedSettings) { + for (Map.Entry entry : cachedSettings.entrySet()) { + Setting setting = this.getOne(new LambdaQueryWrapper().eq(Setting::getCode, entry.getKey())); + if (StringUtils.isEmpty(setting)) { + setting = new Setting(); + } + setting.setCode(entry.getKey()); + setting.setValue(entry.getValue()); + setting.setStatus(0); + this.saveOrUpdate(setting); + } + return false; + } +} diff --git a/src/main/java/com/mikufufu/modules/system/service/impl/UserServiceImpl.java b/src/main/java/com/mikufufu/modules/system/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..aaadf14 --- /dev/null +++ b/src/main/java/com/mikufufu/modules/system/service/impl/UserServiceImpl.java @@ -0,0 +1,220 @@ +package com.mikufufu.modules.system.service.impl; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mikufufu.common.constant.RedisKey; +import com.mikufufu.common.entity.AjaxResult; +import com.mikufufu.common.enums.Channel; +import com.mikufufu.common.enums.SexType; +import com.mikufufu.core.cache.RedisCache; +import com.mikufufu.core.utils.BeanUtil; +import com.mikufufu.modules.auth.model.dto.ForgetPasswordParam; +import com.mikufufu.modules.auth.model.dto.LoginEmailParam; +import com.mikufufu.modules.auth.security.TokenStore; +import com.mikufufu.common.enums.RoleCode; +import com.mikufufu.common.enums.UserStatus; +import com.mikufufu.common.exception.AuthException; +import com.mikufufu.modules.auth.utils.AuthUtils; +import com.mikufufu.core.utils.EncryptionUtils; +import com.mikufufu.core.utils.RegexUtils; +import com.mikufufu.core.utils.StringUtils; +import com.mikufufu.modules.auth.model.dto.LoginParam; +import com.mikufufu.modules.auth.model.dto.RegisterParam; +import com.mikufufu.modules.system.model.entity.Role; +import com.mikufufu.modules.system.model.entity.User; +import com.mikufufu.mapper.UserMapper; +import com.mikufufu.modules.system.model.excel.UserExcel; +import com.mikufufu.modules.system.service.IRoleService; +import com.mikufufu.modules.system.service.IUserService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + *

+ * 用户表 服务实现类 + *

+ */ +@Slf4j +@Service +public class UserServiceImpl extends ServiceImpl implements IUserService { + + @Autowired + private IRoleService roleService; + + @Override + public String register(RegisterParam registerParam) { + User user = new User(); + user.setUsername(registerParam.getAccount()); + String password = EncryptionUtils.sha256(registerParam.getPassword()); + user.setPassword(password); + user.setEmail(registerParam.getEmail()); + user.setAvatar(registerParam.getAvatar()); + //默认为普通用户 + Role role = roleService.getOne(new LambdaQueryWrapper() + .select(Role::getId) + .eq(Role::getRoleCode, RoleCode.ROLE_USER.getCode())); + if(StringUtils.isEmpty(role)){ + user.setRole(role.getId()); + } + this.save(user); + return TokenStore.generateToken(user); + } + + @Override + public String login(LoginParam loginParam, Channel channel) { + String account = loginParam.getAccount(); + if (StringUtils.isBlank(account)) { + throw new AuthException("账号不能为空"); + } + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + boolean isEmail = RegexUtils.isEmail(account); + if (isEmail){ + queryWrapper.eq(User::getEmail,account); + }else { + queryWrapper.eq(User::getUsername, account); + } + String password = EncryptionUtils.sha256(loginParam.getPassword()); + queryWrapper.eq(User::getPassword,password); + User user = getOne(queryWrapper); + if (StringUtils.isEmpty(user)) { + throw new AuthException("用户名或密码错误"); + } + checkUser(user,channel); + return TokenStore.generateToken(user); + } + + private void checkUser(User user, Channel channel) { + if (UserStatus.DISABLED.getCode().equals(user.getStatus())) { + throw new AuthException("用户被封禁"); + } + // 根据用户角色获取角色代码 + String roleCode = roleService.getCodeById(user.getRole()); + if(StringUtils.isBlank(roleCode)){ + throw new AuthException("用户角色不存在"); + } + switch (channel){ + case ADMIN: + if(!RoleCode.getRoleCodeByCode(roleCode).equals(RoleCode.ROLE_ADMIN) && !RoleCode.getRoleCodeByCode(roleCode).equals(RoleCode.ROLE_SYSTEM_ADMIN)){ + throw new AuthException("该用户不是后台管理的用户!!"); + } + break; + case WEB: + if(!RoleCode.getRoleCodeByCode(roleCode).equals(RoleCode.ROLE_USER)){ + throw new AuthException("请勿使用管理员账号登录c端!!"); + } + break; + default: + throw new AuthException("未知渠道"); + } + } + + @Override + public Boolean logout() { + try { + TokenStore.deleteToken(AuthUtils.getToken()); + return true; + }catch (Exception e){ + return false; + } + } + + @Override + public String loginEmail(LoginEmailParam loginEmailParam, Channel channel) { + String code = loginEmailParam.getCode(); + String emailCode = RedisCache.get(RedisKey.EMAIL_CODE + loginEmailParam.getEmail(), String.class); + if(StringUtils.isBlank(emailCode) || !emailCode.equals(code)){ + throw new AuthException("验证码错误"); + } + User user = getOne(new LambdaQueryWrapper().eq(User::getEmail, loginEmailParam.getEmail())); + if (StringUtils.isEmpty(user)) { + throw new AuthException("用户不存在"); + } + checkUser(user,channel); + return TokenStore.generateToken(user); + } + + @Override + public boolean forgetPassword(ForgetPasswordParam forgetPasswordParam) { + + String email = forgetPasswordParam.getEmail(); + + String code = RedisCache.get(RedisKey.EMAIL_CODE + email, String.class); + + if(StringUtils.isBlank(code) || !code.equals(forgetPasswordParam.getCode())){ + throw new AuthException("验证码错误"); + } + + if(!forgetPasswordParam.getNewPassword().equals(forgetPasswordParam.getConfirmPassword())){ + throw new AuthException("两次密码不一致"); + } + + User user = getOne(new LambdaQueryWrapper().eq(User::getEmail, email).eq(User::getStatus,UserStatus.NORMAL )); + if(StringUtils.isEmpty(user)){ + throw new AuthException("用户不存在"); + } + + if(user.getPassword().equals(EncryptionUtils.sha256(forgetPasswordParam.getNewPassword()))){ + throw new AuthException("新密码不能与旧密码相同"); + } + + user.setPassword(EncryptionUtils.sha256(forgetPasswordParam.getNewPassword())); + return this.updateById(user); + + } + + @Override + public void export(HttpServletResponse response) { + // 这里注意 有同学反应使用swagger 会导致各种问题,请直接用浏览器或者用postman + try { + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + // 这里URLEncoder.encode可以防止中文乱码 当然和easyexcel没有关系 + String fileName = URLEncoder.encode("用户列表", "UTF-8").replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + + + List list = this.list().stream().map(user -> { + UserExcel userExcel = BeanUtil.copy(user, UserExcel.class); + if (Objects.nonNull(user.getGender())) { + userExcel.setGender(SexType.getNameByType(user.getGender())); + } + if (Objects.nonNull(user.getStatus())) { + userExcel.setStatus(UserStatus.getName(user.getStatus())); + } + if (Objects.nonNull(user.getRole())) { + RoleCode roleCodeEnum = RoleCode.getRoleCodeByValue(user.getRole()); + userExcel.setRole(roleCodeEnum != null ? roleCodeEnum.getDesc() : ""); + } + return userExcel; + }).collect(Collectors.toList()); + + // 这里需要设置不关闭流 + EasyExcel.write(response.getOutputStream(), UserExcel.class) + .autoCloseStream(Boolean.FALSE) + .sheet("用户信息") + .doWrite(list); + + } catch (Exception e) { + log.error("下载文件失败 {}", e.getMessage(),e); + // 重置response + response.reset(); + response.setContentType("application/json;charset=UTF-8"); + response.setCharacterEncoding("utf-8"); + try { + response.getWriter().println(JSON.toJSONString(AjaxResult.error("下载文件失败:"+e.getMessage()))); + }catch (IOException io){ + throw new RuntimeException(io); + } + } + } +} diff --git a/src/main/java/com/mikufufu/task/BaseTask.java b/src/main/java/com/mikufufu/task/BaseTask.java new file mode 100644 index 0000000..a918aa6 --- /dev/null +++ b/src/main/java/com/mikufufu/task/BaseTask.java @@ -0,0 +1,27 @@ +package com.mikufufu.task; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class BaseTask { + + protected boolean enable; + + protected void run() throws Exception{ + if (enable){ + log.info("开始执行任务:{},执行线程:{}", this.getClass().getSimpleName(),Thread.currentThread().getName()); + task(); + } + } + + /** + * 定义任务逻辑 + */ + public abstract void task() throws Exception; + + /** + * 设置任务是否启用,用于接收配置文件的参数 + * @param enable 启用 + */ + public abstract void setEnable(boolean enable); +} diff --git a/src/main/java/com/mikufufu/task/DemoTask.java b/src/main/java/com/mikufufu/task/DemoTask.java new file mode 100644 index 0000000..ccbf06a --- /dev/null +++ b/src/main/java/com/mikufufu/task/DemoTask.java @@ -0,0 +1,87 @@ +package com.mikufufu.task; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 执行定时任务 + * + */ +@Slf4j +@Component +public class DemoTask extends BaseTask{ + + private final static DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + +// /** +// * 固定速率执行 +// * 任务从上一次开始执行的时间点开始计算,间隔固定的时间后再次执行。 +// * fixedRate属性表示任务执行之间的时间间隔,单位为毫秒。 +// * 每5秒执行一次 +// */ +// @Scheduled(fixedRate = 5000) +// public void reportCurrentTime() { +// System.out.println("fixedRate-执行定时任务,当前时间: " + DATE_TIME_FORMATTER.format(LocalDateTime.now())); +// } +// +// /** +// * 固定延迟执行 +// * 每次任务执行完成之后,再间隔固定的时间再执行。 +// * fixedDelay属性表示任务执行之间的时间间隔,单位为毫秒。 +// * 每5秒执行一次 +// */ +// @Scheduled(fixedDelay = 5000) +// public void reportCurrentTimeWithFixedDelay() { +// System.out.println("fixedDelay-执行定时任务,当前时间: " + DATE_TIME_FORMATTER.format(LocalDateTime.now())); +// } +// +// /** +// * 初始延迟执行 +// * 第一次任务执行前,先等待固定的时间再执行。 +// * initialDelay属性表示任务第一次执行前的延迟时间,单位为毫秒。 +// * 每5秒执行一次 +// */ +// @Scheduled(initialDelay = 1000, fixedRate = 5000) +// public void reportCurrentTimeWithInitialDelay(){ +// System.out.println("initialDelay-fixedRate-执行定时任务,当前时间: " + DATE_TIME_FORMATTER.format(LocalDateTime.now())); +// } +// +// /** +// * Cron表达式 +// * Cron表达式用于定义定时任务的执行时间。 +// * Cron表达式由6个字段组成,分别表示:秒、分钟、小时、日、月、星期。 +// * 每一个字段都可以使用特定的值或者范围来定义,例如: +// * 秒字段可以使用0-59之间的整数,表示每一秒执行一次任务。 +// * 分钟字段可以使用0-59之间的整数,表示每一分钟执行一次任务。 +// * 小时字段可以使用0-23之间的整数,表示每一小时执行一次任务。 +// * 日字段可以使用1-31之间的整数,表示每一日执行一次任务 +// * 月字段可以使用1-12之间的整数,表示每一月执行一次任务。 +// * 星期字段可以使用1-7之间的整数,表示每一星期执行一次任务。 +// * Cron表达式的格式为:"秒 分 小时 日 月 星期",其中星期字段可以省略。 +// */ +// @Scheduled(cron = "0 0 12 * * ?") +// public void scheduledTaskWithCronExpression() { +// System.out.println("每天中午12点执行的任务"); +// } + + @Scheduled(cron = "${demo-task.cron}") + public void scheduledTask() throws Exception { + super.run(); + } + + @Override + public void task() throws Exception { + log.info("DemoTask-执行定时任务,当前时间: {}", DATE_TIME_FORMATTER.format(LocalDateTime.now())); + } + + @Override + @Value("${demo-task.enable}") + public void setEnable(boolean enable) { + super.enable = enable; + } +} diff --git a/src/main/java/com/mikufufu/utils/Demo.java b/src/main/java/com/mikufufu/utils/Demo.java new file mode 100644 index 0000000..00647d1 --- /dev/null +++ b/src/main/java/com/mikufufu/utils/Demo.java @@ -0,0 +1,33 @@ +package com.mikufufu.utils; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.List; + +public class Demo { + + public List getList(T t){ + return null; + } + + /** + * 获取泛型参数对应的Class对象 + * + * @param sort 泛型参数在类型参数列表中的索引位置 + * @return 返回指定位置的泛型参数对应的Class对象 + * @throws RuntimeException 当无法获取到泛型参数时抛出异常 + */ + private Class getTypeClass(int sort){ + // 获取当前类的父类泛型类型 + Type superClass = getClass().getGenericSuperclass(); + + // 判断父类是否为参数化类型,并提取对应的泛型参数 + if(superClass instanceof ParameterizedType parameterizedType){ + Type[] typeArguments = parameterizedType.getActualTypeArguments(); + return (Class) typeArguments[sort]; + }else { + throw new RuntimeException("未找到泛型参数"); + } + } + +} diff --git a/src/main/java/com/mikufufu/utils/MybatisGenerator.java b/src/main/java/com/mikufufu/utils/MybatisGenerator.java new file mode 100644 index 0000000..4fae17b --- /dev/null +++ b/src/main/java/com/mikufufu/utils/MybatisGenerator.java @@ -0,0 +1,71 @@ +package com.mikufufu.utils; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.generator.FastAutoGenerator; +import com.baomidou.mybatisplus.generator.config.OutputFile; +import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine; +import com.mikufufu.common.entity.BaseEntity; + +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.Scanner; + +public class MybatisGenerator { + + public static void generate(String ip,String database,String username,String password,String... tableNames) { + String url = "jdbc:mysql://"+ ip +":3306/"+ database +"?characterEncoding=utf8&serverTimezone=Asia/Shanghai"; + String projectPath = Paths.get(System.getProperty("user.dir")).toString(); + System.out.println("projectPath = " + projectPath); + // 使用 FastAutoGenerator 快速配置代码生成器 + FastAutoGenerator.create(url, username, password) + .globalConfig(builder -> { + builder.author("lw") // 设置作者 + .disableOpenDir() // 禁止打开输出目录 + .enableSpringdoc() // 启用 Springdoc + .outputDir(projectPath + "/src/main/java"); // 输出目录 + }) + .packageConfig(builder -> { + builder.parent("com.mikufufu") // 设置父包名 + .entity("model.entity") // 设置实体类包名 + .mapper("mapper") // 设置 Mapper 接口包名 + .service("service") // 设置 Service 接口包名 + .serviceImpl("service.impl") // 设置 Service 实现类包名 +// .xml("mapper"); // 设置 Mapper XML 文件包名 + .pathInfo(Collections.singletonMap(OutputFile.xml, projectPath + "/src/main/resources/mapper")); // 设置mapperXml生成路径 + }) + .strategyConfig(builder -> + builder.addInclude(tableNames) // 设置需要生成的表名 + .addTablePrefix("m_") + .entityBuilder() + .enableLombok() // 启用 Lombok + .enableTableFieldAnnotation() // 启用字段注解 + .superClass(BaseEntity.class) // 设置父类 + .idType(IdType.AUTO) // 设置主键类型 + .enableFileOverride() // 覆盖已生成文件 + .mapperBuilder() + .enableFileOverride() // 覆盖已生成文件 + .serviceBuilder() + .enableFileOverride() // 覆盖已生成文件 + .controllerBuilder() + .disable() +// .enableRestStyle(); // 启用 REST 风格 + ) + // 使用 Freemarker 模板引擎 + .templateEngine(new FreemarkerTemplateEngine()) + .execute(); // 执行生成 + } + + public static void main(String[] args) { + String ip = "127.0.0.1"; + String database = "miku"; + String username = "root"; + String password = "mysql123456"; + Scanner sc = new Scanner(System.in); + System.out.println("请输入表名,多个英文逗号分隔:"); + String tableNames = sc.next(); + String[] tableArray = tableNames.split(","); + System.out.println("表名:" + Arrays.toString(tableArray)); + MybatisGenerator.generate(ip,database,username,password,tableArray); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..78ea133 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,32 @@ +server: + port: 8080 + +spring: + datasource: + # 设置数据库驱动 + driver-class-name: com.mysql.cj.jdbc.Driver + # druid连接池 + type: com.alibaba.druid.pool.DruidDataSource + # 本地数据库 + url: jdbc:mysql://127.0.0.1:3306/miku?characterEncoding=utf8&serverTimezone=Asia/Shanghai + username: root + password: mysql123456 + + #redis配置 + data: + redis: + host: 127.0.0.1 + password: 123456 + port: 6379 + # 连接超时时间 + timeout: 10s + + task: + scheduling: + pool: + size: 5 # 核心线程数=CPU核心数或稍高 + thread-name-prefix: "task-" + +web: + # 上传文件存储路径 + resource-path: /myfiles/alist/files/picture diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..0946644 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,32 @@ +server: + port: 8080 + +spring: + datasource: + # 设置数据库驱动 + driver-class-name: com.mysql.cj.jdbc.Driver + # druid连接池 + type: com.alibaba.druid.pool.DruidDataSource + # 本地数据库 + url: jdbc:mysql://127.0.0.1:3306/miku?characterEncoding=utf8&serverTimezone=Asia/Shanghai + username: root + password: 123456 + + #redis配置 + data: + redis: + host: 127.0.0.1 + password: 123456 + port: 6379 + # 连接超时时间 + timeout: 10s + + task: + scheduling: + pool: + size: 5 # 核心线程数=CPU核心数或稍高 + thread-name-prefix: "task-" + +web: + # 上传文件存储路径 + resource-path: D:\image \ No newline at end of file diff --git a/src/main/resources/application-prod.yml.template b/src/main/resources/application-prod.yml.template new file mode 100644 index 0000000..ea05e78 --- /dev/null +++ b/src/main/resources/application-prod.yml.template @@ -0,0 +1,32 @@ +server: + port: 端口号 + +spring: + datasource: + # 设置数据库驱动 + driver-class-name: com.mysql.cj.jdbc.Driver + # druid连接池 + type: com.alibaba.druid.pool.DruidDataSource + # 本地数据库 + url: jdbc:mysql://ip地址:端口号/miku?characterEncoding=utf8&serverTimezone=Asia/Shanghai + username: 数据库账号 + password: 数据库密码 + + #redis配置 + data: + redis: + host: redis的ip地址 + password: redis的密码,如果没有密码,则设置为空 + port: 6379 默认端口 + # 连接超时时间 + timeout: 10s + + task: + scheduling: + pool: + size: 核心线程数 + thread-name-prefix: "task-" + +web: + # 上传文件存储路径 + resource-path: 存储桶本地模式的文件夹路径 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..116e611 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,69 @@ +spring: + # 配置环境 + profiles: + # @active@ 为占位符,在maven打包时替换 + active: @active@ + # 静态文件配置 + web: + resources: + # 静态文件路径, 优先级低于webjars + static-locations: classpath:/static,file:${web.resource-path} + + servlet: + # 文件上传配置 + multipart: + # 单个文件最大值 (默认1MB) 解决一次上传多个文件大小超过1MB出现异常的问题 + max-file-size: 50MB + # 单次上传文件最大值 + max-request-size: 50MB + +mybatis-plus: + global-config: + # 关闭mybatis-plus的logo + banner: false + db-config: + # 设置默认id规则是自动递增 + id-type: auto + # configuration: + # 开启mybatis的日志 +# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +client-api: + target-package: com.mikufufu.modules.api.controller + +# 日志配置 +logging: + level: + # 设置日志的默认级别为 info + root: info + com.mikufufu.mapper: debug + +demo-task: + cron: 0/5 * * * * ? + enable: false + +# springdoc-openapi项目配置 +springdoc: + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + api-docs: + path: /v3/api-docs + group-configs: + - group: '默认分组' + paths-to-match: + - '/user/**' + - '/**' + packages-to-scan: com.mikufufu.modules.api + - group: '认证模块' + paths-to-match: '/**' + packages-to-scan: com.mikufufu.modules.auth + - group: '系统模块' + paths-to-match: + - '/permission/**' + - '/role/**' + - '/files/**' + packages-to-scan: + - com.mikufufu.modules.system + - com.mikufufu.modules.storage \ No newline at end of file diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..175713f --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,15 @@ +Spring Boot Version: ${spring-boot.version} +Spring Boot environment: ${spring.profiles.active} + +⣿⣿⣿⠟⠛⠛⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⢋⣩⣉⢻ +⣿⣿⣿⠀⣿⣶⣕⣈⠹⠿⠿⠿⠿⠟⠛⣛⢋⣰⠣⣿⣿⠀⣿ +⣿⣿⣿⡀⣿⣿⣿⣧⢻⣿⣶⣷⣿⣿⣿⣿⣿⣿⠿⠶⡝⠀⣿ +⣿⣿⣿⣷⠘⣿⣿⣿⢏⣿⣿⣋⣀⣈⣻⣿⣿⣷⣤⣤⣿⡐⢿ +⣿⣿⣿⣿⣆⢩⣝⣫⣾⣿⣿⣿⣿⡟⠿⠿⠦⠀⠸⠿⣻⣿⡄⢻ +⣿⣿⣿⣿⣿⡄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣾⣿⣿⣿⣿⠇⣼ +⣿⣿⣿⣿⣿⣿⡄⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⣰ +⣿⣿⣿⣿⣿⣿⠇⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢀⣿ +⣿⣿⣿⣿⣿⠏⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢸⣿ +⣿⣿⣿⣿⠟⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⣿ +⣿⣿⣿⠋⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⣿ +⣿⣿⠋⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢸ \ No newline at end of file diff --git a/src/main/resources/mapper/MenuMapper.xml b/src/main/resources/mapper/MenuMapper.xml new file mode 100644 index 0000000..7d986c7 --- /dev/null +++ b/src/main/resources/mapper/MenuMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/OperateLogMapper.xml b/src/main/resources/mapper/OperateLogMapper.xml new file mode 100644 index 0000000..965fbd6 --- /dev/null +++ b/src/main/resources/mapper/OperateLogMapper.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + id, `module`, url, params, operate_type, method_name, `operator`, ip, operate_time, + remark, `status`, error_message + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml new file mode 100644 index 0000000..7353964 --- /dev/null +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/RoleMapper.xml b/src/main/resources/mapper/RoleMapper.xml new file mode 100644 index 0000000..708c5ab --- /dev/null +++ b/src/main/resources/mapper/RoleMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/RoleMenuMapper.xml b/src/main/resources/mapper/RoleMenuMapper.xml new file mode 100644 index 0000000..9a00632 --- /dev/null +++ b/src/main/resources/mapper/RoleMenuMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/RolePermissionMapper.xml b/src/main/resources/mapper/RolePermissionMapper.xml new file mode 100644 index 0000000..1d7011f --- /dev/null +++ b/src/main/resources/mapper/RolePermissionMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/StorageMapper.xml b/src/main/resources/mapper/StorageMapper.xml new file mode 100644 index 0000000..16cb7c6 --- /dev/null +++ b/src/main/resources/mapper/StorageMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/SysSettingMapper.xml b/src/main/resources/mapper/SysSettingMapper.xml new file mode 100644 index 0000000..922ad6c --- /dev/null +++ b/src/main/resources/mapper/SysSettingMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..6705c12 --- /dev/null +++ b/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..a061c6e --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,31 @@ +#app { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} +#app h1 { + font-size: 50px; + color: #fff; + text-shadow: 0 0 5px rgba(0, 0, 0, 0.5); +} +.ui-button { + background: #ff0000; + color: #fff; + border: 0; + padding: 10px 20px; + border-radius: 5px; + cursor: pointer; + box-shadow : 0 0 5px rgba(0, 0, 0, 0.5); + transition: all 0.5s ease; + outline: none; + position: relative; + z-index: 1; + overflow: hidden; + text-shadow: 0 0 5px rgba(0, 0, 0, 0.5); + text-transform: uppercase; + letter-spacing: 2px; + font-weight: bold; + font-size: 16px; + margin-top: 20px; +} \ No newline at end of file diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f588ace1072d57852cbc306edc6079b769445011 GIT binary patch literal 270398 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 z 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..7f9d0c7 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,16 @@ + + + + + 启动成功展示页 + + + +
+

启动成功

+
+ 启动成功,点击进入首页 +
+
+ + \ No newline at end of file diff --git a/src/main/resources/template/email_code.html b/src/main/resources/template/email_code.html new file mode 100644 index 0000000..bd45130 --- /dev/null +++ b/src/main/resources/template/email_code.html @@ -0,0 +1,19 @@ + + + + + + 验证码信息 + + +
+

您的验证码

+
${code}
+
+ 请在 ${expire}分钟 内使用该验证码
+ 此验证码仅限本次操作使用 +
有效期至:${time}
+
+
+ +