From 4ec19486e0cd6e517688cda4788ae1c159c9311f Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 14:40:23 +0800 Subject: [PATCH] init: UserBaseService (backend + deploy + scripts) --- .dockerignore | 7 + .gitea/workflows/ci.yml | 116 ++ .gitignore | 26 + deploy/.env.example | 20 + deploy/_debug_run.sh | 6 + deploy/_ps_chk.txt | 1 + deploy/_verify_remote.sh | 17 + deploy/build-push.sh | 36 + deploy/check_type_col.sh | 22 + deploy/deploy.sh | 40 + deploy/diag_caddy.sh | 23 + deploy/diag_deep.sh | 24 + deploy/docker-compose.userbase.yml | 74 ++ deploy/find_db.sh | 8 + deploy/fix_caddy.sh | 15 + deploy/fix_restart.sh | 19 + deploy/fix_wildcard.sh | 33 + deploy/probe_nacos.sh | 19 + deploy/probe_net.sh | 10 + deploy/redeploy_app.sh | 28 + deploy/remote_run.py | 35 + deploy/smoke.sh | 13 + deploy/upload.py | 37 + deploy/userbase.nacos.yaml | 104 ++ deploy/verify_db.sh | 10 + deploy/verify_final.sh | 19 + docker/Dockerfile | 20 + docker/application-prod.yml | 90 ++ docker/run-app.sh | 10 + findlock2.py | 64 + pom.xml | 248 ++++ scripts/deploy-via-docker.js | 268 ++++ .../java/com/bestsch/openapi/UserSysRole.java | 37 + .../java/com/bestsch/utils/DException.java | 22 + .../yunhai/userbase/UserBaseApplication.java | 22 + .../userbase/aspect/OperationLogAspect.java | 204 +++ .../common/GlobalExceptionHandler.java | 72 + .../common/annotation/OperationLog.java | 35 + .../userbase/common/context/UserContext.java | 18 + .../common/converter/ClientEnumConverter.java | 18 + .../converter/CommonIntEnumConverter.java | 47 + .../common/converter/GenderEnumConverter.java | 18 + .../converter/GroupTypeEnumConverter.java | 18 + .../common/converter/IntegEnumConverter.java | 18 + .../converter/ListIntegerJsonConverter.java | 50 + .../common/converter/StatusEnumConverter.java | 18 + .../converter/SysRoleEnumConverter.java | 20 + .../converter/UserTypeEnumConverter.java | 18 + .../BoaUserContextInterceptor.java | 48 + .../common/listener/UserAuditListener.java | 33 + .../common/validation/ValidationGroups.java | 11 + .../userbase/config/AppSecretConfig.java | 23 + .../yunhai/userbase/config/CacheConfig.java | 183 +++ .../com/yunhai/userbase/config/Config.java | 33 + .../yunhai/userbase/config/DWebMvcConfig.java | 32 + .../userbase/config/FileStorageConfig.java | 32 + .../userbase/config/MyWebAppConfigurer.java | 29 + .../OpenApiAuthEnforceConfiguration.java | 51 + .../config/PasswordEncoderConfig.java | 29 + .../yunhai/userbase/config/SwaggerConfig.java | 37 + .../config/UserBaseAppSecretProvider.java | 79 ++ .../config/UserBaseUserDetailsProvider.java | 37 + .../controller/api/AppRoleController.java | 135 ++ .../controller/api/AppSettingsController.java | 184 +++ .../controller/api/BaseController.java | 140 ++ .../controller/api/GroupAuthzController.java | 179 +++ .../controller/api/SysGroupController.java | 97 ++ .../api/SysOperationLogController.java | 74 ++ .../controller/api/SysUserController.java | 68 + .../controller/oapi/OpenBaseController.java | 45 + .../oapi/SignKeyInternalController.java | 70 + .../yunhai/userbase/entity/AppCategory.java | 28 + .../com/yunhai/userbase/entity/AppModule.java | 81 ++ .../userbase/entity/ApplicationInfo.java | 107 ++ .../yunhai/userbase/entity/BaseEntity.java | 63 + .../yunhai/userbase/entity/SysAppRole.java | 68 + .../userbase/entity/SysAppRoleModuleRel.java | 49 + .../com/yunhai/userbase/entity/SysGroup.java | 42 + .../userbase/entity/SysGroupAppRel.java | 49 + .../userbase/entity/SysGroupModulePerm.java | 56 + .../userbase/entity/SysOperationLog.java | 140 ++ .../com/yunhai/userbase/entity/SysUser.java | 73 + .../userbase/entity/SysUserAppRoleRel.java | 49 + .../userbase/entity/SysUserCurrentRole.java | 48 + .../userbase/entity/SysUserGroupRel.java | 50 + .../com/yunhai/userbase/enums/BizType.java | 39 + .../com/yunhai/userbase/enums/BookType.java | 50 + .../com/yunhai/userbase/enums/ClientEnum.java | 35 + .../com/yunhai/userbase/enums/GenderEnum.java | 30 + .../yunhai/userbase/enums/GroupTypeEnum.java | 36 + .../java/com/yunhai/userbase/enums/IEnum.java | 65 + .../com/yunhai/userbase/enums/IntegEnum.java | 31 + .../com/yunhai/userbase/enums/LogLevel.java | 41 + .../userbase/enums/ResourceSourceEnum.java | 21 + .../com/yunhai/userbase/enums/RootNode.java | 49 + .../yunhai/userbase/enums/SchoolStage.java | 66 + .../com/yunhai/userbase/enums/StatusEnum.java | 30 + .../yunhai/userbase/enums/SysModeEnum.java | 32 + .../yunhai/userbase/enums/SysRoleEnum.java | 40 + .../yunhai/userbase/enums/SystemGrade.java | 38 + .../com/yunhai/userbase/enums/UserType.java | 28 + .../yunhai/userbase/enums/UserTypeEnum.java | 34 + .../com/yunhai/userbase/model/BaseDict.java | 34 + .../yunhai/userbase/model/BasePageQuery.java | 16 + .../yunhai/userbase/model/BasePageResult.java | 39 + .../com/yunhai/userbase/model/BaseTree.java | 19 + .../userbase/model/Excel/ExcelKnowledge.java | 33 + .../model/Excel/ExcelResearchFill.java | 28 + .../com/yunhai/userbase/model/Operator.java | 29 + .../java/com/yunhai/userbase/model/Page.java | 72 + .../yunhai/userbase/model/StartEndDate.java | 16 + .../java/com/yunhai/userbase/model/Tree.java | 18 + .../model/appauthz/AppAuthzAppRow.java | 37 + .../model/appauthz/AppAuthzCategoryVo.java | 48 + .../model/appcategory/AppCategoryQuery.java | 13 + .../model/appcategory/AppCategoryRequest.java | 22 + .../model/appcategory/AppCategoryVo.java | 15 + .../application/ApplicationInfoQuery.java | 22 + .../application/ApplicationInfoRequest.java | 76 ++ .../application/ApplicationInfoSortVo.java | 34 + .../model/application/ApplicationInfoVo.java | 84 ++ .../model/appmodule/AppModuleQuery.java | 18 + .../model/appmodule/AppModuleRequest.java | 36 + .../userbase/model/appmodule/AppModuleVo.java | 25 + .../model/approle/AppRoleAuthzAppVo.java | 34 + .../model/approle/AppRoleAuthzModuleVo.java | 42 + .../approle/AppRoleModuleAssignRequest.java | 27 + .../model/approle/AppRoleUserBindRequest.java | 27 + .../userbase/model/approle/AppRoleUserVo.java | 21 + .../model/approle/SysAppRoleQuery.java | 24 + .../model/approle/SysAppRoleRequest.java | 50 + .../userbase/model/approle/SysAppRoleVo.java | 33 + .../model/approle/UserAppRoleBindRequest.java | 27 + .../groupauthz/GroupAppAssignRequest.java | 23 + .../userbase/model/groupauthz/GroupAppVo.java | 30 + .../model/groupauthz/GroupAuthzAppVo.java | 35 + .../groupauthz/GroupAuthzCategoryVo.java | 25 + .../model/groupauthz/GroupAuthzModuleVo.java | 45 + .../groupauthz/GroupModuleAuthzItem.java | 47 + .../groupauthz/GroupModulePermRequest.java | 38 + .../groupauthz/SchoolAppAuthzRequest.java | 38 + .../model/groupauthz/UserGroupNodeVo.java | 29 + .../model/groupauthz/UserPermissionAppVo.java | 32 + .../groupauthz/UserPermissionCategoryVo.java | 26 + .../groupauthz/UserPermissionModuleVo.java | 38 + .../groupauthz/UserPermissionUserVo.java | 25 + .../userbase/model/groupauthz/UserRoleVo.java | 30 + .../model/oplog/SysOperationLogListVo.java | 77 ++ .../model/oplog/SysOperationLogQuery.java | 59 + .../model/other/OperationDetailsModel.java | 33 + .../userbase/model/other/RoleInfoModel.java | 25 + .../model/other/SchLenGradeModel.java | 22 + .../model/other/SchLenLevelModel.java | 19 + .../userbase/model/other/UserInfoModel.java | 26 + .../model/serialize/AcademicYear.java | 20 + .../model/serialize/AcademicYearSeq.java | 25 + .../userbase/model/serialize/BaseSchool.java | 18 + .../model/serialize/GradeClassStu.java | 22 + .../userbase/model/serialize/SchClass.java | 15 + .../model/serialize/SchClassModel.java | 12 + .../userbase/model/serialize/SchLenLevel.java | 23 + .../userbase/model/serialize/SchTeaInfo.java | 35 + .../userbase/model/serialize/StuInfo.java | 29 + .../userbase/model/serialize/StuInfo2.java | 31 + .../model/serialize/TeaFirstClass.java | 13 + .../model/sysgroup/SysGroupBatchRequest.java | 48 + .../model/sysgroup/SysGroupMoveRequest.java | 23 + .../model/sysgroup/SysGroupQuery.java | 15 + .../model/sysgroup/SysGroupRequest.java | 31 + .../model/sysgroup/SysGroupSortRequest.java | 24 + .../userbase/model/sysgroup/SysGroupVo.java | 21 + .../userbase/model/sysuser/SysUserQuery.java | 27 + .../model/sysuser/SysUserRequest.java | 43 + .../userbase/model/sysuser/SysUserVo.java | 31 + .../repository/AppCategoryRepository.java | 33 + .../repository/AppModuleRepository.java | 46 + .../repository/ApplicationInfoRepository.java | 74 ++ .../SysAppRoleModuleRelRepository.java | 47 + .../repository/SysAppRoleRepository.java | 40 + .../repository/SysGroupAppRelRepository.java | 47 + .../SysGroupModulePermRepository.java | 60 + .../repository/SysGroupRepository.java | 31 + .../repository/SysOperationLogRepository.java | 27 + .../SysUserAppRoleRelRepository.java | 51 + .../repository/SysUserGroupRelRepository.java | 25 + .../repository/SysUserRepository.java | 23 + .../userbase/service/IAppCategoryService.java | 36 + .../userbase/service/IAppModuleService.java | 23 + .../userbase/service/IAppRoleService.java | 105 ++ .../service/IApplicationInfoService.java | 85 ++ .../userbase/service/IGroupAuthzService.java | 161 +++ .../userbase/service/ISysGroupService.java | 54 + .../service/ISysOperationLogService.java | 50 + .../userbase/service/ISysUserService.java | 42 + .../userbase/service/SignKeyCacheService.java | 151 +++ .../userbase/service/UserCenterService.java | 145 ++ .../service/impl/AppCategoryService.java | 138 ++ .../service/impl/AppModuleService.java | 277 ++++ .../userbase/service/impl/AppRoleService.java | 499 +++++++ .../service/impl/ApplicationInfoService.java | 259 ++++ .../service/impl/GroupAuthzService.java | 1180 +++++++++++++++++ .../service/impl/SysGroupService.java | 403 ++++++ .../impl/SysOperationLogServiceImpl.java | 181 +++ .../userbase/service/impl/SysUserService.java | 279 ++++ .../task/OperationLogCleanupTask.java | 33 + .../com/yunhai/userbase/utils/BaseUtil.java | 155 +++ .../yunhai/userbase/utils/CacheKitUtils.java | 57 + .../yunhai/userbase/utils/ConstantUtil.java | 44 + .../com/yunhai/userbase/utils/DicUtil.java | 179 +++ .../userbase/utils/DownloadFileUtil.java | 56 + .../yunhai/userbase/utils/EasyPoiUtils.java | 105 ++ .../com/yunhai/userbase/utils/EnumUtils.java | 134 ++ .../com/yunhai/userbase/utils/FileUtil.java | 411 ++++++ .../com/yunhai/userbase/utils/GzipUtil.java | 64 + .../com/yunhai/userbase/utils/HttpUtils.java | 143 ++ .../com/yunhai/userbase/utils/IpUtils.java | 53 + .../com/yunhai/userbase/utils/NodeIdUtil.java | 86 ++ .../userbase/utils/OperationLogResolver.java | 85 ++ .../com/yunhai/userbase/utils/OtherUtil.java | 139 ++ .../com/yunhai/userbase/utils/PageUtils.java | 53 + .../yunhai/userbase/utils/PermissionBits.java | 43 + .../yunhai/userbase/utils/RedisConstants.java | 10 + .../com/yunhai/userbase/utils/RedisUtil.java | 868 ++++++++++++ .../yunhai/userbase/utils/SubjectDict.java | 78 ++ .../com/yunhai/userbase/utils/TimeUtils.java | 273 ++++ .../yunhai/userbase/utils/ValidatorUtil.java | 134 ++ .../yunhai/userbase/utils/WarnInfoUtil.java | 8 + src/main/resources/application.yml | 89 ++ src/main/resources/bootstrap.yml | 26 + src/main/resources/bschconfig.jks | Bin 0 -> 2267 bytes src/main/resources/logback-spring.xml | 28 + .../resources/sql/sys_group_root_init.sql | 27 + src/main/resources/sql/sys_user_init.sql | 76 ++ .../userbase/UserBaseApplicationTests.java | 14 + 234 files changed, 15604 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 deploy/.env.example create mode 100644 deploy/_debug_run.sh create mode 100644 deploy/_ps_chk.txt create mode 100644 deploy/_verify_remote.sh create mode 100644 deploy/build-push.sh create mode 100644 deploy/check_type_col.sh create mode 100644 deploy/deploy.sh create mode 100644 deploy/diag_caddy.sh create mode 100644 deploy/diag_deep.sh create mode 100644 deploy/docker-compose.userbase.yml create mode 100644 deploy/find_db.sh create mode 100644 deploy/fix_caddy.sh create mode 100644 deploy/fix_restart.sh create mode 100644 deploy/fix_wildcard.sh create mode 100644 deploy/probe_nacos.sh create mode 100644 deploy/probe_net.sh create mode 100644 deploy/redeploy_app.sh create mode 100644 deploy/remote_run.py create mode 100644 deploy/smoke.sh create mode 100644 deploy/upload.py create mode 100644 deploy/userbase.nacos.yaml create mode 100644 deploy/verify_db.sh create mode 100644 deploy/verify_final.sh create mode 100644 docker/Dockerfile create mode 100644 docker/application-prod.yml create mode 100644 docker/run-app.sh create mode 100644 findlock2.py create mode 100644 pom.xml create mode 100644 scripts/deploy-via-docker.js create mode 100644 src/main/java/com/bestsch/openapi/UserSysRole.java create mode 100644 src/main/java/com/bestsch/utils/DException.java create mode 100644 src/main/java/com/yunhai/userbase/UserBaseApplication.java create mode 100644 src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java create mode 100644 src/main/java/com/yunhai/userbase/common/GlobalExceptionHandler.java create mode 100644 src/main/java/com/yunhai/userbase/common/annotation/OperationLog.java create mode 100644 src/main/java/com/yunhai/userbase/common/context/UserContext.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/ClientEnumConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/CommonIntEnumConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/GenderEnumConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/GroupTypeEnumConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/IntegEnumConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/ListIntegerJsonConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/StatusEnumConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/SysRoleEnumConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/converter/UserTypeEnumConverter.java create mode 100644 src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java create mode 100644 src/main/java/com/yunhai/userbase/common/listener/UserAuditListener.java create mode 100644 src/main/java/com/yunhai/userbase/common/validation/ValidationGroups.java create mode 100644 src/main/java/com/yunhai/userbase/config/AppSecretConfig.java create mode 100644 src/main/java/com/yunhai/userbase/config/CacheConfig.java create mode 100644 src/main/java/com/yunhai/userbase/config/Config.java create mode 100644 src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java create mode 100644 src/main/java/com/yunhai/userbase/config/FileStorageConfig.java create mode 100644 src/main/java/com/yunhai/userbase/config/MyWebAppConfigurer.java create mode 100644 src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java create mode 100644 src/main/java/com/yunhai/userbase/config/PasswordEncoderConfig.java create mode 100644 src/main/java/com/yunhai/userbase/config/SwaggerConfig.java create mode 100644 src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java create mode 100644 src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java create mode 100644 src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/api/BaseController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/api/SysUserController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/oapi/SignKeyInternalController.java create mode 100644 src/main/java/com/yunhai/userbase/entity/AppCategory.java create mode 100644 src/main/java/com/yunhai/userbase/entity/AppModule.java create mode 100644 src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java create mode 100644 src/main/java/com/yunhai/userbase/entity/BaseEntity.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysAppRole.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysAppRoleModuleRel.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysGroup.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysGroupAppRel.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysGroupModulePerm.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysOperationLog.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysUser.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysUserAppRoleRel.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java create mode 100644 src/main/java/com/yunhai/userbase/enums/BizType.java create mode 100644 src/main/java/com/yunhai/userbase/enums/BookType.java create mode 100644 src/main/java/com/yunhai/userbase/enums/ClientEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/GenderEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/GroupTypeEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/IEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/IntegEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/LogLevel.java create mode 100644 src/main/java/com/yunhai/userbase/enums/ResourceSourceEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/RootNode.java create mode 100644 src/main/java/com/yunhai/userbase/enums/SchoolStage.java create mode 100644 src/main/java/com/yunhai/userbase/enums/StatusEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/SysModeEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/SysRoleEnum.java create mode 100644 src/main/java/com/yunhai/userbase/enums/SystemGrade.java create mode 100644 src/main/java/com/yunhai/userbase/enums/UserType.java create mode 100644 src/main/java/com/yunhai/userbase/enums/UserTypeEnum.java create mode 100644 src/main/java/com/yunhai/userbase/model/BaseDict.java create mode 100644 src/main/java/com/yunhai/userbase/model/BasePageQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/BasePageResult.java create mode 100644 src/main/java/com/yunhai/userbase/model/BaseTree.java create mode 100644 src/main/java/com/yunhai/userbase/model/Excel/ExcelKnowledge.java create mode 100644 src/main/java/com/yunhai/userbase/model/Excel/ExcelResearchFill.java create mode 100644 src/main/java/com/yunhai/userbase/model/Operator.java create mode 100644 src/main/java/com/yunhai/userbase/model/Page.java create mode 100644 src/main/java/com/yunhai/userbase/model/StartEndDate.java create mode 100644 src/main/java/com/yunhai/userbase/model/Tree.java create mode 100644 src/main/java/com/yunhai/userbase/model/appauthz/AppAuthzAppRow.java create mode 100644 src/main/java/com/yunhai/userbase/model/appauthz/AppAuthzCategoryVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/application/ApplicationInfoQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/application/ApplicationInfoSortVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/appmodule/AppModuleQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/appmodule/AppModuleVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzAppVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzModuleVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/AppRoleModuleAssignRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBindRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/AppRoleUserVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/SysAppRoleQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/SysAppRoleRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/SysAppRoleVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/approle/UserAppRoleBindRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/GroupAppAssignRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/GroupAppVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzAppVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzCategoryVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzModuleVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/GroupModuleAuthzItem.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/GroupModulePermRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/SchoolAppAuthzRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/UserGroupNodeVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionAppVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionCategoryVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionModuleVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionUserVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/groupauthz/UserRoleVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/oplog/SysOperationLogListVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/oplog/SysOperationLogQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/other/OperationDetailsModel.java create mode 100644 src/main/java/com/yunhai/userbase/model/other/RoleInfoModel.java create mode 100644 src/main/java/com/yunhai/userbase/model/other/SchLenGradeModel.java create mode 100644 src/main/java/com/yunhai/userbase/model/other/SchLenLevelModel.java create mode 100644 src/main/java/com/yunhai/userbase/model/other/UserInfoModel.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/AcademicYear.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/AcademicYearSeq.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/BaseSchool.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/GradeClassStu.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/SchClass.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/SchClassModel.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/SchLenLevel.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/SchTeaInfo.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/StuInfo.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/StuInfo2.java create mode 100644 src/main/java/com/yunhai/userbase/model/serialize/TeaFirstClass.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupBatchRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupMoveRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupSortRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/SysUserQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java create mode 100644 src/main/java/com/yunhai/userbase/repository/AppCategoryRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/AppModuleRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysAppRoleModuleRelRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysAppRoleRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysGroupAppRelRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysGroupModulePermRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysGroupRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysOperationLogRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysUserAppRoleRelRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysUserRepository.java create mode 100644 src/main/java/com/yunhai/userbase/service/IAppCategoryService.java create mode 100644 src/main/java/com/yunhai/userbase/service/IAppModuleService.java create mode 100644 src/main/java/com/yunhai/userbase/service/IAppRoleService.java create mode 100644 src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java create mode 100644 src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java create mode 100644 src/main/java/com/yunhai/userbase/service/ISysGroupService.java create mode 100644 src/main/java/com/yunhai/userbase/service/ISysOperationLogService.java create mode 100644 src/main/java/com/yunhai/userbase/service/ISysUserService.java create mode 100644 src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java create mode 100644 src/main/java/com/yunhai/userbase/service/UserCenterService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/SysUserService.java create mode 100644 src/main/java/com/yunhai/userbase/task/OperationLogCleanupTask.java create mode 100644 src/main/java/com/yunhai/userbase/utils/BaseUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/CacheKitUtils.java create mode 100644 src/main/java/com/yunhai/userbase/utils/ConstantUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/DicUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/DownloadFileUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/EasyPoiUtils.java create mode 100644 src/main/java/com/yunhai/userbase/utils/EnumUtils.java create mode 100644 src/main/java/com/yunhai/userbase/utils/FileUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/GzipUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/HttpUtils.java create mode 100644 src/main/java/com/yunhai/userbase/utils/IpUtils.java create mode 100644 src/main/java/com/yunhai/userbase/utils/NodeIdUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/OperationLogResolver.java create mode 100644 src/main/java/com/yunhai/userbase/utils/OtherUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/PageUtils.java create mode 100644 src/main/java/com/yunhai/userbase/utils/PermissionBits.java create mode 100644 src/main/java/com/yunhai/userbase/utils/RedisConstants.java create mode 100644 src/main/java/com/yunhai/userbase/utils/RedisUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/SubjectDict.java create mode 100644 src/main/java/com/yunhai/userbase/utils/TimeUtils.java create mode 100644 src/main/java/com/yunhai/userbase/utils/ValidatorUtil.java create mode 100644 src/main/java/com/yunhai/userbase/utils/WarnInfoUtil.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/bootstrap.yml create mode 100644 src/main/resources/bschconfig.jks create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/main/resources/sql/sys_group_root_init.sql create mode 100644 src/main/resources/sql/sys_user_init.sql create mode 100644 src/test/java/com/yunhai/userbase/UserBaseApplicationTests.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d239eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target/* +!target/userbase.jar +src +.idea +*.iml +*.log +.workbuddy diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7970afb --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,116 @@ +name: ci-userbase-service + +# 后端标准化 CI:maven 构建 jar -> docker build -> 推 Nexus docker-hosted -> 部署 160 +# 触发:push 到 main(service 仓库根即后端代码)/ 手动 Run workflow +# 依赖仓库 Actions Secrets:NEXUS_USER / NEXUS_PASSWORD(ci-docker 账号,仅 docker-hosted 推送权) +on: + push: + branches: [main] + workflow_dispatch: + inputs: + tag: + description: 'image tag (default = short sha)' + required: false + default: 'auto' + deploy: + description: 'deploy to 160 after build (auto / skip)' + required: false + default: 'auto' + +jobs: + build-and-push: + runs-on: ubuntu-latest + # docker:24 自带 docker CLI,复用宿主 docker daemon(act_runner v0.6.1 已挂载 docker.sock)。 + # 切勿再声明 volumes: docker.sock(会报 Duplicate mount point)。 + container: + image: docker:24 + steps: + - name: Install git (alpine) + run: apk add --no-cache git + + # 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库 + - name: Checkout (manual, internal Gitea) + run: | + git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src + cp -a /tmp/src/. . + rm -rf /tmp/src + + - name: Compute image tag + id: meta + run: | + if [ "${{ inputs.tag }}" = "auto" ] || [ -z "${{ inputs.tag }}" ]; then + echo "tag=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT + else + echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT + fi + + - name: Build jar with Maven + run: | + # job 容器经 docker.sock 调宿主 docker daemon,故 docker run -v 路径按宿主解析; + # act 工作区是宿主 docker volume,容器内 $PWD 对宿主不可见,直接 -v $PWD:/build 会绑空目录。 + # 故改为:起 maven 容器 -> docker cp 源码入容器 -> exec 构建 -> cp 产物回工作区。 + # mavenbuild 加入 infra-infra-net,用容器名 nexus 直连 Nexus(8081 maven-public)。 + # 先清理上次失败残留的同名容器 + docker rm -f mavenbuild 2>/dev/null || true + docker run -d --name mavenbuild \ + --network infra-infra-net \ + -v /opt/docker/ubs-m2:/root/.m2 \ + -w /build maven:3.6.3-jdk-8 sleep 3600 + docker cp "${PWD}/." mavenbuild:/build/ + # settings.xml:新增 profile 把 nexus:8081 maven-public 作为额外仓库(infra-infra-net 内容器名直连)。 + # 不用 mirror(external:http:*):对 BOM import 解析未生效;profile 方式更可靠。 + docker exec mavenbuild sh -c "printf '%s' 'nexus-reponexus-internalhttp://nexus:8081/repository/maven-public/truetruealwaysnexus-repo' > /root/.m2/settings.xml" + # 仅清旧 yh-auth 缓存(含 08-05 local install 的 maven-metadata-local.xml);BOM 等其它模块保留缓存。 + docker exec mavenbuild rm -rf /root/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter + docker exec mavenbuild sh -c 'cd /build && mvn -U -B -DskipTests clean package' + mkdir -p "${PWD}/target" + docker cp mavenbuild:/build/target/userbase.jar "${PWD}/target/userbase.jar" + docker rm -f mavenbuild + + - name: Login to Nexus docker registry + run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin + + - name: Build image + run: docker build -t nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }} -f docker/Dockerfile . + + - name: Push image + run: docker push nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }} + + - name: Report build + run: echo "pushed nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}" + + # 部署阶段(仅在 push 或手动 dispatch deploy=auto 时执行),运行在 160 的 act_runner 上。 + - name: Deploy (pull + rollout with rollback) + if: github.event_name == 'push' || inputs.deploy == 'auto' + run: | + set +e + IMG=nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }} + echo "deploying $IMG" + echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin + docker pull "$IMG" + RB=userbase-service:rb-$(date +%s) + docker tag userbase-service:latest "$RB" 2>/dev/null || true + docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null + # SCA 2.2.4 坑: 命名空间必须用 SPRING_CLOUD_NACOS_CONFIG_NAMESPACE; + # 凭据必须放顶层 SPRING_CLOUD_NACOS_USERNAME/PASSWORD。 + docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \ + -e NACOS_ADDR=nacos:8848 \ + -e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \ + -e SPRING_CLOUD_NACOS_USERNAME=yh-reader \ + -e SPRING_CLOUD_NACOS_PASSWORD=123456 \ + "$IMG" + OK=0 + for i in $(seq 1 30); do + sleep 5 + CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8060/userbase/ 2>/dev/null || true) + if [ "$CODE" != "000" ]; then OK=1; break; fi + done + if [ "$OK" = "0" ]; then + echo "!! UNHEALTHY -> rollback to userbase-service:latest" + docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null + docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 userbase-service:latest + echo "rolled back"; exit 7 + fi + echo "DEPLOY OK: $IMG" + docker tag "$IMG" userbase-service:latest + docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep userbase || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..970d9d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# ---- java build ---- +target/ + +# ---- IDE ---- +.idea/ +*.iml + +# ---- local tooling ---- +.workbuddy/ + +# ---- env secrets (keep *.example) ---- +.env +!.env.example + +# ---- logs ---- +*.log +deploy/*_run*.log +deploy/verify_run*.log +deploy/push_run.log + +# ---- OS ---- +Thumbs.db +.DS_Store + +# ---- scanner artifacts ---- +defender_chk.txt diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..6e7d395 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,20 @@ +# 本地构建推送 / 远端部署 用的环境变量示例 +# 用法:复制为 .env 或直接 export 后执行脚本 + +# ---- ACR 阿里云镜像仓库 ---- +ACR_REGISTRY=registry.cn-hangzhou.aliyuncs.com +ACR_NAMESPACE=damaomi_docker +ACR_REPO=yunhai +ACR_USERNAME=your-acr-username +ACR_PASSWORD=your-acr-password + +# ---- 镜像 tag ---- +APP_VERSION=userbase-v1.0 +FRONT_VERSION=userbase-front-v1.0 + +# ---- 部署到远端 ---- +DEPLOY_HOST=192.168.10.157 +DEPLOY_PORT=22 +DEPLOY_USER=yunhai +REMOTE_DIR=/opt/docker/userbase +SSHPASS=your-ssh-password diff --git a/deploy/_debug_run.sh b/deploy/_debug_run.sh new file mode 100644 index 0000000..4f207bb --- /dev/null +++ b/deploy/_debug_run.sh @@ -0,0 +1,6 @@ +docker run --rm --network infra-infra-net --entrypoint java registry.cn-hangzhou.aliyuncs.com/damaomi_docker/yunhai:userbase-v1.0 -jar /app/userbase.jar --spring.profiles.active=prod --debug > /tmp/ub_debug.log 2>&1 +echo "EXIT=$?" +echo "=== conditions / binding matches ===" +grep -nE "YhAuthAutoConfiguration|CasSsoConfiguration|Failed to bind|Binding to|YhSsoProperties|Exception starting filter|serverUrlPrefix|NoSuchBean" /tmp/ub_debug.log | head -60 +echo "=== tail (conditions report) ===" +tail -50 /tmp/ub_debug.log diff --git a/deploy/_ps_chk.txt b/deploy/_ps_chk.txt new file mode 100644 index 0000000..8500e20 --- /dev/null +++ b/deploy/_ps_chk.txt @@ -0,0 +1 @@ +EOF diff --git a/deploy/_verify_remote.sh b/deploy/_verify_remote.sh new file mode 100644 index 0000000..dca455f --- /dev/null +++ b/deploy/_verify_remote.sh @@ -0,0 +1,17 @@ +set -e +echo "== wait for health (up to 180s) ==" +for i in $(seq 1 36); do + st=$(docker inspect -f '{{.State.Health.Status}}' userbase-app 2>/dev/null || echo unknown) + echo " [$i] health=$st" + if [ "$st" = "healthy" ]; then break; fi + sleep 5 +done +echo "== container status ==" +docker ps --filter name=userbase --format "table {{.Names}}\t{{.Status}}" +echo "== nacos reachability (host) ==" +curl -s -m 5 -o /dev/null -w "host nacos http=%{http_code}\n" "http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=" || echo "host cannot reach nacos" +echo "== nacos reachability (container) ==" +docker exec userbase-app sh -c "curl -s -m 5 -o /dev/null -w 'container nacos http=%{http_code}\n' 'http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant='" || echo "container cannot reach nacos" +echo "== app logs: nacos / config / errors (tail) ==" +docker logs --tail 200 userbase-app 2>&1 | grep -iE "Loading nacos data|userbase.yaml|locate Nacos|NacosConfig|get config from nacos|ERROR|Exception|connect.*refused|UnknownHost|403|401|拒绝|超时|Started " | tail -40 || true +echo "== done ==" diff --git a/deploy/build-push.sh b/deploy/build-push.sh new file mode 100644 index 0000000..ace2b66 --- /dev/null +++ b/deploy/build-push.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# 本地构建 UserBase 前后端镜像并推送到阿里云 ACR +# 用法: ./build-push.sh [app_tag] [front_tag] +# 前置环境变量: ACR_NAMESPACE, ACR_USERNAME, ACR_PASSWORD +# 前置产物: service/target/userbase.jar、front/dist/prod/ +set -euo pipefail + +APP_VERSION="${1:-${APP_VERSION:-userbase-v1.0}}" +FRONT_VERSION="${2:-${FRONT_VERSION:-userbase-front-v1.0}}" +REGISTRY="${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}" +NAMESPACE="${ACR_NAMESPACE:?请设置 ACR_NAMESPACE}" +REPO="${ACR_REPO:-yunhai}" + +APP_IMG="$REGISTRY/$NAMESPACE/$REPO:$APP_VERSION" +FRONT_IMG="$REGISTRY/$NAMESPACE/$REPO:$FRONT_VERSION" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +[ -f "$ROOT/service/target/userbase.jar" ] || { echo "缺少 service/target/userbase.jar,请先 mvn package"; exit 1; } +[ -f "$ROOT/front/dist/prod/index.html" ] || { echo "缺少 front/dist/prod,请先 npm run build:prod"; exit 1; } + +echo "== login $REGISTRY ==" +echo "${ACR_PASSWORD:?请设置 ACR_PASSWORD}" | docker login --username="${ACR_USERNAME:?请设置 ACR_USERNAME}" --password-stdin "$REGISTRY" + +echo "== build $APP_IMG ==" +docker build -f "$ROOT/service/docker/Dockerfile" -t "$APP_IMG" "$ROOT/service" + +echo "== build $FRONT_IMG ==" +docker build -f "$ROOT/front/docker/Dockerfile" -t "$FRONT_IMG" "$ROOT/front" + +echo "== push ==" +docker push "$APP_IMG" +docker push "$FRONT_IMG" + +echo "PUSHED $APP_IMG" +echo "PUSHED $FRONT_IMG" diff --git a/deploy/check_type_col.sh b/deploy/check_type_col.sh new file mode 100644 index 0000000..dd8664d --- /dev/null +++ b/deploy/check_type_col.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e +MC=mysql8 +echo "MYSQL_CONTAINER=$MC" + +docker exec userbase-app cat /app/config/application-prod.yml > /tmp/cfg.yml +PW=$(awk '/username: root/{f=1} f&&/^ *password:/{sub(/^ *password: */,""); gsub(/\r/,""); print; exit}' /tmp/cfg.yml) +echo "PW_LEN=${#PW}" + +printf '[client]\nuser=root\npassword=%s\n' "$PW" > /tmp/my.cnf +docker cp /tmp/my.cnf "$MC":/tmp/my.cnf + +echo "== check sys_user.type column (via TCP 127.0.0.1) ==" +docker exec "$MC" sh -c "mysql --defaults-extra-file=/tmp/my.cnf -h 127.0.0.1 -N -e \"SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='bsch_userbase' AND TABLE_NAME='sys_user' AND COLUMN_NAME='type';\"" +echo "QUERY_EXIT=$?" + +echo "== also confirm DB reachable & table exists ==" +docker exec "$MC" sh -c "mysql --defaults-extra-file=/tmp/my.cnf -h 127.0.0.1 -N -e \"SHOW TABLES FROM bsch_userbase LIKE 'sys_user';\"" + +docker exec "$MC" rm -f /tmp/my.cnf +rm -f /tmp/my.cnf /tmp/cfg.yml +echo "CLEANED" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..d1dabd8 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# 一键部署:上传编排文件 -> SSH 到服务器拉取镜像并启动 +# 用法: ./deploy.sh [app_tag] [front_tag] +# 前置环境变量: DEPLOY_HOST, SSHPASS(可选 ACR_NAMESPACE / DEPLOY_USER / REMOTE_DIR) +# 注:服务器需已 docker login registry.cn-hangzhou.aliyuncs.com +set -euo pipefail + +APP_VERSION="${1:-${APP_VERSION:-userbase-v1.0}}" +FRONT_VERSION="${2:-${FRONT_VERSION:-userbase-front-v1.0}}" +HOST="${DEPLOY_HOST:?请设置 DEPLOY_HOST}" +PORT="${DEPLOY_PORT:-22}" +USER="${DEPLOY_USER:-yunhai}" +REMOTE_DIR="${REMOTE_DIR:-/opt/docker/userbase}" +NAMESPACE="${ACR_NAMESPACE:-damaomi_docker}" +REGISTRY="${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}" +PY="${PYTHON_BIN:-python3}" + +cd "$(dirname "$0")" + +echo "== 1/2 上传 compose 到 $HOST:$REMOTE_DIR ==" +"$PY" upload.py "$HOST" "$PORT" "$USER" \ + docker-compose.userbase.yml "$REMOTE_DIR/docker-compose.userbase.yml" + +echo "== 2/2 远端拉取镜像并启动 ==" +"$PY" remote_run.py "$HOST" "$PORT" "$USER" < .env < {{.Destination}}{{"\n"}}{{end}}' +echo +echo "===== 4. Running caddy admin config: does it contain 'userbase'? =====" +docker exec caddy curl -s localhost:2019/config/ | grep -i "userbase" | head -n 20 +echo "--- end grep ---" +echo +echo "===== 5. Caddy container status =====" +docker ps --filter name=caddy --format '{{.Names}} {{.Status}}' +echo +echo "===== 6. Direct test via 127.0.0.1 with Host header =====" +for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x; do + code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p") + echo "$p -> $code | $(head -c 80 /tmp/ub_body | tr -d '\n')" +done diff --git a/deploy/diag_deep.sh b/deploy/diag_deep.sh new file mode 100644 index 0000000..bd3797e --- /dev/null +++ b/deploy/diag_deep.sh @@ -0,0 +1,24 @@ +set -u +echo "===== 1. In-container view of /etc/caddy/Caddyfile: userbase count =====" +docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile +echo +echo "===== 2. Container start command =====" +docker inspect caddy --format 'CMD={{.Config.Cmd}} ENTRY={{.Config.Entrypoint}}' +echo +echo "===== 3. Admin API reachable? top-level keys =====" +docker exec caddy curl -s localhost:2019/ +echo +echo "===== 4. Running HTTP servers + route matchers in config =====" +docker exec caddy curl -s localhost:2019/config/ > /tmp/running.json +echo "bytes: $(wc -c < /tmp/running.json)" +echo "occurrences:" +for s in "userbase" "Welcome to Base Platform" "handle_path" "reverse_proxy" "www.yunhaixingtong.com"; do + echo " $s : $(grep -o "$s" /tmp/running.json | wc -l)" +done +echo +echo "===== 5. Do a manual POST /load from inside container and show result =====" +docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1 +echo "exit=$?" +sleep 1 +echo "--- after reload, recount ---" +docker exec caddy curl -s localhost:2019/config/ | grep -c "userbase" diff --git a/deploy/docker-compose.userbase.yml b/deploy/docker-compose.userbase.yml new file mode 100644 index 0000000..ba61db6 --- /dev/null +++ b/deploy/docker-compose.userbase.yml @@ -0,0 +1,74 @@ +# UserBase 编排:后端(Spring Boot 8060) + 前端(nginx 80) +# 两者都挂在 infra-infra-net,由 caddy 按服务名反代到 www.yunhaixingtong.com/userbase +# 启动:cd /opt/docker/userbase && docker compose -f docker-compose.userbase.yml up -d +services: + userbase-app: + image: ${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}/${ACR_NAMESPACE:-damaomi_docker}/yunhai:${APP_VERSION:-userbase-v1.0} + container_name: userbase-app + restart: unless-stopped + networks: + - infra-infra-net + # SSO 回调后容器内要做 CAS serviceValidate 回调 sso.yunhaixingtong.com, + # 该虚拟主机由宿主机 caddy 按 vhost 路由,但容器内无 DNS 记录会 UnknownHostException。 + # host-gateway 指向宿主机网关(caddy 监听 :80/:443 全接口),无需硬编码 IP; + # 旧版 Docker(<20.10)不支持 host-gateway,可改为宿主机桥接网关 IP,如 172.17.0.1。 + extra_hosts: + - "sso.yunhaixingtong.com:host-gateway" + environment: + TZ: Asia/Shanghai + # 配置中心:由 bootstrap.yml 中 spring.cloud.nacos.config 在 bootstrap 阶段自动拉取 + # 容器内通过 infra-infra-net 解析内网别名 nacos:8848(宿主机 shell 无法直接解析), + # 公网域名 nacos.yunhaixingtong.com 仅用于控制台管理,生产容器走内网。 + NACOS_ADDR: nacos:8848 + # Nacos 已开启鉴权,config 客户端需携带账号(与旧 run-app.sh 的 nacos/nacos 一致) + SPRING_CLOUD_NACOS_USERNAME: nacos + SPRING_CLOUD_NACOS_PASSWORD: nacos + SPRING_CLOUD_NACOS_CONFIG_USERNAME: nacos + SPRING_CLOUD_NACOS_CONFIG_PASSWORD: nacos + JAVA_OPTS: "-Xms512m -Xmx1024m" + volumes: + - ./upload-data:/data/upload + expose: + - "8060" + healthcheck: + # 只要能返回 HTTP 状态码就认为进程已起(业务接口普遍需要鉴权,不能用 -f) + test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://localhost:8060/userbase/ | grep -qE '^[234]'"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + deploy: + resources: + limits: + cpus: '1.5' + memory: 1536M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + userbase-front: + image: ${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}/${ACR_NAMESPACE:-damaomi_docker}/yunhai:${FRONT_VERSION:-userbase-front-v1.0} + container_name: userbase-front + restart: unless-stopped + networks: + - infra-infra-net + environment: + TZ: Asia/Shanghai + expose: + - "80" + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + infra-infra-net: + external: true diff --git a/deploy/find_db.sh b/deploy/find_db.sh new file mode 100644 index 0000000..073e482 --- /dev/null +++ b/deploy/find_db.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e +echo "== all containers (name + image) ==" +docker ps -a --format '{{.Names}}\t{{.Image}}' +echo "== which containers are on infra-infra-net ==" +docker network inspect infra-infra-net --format '{{range .Containers}}{{.Name}} ({{.IPv4Address}}){{"\n"}}{{end}}' 2>/dev/null || echo "no such network" +echo "== resolve mysql8 from userbase-app ==" +docker exec userbase-app getent hosts mysql8 2>/dev/null || echo "getent failed" diff --git a/deploy/fix_caddy.sh b/deploy/fix_caddy.sh new file mode 100644 index 0000000..2ea602c --- /dev/null +++ b/deploy/fix_caddy.sh @@ -0,0 +1,15 @@ +set -u +echo "===== A. Reload caddy from inside the container (reads mounted /etc/caddy/Caddyfile) =====" +docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile +echo "reload exit: $?" +sleep 2 +echo +echo "===== B. Verify running config now contains userbase =====" +docker exec caddy curl -s localhost:2019/config/ | grep -c "userbase" +echo "(count above; 0 means still missing)" +echo +echo "===== C. Test routes via 127.0.0.1 with Host header =====" +for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x /userbase/login; do + code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p") + echo "$p -> $code | $(head -c 90 /tmp/ub_body | tr -d '\n')" +done diff --git a/deploy/fix_restart.sh b/deploy/fix_restart.sh new file mode 100644 index 0000000..d4b8568 --- /dev/null +++ b/deploy/fix_restart.sh @@ -0,0 +1,19 @@ +set -u +echo "===== host file (has userbase) =====" +grep -c "userbase" /opt/docker/infra/Caddyfile +echo "===== container file BEFORE restart (expect 0) =====" +docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile +echo +echo "===== restart caddy to re-bind the edited file =====" +docker restart caddy +echo "restart exit: $?" +sleep 6 +echo +echo "===== container file AFTER restart (expect >0) =====" +docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile +echo +echo "===== test routes =====" +for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x /userbase/login; do + code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p") + echo "$p -> $code | $(head -c 100 /tmp/ub_body | tr -d '\n')" +done diff --git a/deploy/fix_wildcard.sh b/deploy/fix_wildcard.sh new file mode 100644 index 0000000..b099adc --- /dev/null +++ b/deploy/fix_wildcard.sh @@ -0,0 +1,33 @@ +set -u +CP=/opt/docker/infra/Caddyfile +echo "===== before: line 70 =====" +sed -n '70p' "$CP" +echo "===== apply wildcard matcher (inode will change; restart re-binds) =====" +sed -i 's#handle_path /userbase/ {#handle_path /userbase* {#' "$CP" +echo "after:"; sed -n '70p' "$CP" +echo +echo "===== restart caddy to re-bind edited file =====" +docker restart caddy +echo "restart exit: $?" +sleep 6 +echo "===== container sees fix? =====" +docker exec caddy grep -n "handle_path /userbase" /etc/caddy/Caddyfile +echo +echo "===== extract a real JS asset from SPA and test it =====" +html=$(curl -s -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/) +echo "$html" | grep -oE 'src="[^"]+\.js"' | head -3 +asset=$(echo "$html" | grep -oE '/[A-Za-z0-9_./-]+\.js' | head -1) +echo "first asset path: $asset" +if [ -n "$asset" ]; then + code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1/userbase${asset}") + echo "/userbase${asset} -> $code" +fi +echo +echo "===== route tests =====" +for p in /userbase/ /userbase/index.html "/userbase${asset:-/static/js/}"; do + code=$(curl -s -o /tmp/b -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p") + echo "$p -> $code | $(head -c 70 /tmp/b|tr -d '\n')" +done +echo +echo "===== backend test =====" +curl -s -o /dev/null -w "/userbase/api/x -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/api/x diff --git a/deploy/probe_nacos.sh b/deploy/probe_nacos.sh new file mode 100644 index 0000000..8152fa9 --- /dev/null +++ b/deploy/probe_nacos.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# 探测内网 nacos:8848 是否可达、public 空间下 userbase.yaml 是否存在、是否需要鉴权 +echo "== resolve nacos ==" +getent hosts nacos || echo "cannot resolve nacos" +echo "== fetch userbase.yaml (public, no auth) ==" +curl -s -m 8 -o /tmp/nc.txt -w "http=%{http_code}\n" "http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=" +echo "--- body (first 200 chars) ---" +head -c 200 /tmp/nc.txt; echo +if grep -q "unknown user" /tmp/nc.txt || grep -q "403" /tmp/nc.txt || [ "$(head -c 1 /tmp/nc.txt)" = "" ]; then + echo "== needs auth, try login nacos/nacos ==" + TOKEN=$(curl -s -m 8 -X POST "http://nacos:8848/nacos/v1/auth/login?username=nacos&password=nacos" | sed -n 's/.*"accessToken":"\([^"]*\)".*/\1/p') + echo "token_len=${#TOKEN}" + if [ -n "$TOKEN" ]; then + curl -s -m 8 -o /tmp/nc2.txt -w "http=%{http_code}\n" "http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=&accessToken=$TOKEN" + echo "--- authed body (first 200 chars) ---" + head -c 200 /tmp/nc2.txt; echo + fi +fi +echo "== done ==" diff --git a/deploy/probe_net.sh b/deploy/probe_net.sh new file mode 100644 index 0000000..6e3bfcd --- /dev/null +++ b/deploy/probe_net.sh @@ -0,0 +1,10 @@ +#!/bin/sh +echo "== containers on host ==" +docker ps --format '{{.Names}}\t{{.Networks}}' | head -40 +echo "== infra-infra-net members ==" +docker network inspect infra-infra-net --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null +echo "== resolve nacos from inside userbase-app ==" +docker exec userbase-app sh -c "getent hosts nacos || echo 'no nacos in container DNS'" +echo "== curl nacos:8848 from inside userbase-app (no auth) ==" +docker exec userbase-app sh -c "curl -s -m 8 -o /tmp/nc.txt -w 'http=%{http_code}\n' 'http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=' ; head -c 200 /tmp/nc.txt; echo" || echo "curl failed" +echo "== done ==" diff --git a/deploy/redeploy_app.sh b/deploy/redeploy_app.sh new file mode 100644 index 0000000..e0d5944 --- /dev/null +++ b/deploy/redeploy_app.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# 重新部署 userbase-app:拉取新镜像、重建、等待健康,并核对是否真正从 Nacos 拉到了配置 +echo "== login ACR ==" +echo '59qtJRqlT7' | docker login --username=15121037482 --password-stdin registry.cn-hangzhou.aliyuncs.com + +cd /opt/docker/userbase +echo "== pull userbase-app ==" +docker compose -f docker-compose.userbase.yml pull userbase-app +echo "== up -d userbase-app ==" +docker compose -f docker-compose.userbase.yml up -d userbase-app + +echo "== wait for health (up to 150s) ==" +for i in $(seq 1 30); do + st=$(docker inspect -f '{{.State.Health.Status}}' userbase-app 2>/dev/null || echo unknown) + echo " [$i] health=$st" + if [ "$st" = "healthy" ]; then break; fi + sleep 5 +done + +echo "== nacos reachability (host) ==" +curl -s -m 5 -o /dev/null -w "host nacos http=%{http_code}\n" "http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=" || echo "host cannot reach nacos" + +echo "== nacos reachability (container) ==" +docker exec userbase-app sh -c "curl -s -m 5 -o /dev/null -w 'container nacos http=%{http_code}\n' 'http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant='" || echo "container cannot reach nacos" + +echo "== app logs: nacos / config / errors ==" +docker logs --tail 150 userbase-app 2>&1 | grep -iE "Loading nacos data|userbase.yaml|locate Nacos|NacosConfig|get config from nacos|ERROR|Exception|connect.*refused|UnknownHost|403|401|拒绝|超时" | tail -50 || true +echo "== done ==" diff --git a/deploy/remote_run.py b/deploy/remote_run.py new file mode 100644 index 0000000..b6e254e --- /dev/null +++ b/deploy/remote_run.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# 通过 SSH 在远端以 sudo 执行脚本(从 stdin 读取) +# 用法: python3 remote_run.py < script.sh +# 依赖环境变量 SSHPASS +import os, sys, paramiko + + +def main(): + host = sys.argv[1] + port = int(sys.argv[2]) if len(sys.argv) > 2 else 22 + user = sys.argv[3] if len(sys.argv) > 3 else "root" + pw = os.environ.get("SSHPASS") + if not pw: + sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n") + sys.exit(1) + script = sys.stdin.read() + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(host, port, user, password=pw, timeout=30) + stdin, stdout, stderr = c.exec_command("sudo -S bash -s", timeout=1800) + stdin.write(pw + "\n") + stdin.write(script) + stdin.flush() + stdin.channel.shutdown_write() + sys.stdout.write(stdout.read().decode(errors="replace")) + err = stderr.read().decode(errors="replace") + if err.strip(): + sys.stderr.write("STDERR:\n" + err) + code = stdout.channel.recv_exit_status() + c.close() + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/deploy/smoke.sh b/deploy/smoke.sh new file mode 100644 index 0000000..a911205 --- /dev/null +++ b/deploy/smoke.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# 线上冒烟:经 Caddy 访问 UserBase,确认 Nacos 配置生效、前后端可达 +echo "== SPA 首页 ==" +curl -s -m 8 -o /dev/null -w "/userbase/ -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/ +echo "== 前端静态资源 ==" +curl -s -m 8 -o /dev/null -w "/userbase/index.html -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/index.html +echo "== 后端 API 可达性(CAS 重定向即代表到达后端) ==" +curl -s -m 8 -o /dev/null -w "/userbase/api/x -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/api/x +echo "== 容器健康 ==" +docker inspect -f 'userbase-app health={{.State.Health.Status}}' userbase-app +echo "== 确认应用真正连的是 nacos 下发的 mysql8 数据源(日志里看 datasource url) ==" +docker logs --tail 200 userbase-app 2>&1 | grep -iE "jdbc:mysql://mysql8|HikariPool|Started UserBaseApplication" | tail -5 || true +echo "== done ==" diff --git a/deploy/upload.py b/deploy/upload.py new file mode 100644 index 0000000..80c0703 --- /dev/null +++ b/deploy/upload.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# 通过 SFTP 上传文件到远端(参数: host port user local remote [local remote ...]) +# 依赖环境变量 SSHPASS +import os, sys, paramiko + + +def main(): + host = sys.argv[1] + port = int(sys.argv[2]) if len(sys.argv) > 2 else 22 + user = sys.argv[3] if len(sys.argv) > 3 else "root" + pw = os.environ.get("SSHPASS") + if not pw: + sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n") + sys.exit(1) + t = paramiko.Transport((host, port)) + t.connect(username=user, password=pw) + sftp = paramiko.SFTPClient.from_transport(t) + args = sys.argv[4:] + for i in range(0, len(args), 2): + local, remote = args[i], args[i + 1] + cur = "" + for p in [p for p in os.path.dirname(remote).split("/") if p]: + cur += "/" + p + try: + sftp.stat(cur) + except IOError: + try: + sftp.mkdir(cur) + except IOError: + pass + sftp.put(local, remote) + print(f"PUT {local} -> {remote}") + t.close() + + +if __name__ == "__main__": + main() diff --git a/deploy/userbase.nacos.yaml b/deploy/userbase.nacos.yaml new file mode 100644 index 0000000..41698d5 --- /dev/null +++ b/deploy/userbase.nacos.yaml @@ -0,0 +1,104 @@ +# ============================================================ +# 请在 Nacos 控制台新增此配置(public 命名空间) +# Data ID : userbase.yaml +# Group : USERBASE +# Format : YAML +# 说明: +# - 本文件即应用自身配置。Nacos 解析规则为 ${spring.application.name}.${file-extension}, +# 因 bootstrap.yml 中 name=userbase、file-extension=yaml,故 Data ID 必须是 userbase.yaml。 +# - public 空间下的 common.properties 会作为共享配置优先加载(低优先级), +# 本文件(高优先级)中的同名字段会覆盖 common.properties。 +# - 若 common.properties 已包含 datasource / redis 等公共项,下面这些行可按需删减; +# 保留也无害(应用配置优先级更高,仍以本文件为准)。 +# - 生产启动时 profile=prod,Nacos 还会额外尝试 userbase-prod.yaml(可选,不存在也无妨)。 +# ============================================================ + +server: + port: 8060 + servlet: + # 对外经 caddy 暴露为 http://www.yunhaixingtong.com/userbase + context-path: /userbase + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + servlet: + multipart: + max-file-size: 50MB + max-request-size: 200MB + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + redis: + host: redis-server + port: 6379 + database: 0 + password: 'wv&WcDYAmLtqTIGM' + timeout: 2000ms + data: + redis: + repositories: + enabled: false + + datasource: + url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 'CVf^x$sW*b6CGHxh' + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + + jpa: + database: MySQL + show-sql: false + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + hibernate: + dialect: org.hibernate.dialect.MySQL5InnoDBDialect + open-in-view: true + + rabbitmq: + host: rabbitmq + port: 5672 + username: admin + password: 'JdU29I#Q2E4yXoUl' + listener: + direct: + auto-startup: false + simple: + auto-startup: false + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://www.yunhaixingtong.com/userbase + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务) + app-key: 100616 + app-secret: 1febb9502d2744b6a235d3e4cfde4a2d + # 受信应用(服务端验签用):appKey -> appSecret + trusted-apps: + 100616: 1febb9502d2744b6a235d3e4cfde4a2d + +ADMIN_TAG_ID: 290 + +# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data +FILE_PATH: /data/upload +FILE_WEB_PATH: http://www.yunhaixingtong.com/userbase/upload/ +ZIYUAN_WEB_PATH_HTTP: http://base.bestsch.com/ziyuanku/file/ +UPLOAD_WEB_PATH_HTTP: http://www.yunhaixingtong.com/userbase/upload/file/ +ORC_SERVICE_URL: http://oa.bestsch.com/OrcService/api/Ocr/Upload + +logging: + level: + root: INFO diff --git a/deploy/verify_db.sh b/deploy/verify_db.sh new file mode 100644 index 0000000..89711fa --- /dev/null +++ b/deploy/verify_db.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -e +echo "== app startup DDL/schema hints ==" +docker logs userbase-app 2>&1 | grep -iE "alter table|update schema|create table|ddl|column|sys_user" | head -30 || echo "(no ddl lines captured)" + +echo "== datasource config from container ==" +docker exec userbase-app cat /app/config/application-prod.yml 2>/dev/null | grep -iE "url:|username:|password:|jdbc" | sed -E 's/(password:).*/\1 ****/' || echo "(no config)" + +echo "== mysql client available? ==" +which mysql || echo "NO_MYSQL_CLI" diff --git a/deploy/verify_final.sh b/deploy/verify_final.sh new file mode 100644 index 0000000..9d8bd40 --- /dev/null +++ b/deploy/verify_final.sh @@ -0,0 +1,19 @@ +set -u +echo "===== 1. Main app JS chunk from SPA =====" +html=$(curl -s -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/) +appjs=$(echo "$html" | grep -oE '/static/js/[A-Za-z0-9_./-]+\.js' | head -1) +echo "app js: $appjs" +curl -s -o /dev/null -w " /userbase${appjs} -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1/userbase${appjs}" +echo +echo "===== 2. Ensure other sites not regressed (sso / b) =====" +curl -s -o /dev/null -w " sso host / -> %{http_code}\n" -H "Host: sso.yunhaixingtong.com" http://127.0.0.1/ +curl -s -o /dev/null -w " b host / -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/b/ +echo +echo "===== 3. Caddy listening ports on host =====" +ss -ltnp 2>/dev/null | grep -E ':80 |:443 ' | head +echo +echo "===== 4. Containers status =====" +docker ps --format '{{.Names}} {{.Status}}' | grep -E 'caddy|userbase' +echo +echo "===== 5. userbase-front health (serve index directly) =====" +docker exec userbase-front wget -qO- http://localhost/ 2>/dev/null | head -c 60; echo diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..19225e5 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +# UserBase 后端镜像(基于已构建好的 target/userbase.jar) +# 构建上下文 = service 目录: docker build -f docker/Dockerfile -t . +FROM eclipse-temurin:8-jre + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl tzdata \ + && ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && echo "Asia/Shanghai" > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY target/userbase.jar /app/userbase.jar +# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」; +# 正常启动由 run-app.sh 从 nacos 拉取线上配置(dataId=userbase-prod.yml)后覆盖。 +COPY docker/application-prod.yml /app/config/application-prod.yml +COPY docker/run-app.sh /app/run-app.sh +RUN chmod +x /app/run-app.sh && mkdir -p /data/upload + +EXPOSE 8060 +ENTRYPOINT ["/app/run-app.sh"] diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..6252aed --- /dev/null +++ b/docker/application-prod.yml @@ -0,0 +1,90 @@ +# UserBase 生产配置(线上以 nacos 的 userbase-prod.yml 为准,本文件是 nacos 不可达时的兜底) +server: + port: 8060 + servlet: + # 对外经 caddy 暴露为 http://www.yunhaixingtong.com/userbase + context-path: /userbase + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + servlet: + multipart: + max-file-size: 50MB + max-request-size: 200MB + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + redis: + host: redis-server + port: 6379 + database: 0 + password: 'wv&WcDYAmLtqTIGM' + timeout: 2000ms + data: + redis: + repositories: + enabled: false + + datasource: + url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 'CVf^x$sW*b6CGHxh' + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + + jpa: + database: MySQL + show-sql: false + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + hibernate: + dialect: org.hibernate.dialect.MySQL5InnoDBDialect + open-in-view: true + + rabbitmq: + host: rabbitmq + port: 5672 + username: admin + password: 'JdU29I#Q2E4yXoUl' + listener: + direct: + auto-startup: false + simple: + auto-startup: false + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://www.yunhaixingtong.com/userbase + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务) + app-key: 100616 + app-secret: 1febb9502d2744b6a235d3e4cfde4a2d + # 受信应用(服务端验签用):appKey -> appSecret + trusted-apps: + 100616: 1febb9502d2744b6a235d3e4cfde4a2d + +ADMIN_TAG_ID: 290 + +# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data +FILE_PATH: /data/upload +FILE_WEB_PATH: http://www.yunhaixingtong.com/userbase/upload/ +ZIYUAN_WEB_PATH_HTTP: http://base.bestsch.com/ziyuanku/file/ +UPLOAD_WEB_PATH_HTTP: http://www.yunhaixingtong.com/userbase/upload/file/ +ORC_SERVICE_URL: http://oa.bestsch.com/OrcService/api/Ocr/Upload + +logging: + level: + root: INFO diff --git a/docker/run-app.sh b/docker/run-app.sh new file mode 100644 index 0000000..163abd4 --- /dev/null +++ b/docker/run-app.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# UserBase 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) +# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到 +# 镜像内烤好的 application-prod.yml 兜底默认。 +set -e + +echo "[run-app] starting UserBase (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..." + +exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/userbase.jar \ + --spring.profiles.active=prod diff --git a/findlock2.py b/findlock2.py new file mode 100644 index 0000000..ac4010a --- /dev/null +++ b/findlock2.py @@ -0,0 +1,64 @@ +import ctypes +from ctypes import wintypes + +rstrtmgr = ctypes.windll.rstrtmgr + +class RM_UNIQUE_PROCESS(ctypes.Structure): + _fields_ = [("dwProcessId", wintypes.DWORD), + ("ProcessStartTime", wintypes.FILETIME)] + +class RM_PROCESS_INFO(ctypes.Structure): + _fields_ = [("Process", RM_UNIQUE_PROCESS), + ("strAppName", ctypes.c_wchar * 255), + ("strServiceShortName", ctypes.c_wchar * 63), + ("ApplicationType", ctypes.c_int), + ("AppStatus", ctypes.c_uint), + ("TSSessionId", wintypes.DWORD), + ("bRestartable", wintypes.BOOL)] + +RmStartSession = rstrtmgr.RmStartSession +RmStartSession.argtypes = [ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32, ctypes.c_wchar_p] +RmStartSession.restype = ctypes.c_uint32 + +RmRegisterResources = rstrtmgr.RmRegisterResources +RmRegisterResources.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p, + ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p), + ctypes.c_uint32, ctypes.c_wchar_p] +RmRegisterResources.restype = ctypes.c_uint32 + +RmGetList = rstrtmgr.RmGetList +RmGetList.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), + ctypes.POINTER(RM_PROCESS_INFO), ctypes.POINTER(ctypes.c_uint32)] +RmGetList.restype = ctypes.c_uint32 + +RmEndSession = rstrtmgr.RmEndSession +RmEndSession.argtypes = [ctypes.c_uint32] +RmEndSession.restype = ctypes.c_uint32 + +session = ctypes.c_uint32(0) +desc = ctypes.create_unicode_buffer("diag") +rc = RmStartSession(ctypes.byref(session), 0, desc) +if rc != 0: + print("RmStartSession failed", rc) + raise SystemExit + +path = ctypes.create_unicode_buffer(r"C:\ub-build\userbase\classes\application.yml") +rc = RmRegisterResources(session, 1, path, 0, None, 0, None) +print("RmRegisterResources rc=", rc) + +nNeeded = ctypes.c_uint32(0) +nActual = ctypes.c_uint32(0) +reason = ctypes.c_uint32(0) +rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), None, ctypes.byref(reason)) +print("first RmGetList rc=", rc, "needed=", nNeeded.value) +if nNeeded.value > 0: + arr = (RM_PROCESS_INFO * nNeeded.value)() + nActual = ctypes.c_uint32(nNeeded.value) + rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), arr, ctypes.byref(reason)) + print("second RmGetList rc=", rc, "actual=", nActual.value) + for i in range(nActual.value): + p = arr[i] + print("HOLDER PID=", p.Process.dwProcessId, "app=", p.strAppName) +else: + print("NO PROCESS HOLDER (likely ACL/permission, not a lock)") +RmEndSession(session) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bd1a429 --- /dev/null +++ b/pom.xml @@ -0,0 +1,248 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + yunhaibase + 0.0.1 + UserBase + 用户中心 + + 1.8 + 5.8.36 + 3.1.2 + + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + 2021.0.5.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 3.1.8 + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-cache + + + + + org.springframework + spring-context-support + + + + + com.github.ben-manes.caffeine + caffeine + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.redisson + redisson + 3.13.6 + + + + com.alibaba + fastjson + 1.2.80 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + + cn.hutool + hutool-core + ${cn.hutool.version} + + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + + + org.apache.commons + commons-lang3 + + + cn.afterturn + easypoi-base + 4.1.0 + + + + + com.yunhai.platform + yh-auth-spring-boot-starter + + + com.yunhai.platform + yh-app-auth-spring-boot-starter + + + + com.yunhai.platform + yh-api-std + + + + + + + + userbase + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.userbase.UserBaseApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/ub-build2/userbase + + + + + diff --git a/scripts/deploy-via-docker.js b/scripts/deploy-via-docker.js new file mode 100644 index 0000000..af308de --- /dev/null +++ b/scripts/deploy-via-docker.js @@ -0,0 +1,268 @@ +#!/usr/bin/env node +// Deploy front/dist/prod to host /data/www/userbase/front using the Docker daemon +// that act_runner already mounts into the job container (/var/run/docker.sock). +// +// Why this script exists: +// act_runner (v0.6.1) ignores both `container.options` extra --volume and +// workflow `container.volumes` in this setup, so the host static dir can't be +// bind-mounted directly into the job container. But the host docker.sock IS +// mounted by default, so we use the Docker HTTP API to: +// 1. tar the built assets straight out of THIS container's filesystem +// (GET /containers/{jobId}/archive?path=...), and +// 2. upload that tar into a throwaway helper container that has +// /data/www/userbase mounted RW (PUT /containers/{helperId}/archive), +// then `mv` the extracted folder so files land at the correct top level. +// +// Diagnostics: every step is collected and written to /data/www/userbase/_debug.txt +// on the HOST via `docker exec` into the helper (the job container itself cannot +// write there). The Gitea logs API is unavailable here, so this file is our only +// reliable window into what happened. +// +// No docker CLI and no network egress required — we speak the Docker HTTP API +// over the unix socket directly with Node's http module. + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const SOCKET = '/var/run/docker.sock'; +const HOST_STATIC = '/data/www/userbase'; +const HELPER_IMAGE = 'node:20'; // already cached on host (used by the job) +const STAGING = HOST_STATIC + '/_staging'; + +// --- low level docker http helpers ------------------------------------------- + +function dockerJson(method, p, bodyObj) { + return new Promise((resolve, reject) => { + const data = bodyObj ? JSON.stringify(bodyObj) : null; + const headers = {}; + if (data) { + headers['Content-Type'] = 'application/json'; + headers['Content-Length'] = Buffer.byteLength(data); + } + const req = http.request( + { socketPath: SOCKET, path: p, method, headers }, + (res) => { + let buf = ''; + res.on('data', (c) => (buf += c)); + res.on('end', () => resolve({ status: res.statusCode, body: buf })); + } + ); + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); +} + +function getArchive(id, p) { + return new Promise((resolve, reject) => { + const req = http.request( + { + socketPath: SOCKET, + path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`, + method: 'GET', + }, + (res) => resolve(res) + ); + req.on('error', reject); + req.end(); + }); +} + +function putArchive(id, p, tarStream) { + return new Promise((resolve, reject) => { + const req = http.request( + { + socketPath: SOCKET, + path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`, + method: 'PUT', + headers: { 'Content-Type': 'application/x-tar' }, + }, + (res) => { + let buf = ''; + res.on('data', (c) => (buf += c)); + res.on('end', () => resolve({ status: res.statusCode, body: buf })); + } + ); + req.on('error', reject); + tarStream.pipe(req); + }); +} + +// Run a command inside the helper and return its stdout/stderr (Detach:false so +// the call resolves only after the command finishes — reliable for sequencing). +async function runExec(id, cmd) { + const created = await dockerJson('POST', `/containers/${id}/exec`, { + Cmd: ['sh', '-c', cmd], + AttachStdout: true, + AttachStderr: true, + }); + if (created.status >= 400) { + throw new Error('exec create failed: ' + created.status + ' ' + created.body); + } + const execId = JSON.parse(created.body).Id; + const started = await dockerJson('POST', `/exec/${execId}/start`, { + Detach: false, + Tty: false, + }); + return started; // { status, body(stdout+stderr) } +} + +// --- helper container + diagnostics ------------------------------------------ + +function getContainerId() { + const cg = fs.readFileSync('/proc/self/cgroup', 'utf8'); + const m = cg.match(/[0-9a-f]{64}/); + if (!m) { + const h = fs.readFileSync('/etc/hostname', 'utf8').trim(); + return h; + } + return m[0]; +} + +let HELPER_ID = null; +let DEBUG_BUF = []; + +function dbg(line) { + const ts = new Date().toISOString(); + DEBUG_BUF.push(`[${ts}] ${line}`); + console.log(line); // also emit to job log (best-effort) +} + +// Write the accumulated debug buffer to the HOST file via a base64 pipe. Passing +// the payload inline (single-quoted) avoids env-var/newline fragility that broke +// the earlier diagnostics channel. Uses Detach:false so we know it completed. +async function flushDebug() { + if (!HELPER_ID) { + console.error('flushDebug: no helper, cannot write host debug'); + return; + } + const content = DEBUG_BUF.join('\n') + '\n'; + const b64 = Buffer.from(content, 'utf8').toString('base64'); + const cmd = `echo ${b64} | base64 -d > ${HOST_STATIC}/_debug.txt`; + try { + const r = await runExec(HELPER_ID, cmd); + if (r.status >= 400) console.error('flushDebug exec failed:', r.status, r.body); + else dbg('debug written to host (' + content.length + ' bytes)'); + } catch (e) { + console.error('flushDebug error:', e.message); + } +} + +// --- main -------------------------------------------------------------------- + +function resolveDistPath() { + const candidates = [ + path.join(process.cwd(), 'front', 'dist', 'prod'), + '/workspace/admin/userbase/front/dist/prod', + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return null; +} + +async function main() { + dbg('START script version=2026-08-04c'); + dbg('cwd=' + process.cwd()); + + // 1) helper container with the host static dir mounted RW; keep it alive. + const createBody = { + Image: HELPER_IMAGE, + HostConfig: { NetworkMode: 'none', Binds: [`${HOST_STATIC}:${HOST_STATIC}`] }, + Cmd: ['sh', '-c', `mkdir -p ${STAGING} && exec tail -f /dev/null`], + }; + const created = await dockerJson('POST', '/containers/create', createBody); + if (created.status >= 400) { + throw new Error('create helper failed: ' + created.status + ' ' + created.body); + } + HELPER_ID = JSON.parse(created.body).Id; + dbg('helper created id=' + HELPER_ID); + + const started = await dockerJson('POST', `/containers/${HELPER_ID}/start`); + if (started.status >= 400) { + throw new Error('start helper failed: ' + started.status + ' ' + started.body); + } + dbg('helper started'); + // clean any stale staging from a previous failed run + await runExec(HELPER_ID, `rm -rf ${STAGING} && mkdir -p ${STAGING}`); + + // 2) locate the built assets inside THIS container. + const jobId = getContainerId(); + dbg('job container id=' + jobId); + + const distDir = resolveDistPath(); + if (!distDir) { + dbg('dist dir NOT found; cwd listing:'); + try { + dbg(fs.readdirSync(process.cwd()).join(' ')); + const front = path.join(process.cwd(), 'front'); + if (fs.existsSync(front)) dbg('front listing: ' + fs.readdirSync(front).join(' ')); + } catch (e) { + dbg('ls err: ' + e.message); + } + throw new Error('built dist/prod not found under any candidate path'); + } + dbg('dist dir=' + distDir); + dbg('dist listing: ' + fs.readdirSync(distDir).join(' ')); + + // 3) tar of the built assets, fetched from THIS container's own filesystem. + // We fetch WITHOUT a trailing slash so the tar contains a top-level "prod/" + // folder; we then mv it into place (deterministic, no reliance on the + // trailing-slash "contents only" quirk which this moby build ignores). + const tar = await getArchive(jobId, distDir); + if (tar.statusCode !== 200) { + let msg = ''; + tar.on('data', (c) => (msg += c)); + await new Promise((r) => tar.on('end', r)); + throw new Error('get archive failed (status ' + tar.statusCode + '): ' + msg); + } + dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')'); + + // 4) upload the tar into the staging dir of the helper's mounted host dir. + const put = await putArchive(HELPER_ID, STAGING, tar); + dbg('put archive status=' + put.status + (put.status >= 400 ? ' ' + put.body : '')); + if (put.status >= 400) { + throw new Error('put archive failed: ' + put.status + ' ' + put.body); + } + + // 5) move staging/prod -> HOST_STATIC/front (deterministic top-level landing). + const mv = await runExec( + HELPER_ID, + `rm -rf ${HOST_STATIC}/front && mv ${STAGING}/prod ${HOST_STATIC}/front && rm -rf ${STAGING}` + ); + dbg('mv status=' + mv.status + ' out=' + mv.body.replace(/\n/g, ' | ')); + + // 6) verify on host via exec into helper. + const verify = await runExec( + HELPER_ID, + `echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ` + + `echo "has_stray_prod=$(test -d ${HOST_STATIC}/front/prod && echo YES || echo NO)"; ` + + `ls -la ${HOST_STATIC}/front | head -20` + ); + dbg('verify output: ' + verify.body.replace(/\n/g, ' | ')); + + dbg('DEPLOY OK -> ' + HOST_STATIC + '/front (top-level, no prod/ nesting)'); +} + +main() + .then(async () => { + // On success, keep diagnostics only briefly, then remove the debug file so + // it is not left web-served at /userbase/_debug.txt. Failures keep it for + // post-mortem (see .catch below). + await flushDebug(); + try { + if (HELPER_ID) await runExec(HELPER_ID, `rm -f ${HOST_STATIC}/_debug.txt`); + } catch (e) { + console.error('cleanup debug file failed:', e.message); + } + if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`); + process.exit(0); + }) + .catch(async (err) => { + dbg('DEPLOY FAILED: ' + err.stack); + await flushDebug(); // keep debug file on failure for post-mortem + if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`); + console.error('DEPLOY FAILED:', err.message); + process.exit(1); + }); diff --git a/src/main/java/com/bestsch/openapi/UserSysRole.java b/src/main/java/com/bestsch/openapi/UserSysRole.java new file mode 100644 index 0000000..ff983b2 --- /dev/null +++ b/src/main/java/com/bestsch/openapi/UserSysRole.java @@ -0,0 +1,37 @@ +package com.bestsch.openapi; + +/** + * 迁移兼容 shim:原 bestsch 开放接口的"当前角色"载体。 + * 仅保留 {@code Operator.of(...)} 所需的访问器,避免批量改动业务代码。 + * 后续可替换为 UserBase 自有角色模型。 + */ +public class UserSysRole { + + private Integer userId; + private Integer schId; + private Integer roleId; + + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } + + public Integer getSchId() { + return schId; + } + + public void setSchId(Integer schId) { + this.schId = schId; + } + + public Integer getRoleId() { + return roleId; + } + + public void setRoleId(Integer roleId) { + this.roleId = roleId; + } +} diff --git a/src/main/java/com/bestsch/utils/DException.java b/src/main/java/com/bestsch/utils/DException.java new file mode 100644 index 0000000..a1ed5e9 --- /dev/null +++ b/src/main/java/com/bestsch/utils/DException.java @@ -0,0 +1,22 @@ +package com.bestsch.utils; + +/** + * 迁移兼容 shim:原 bestsch 框架的业务异常类。 + * UserBase 大量业务代码依赖此异常,迁移到基础平台期间保留原包名与常用构造签名,避免批量改动。 + * 后续可统一替换为 UserBase 自有异常类型。 + */ +public class DException extends RuntimeException { + + public DException(String message) { + super(message); + } + + public DException(String message, Throwable cause) { + super(message, cause); + } + + /** 兼容历史调用方携带错误码的场景 */ + public DException(String message, int code) { + super(message); + } +} diff --git a/src/main/java/com/yunhai/userbase/UserBaseApplication.java b/src/main/java/com/yunhai/userbase/UserBaseApplication.java new file mode 100644 index 0000000..34cc402 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/UserBaseApplication.java @@ -0,0 +1,22 @@ +package com.yunhai.userbase; + +import com.alibaba.fastjson.parser.ParserConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@SpringBootApplication +@EnableTransactionManagement +@EnableAsync +@EnableScheduling +public class UserBaseApplication { + + public static void main(String[] args) { + // 开启安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(UserBaseApplication.class, args); + } + +} diff --git a/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java b/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java new file mode 100644 index 0000000..980604e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java @@ -0,0 +1,204 @@ +package com.yunhai.userbase.aspect; + +import com.alibaba.fastjson.JSON; +import com.yunhai.userbase.common.annotation.OperationLog; +import com.yunhai.userbase.common.context.UserContext; +import com.yunhai.userbase.entity.SysOperationLog; +import com.yunhai.userbase.service.ISysOperationLogService; +import com.yunhai.userbase.utils.GzipUtil; +import com.yunhai.userbase.utils.IpUtils; +import com.yunhai.userbase.utils.OperationLogResolver; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * 操作日志切面。
+ * 拦截所有 Controller 方法,按如下规则自动采集并落库:
+ * - GET 请求:自动记录为「一般(查询)」,无需任何注解;
+ * - 非 GET 请求:仅当方法标注 @OperationLog 时才记录(沿用原注解式行为);
+ * - 排除日志查看接口自身(SysOperationLogController),避免递归与刷屏。
+ * 响应结果 GZIP 压缩 + 截断后,在 finally 中异步落库,失败不影响主业务。
+ * + * @author chenx + * @date 2026/7/28 + */ +@Slf4j +@Aspect +@Component +public class OperationLogAspect { + + /** 不记录日志查询/下载接口自身,避免递归与刷屏。 */ + private static final String LOG_CONTROLLER = "com.yunhai.userbase.controller.api.SysOperationLogController"; + + /** GET 自动记录时使用的默认响应保存上限(10KB)。 */ + private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240; + + @Autowired + private ISysOperationLogService sysOperationLogService; + + @Around("execution(* com.yunhai.userbase.controller..*.*(..))") + public Object around(ProceedingJoinPoint point) throws Throwable { + // 排除日志查看接口自身 + if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) { + return point.proceed(); + } + + HttpServletRequest request = getRequest(); + String httpMethod = request != null ? request.getMethod() : ""; + boolean isGet = "GET".equalsIgnoreCase(httpMethod); + + // 解析方法上的 @OperationLog 注解(可能为 null) + OperationLog operationLog = findAnnotation(point); + + // 决策:GET 自动记录;非 GET 仅在有注解时记录 + if (!isGet && operationLog == null) { + return point.proceed(); + } + + long start = System.currentTimeMillis(); + SysOperationLog entity = buildEntity(point, request, httpMethod); + + // GET 未标注注解时使用默认参数;标注了则优先用注解配置 + boolean saveResponse = operationLog != null ? operationLog.saveResponse() : true; + int maxResponseSize = operationLog != null ? operationLog.maxResponseSize() : DEFAULT_MAX_RESPONSE_SIZE; + + Object result = null; + try { + result = point.proceed(); + entity.setStatusCode(200); + fillResponse(entity, result, saveResponse, maxResponseSize); + } catch (Throwable e) { + entity.setStatusCode(500); + entity.setErrorMsg(truncate(e.getMessage(), 2000)); + throw e; + } finally { + entity.setCostTime(System.currentTimeMillis() - start); + sysOperationLogService.saveAsync(entity); + } + return result; + } + + /** + * 从连接点解析方法上的 @OperationLog 注解。 + */ + private OperationLog findAnnotation(ProceedingJoinPoint point) { + try { + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + return method.getAnnotation(OperationLog.class); + } catch (Exception e) { + return null; + } + } + + /** + * 组装日志实体(基础字段 + 级别/类型判定)。 + */ + private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) { + SysOperationLog entity = new SysOperationLog(); + + Integer userId = UserContext.getCurrentUser(); + entity.setUserId(userId != null ? userId.longValue() : 0L); + String username = request != null ? request.getRemoteUser() : null; + entity.setUsername(StringUtils.isNotBlank(username) ? username : "anonymous"); + + String requestUri = ""; + if (request != null) { + entity.setRequestIp(IpUtils.getIpAddr(request)); + requestUri = request.getRequestURI(); + entity.setUserAgent(request.getHeader("User-Agent")); + entity.setRequestParams(buildRequestParams(point, request)); + } else { + entity.setRequestParams(buildRequestParams(point, null)); + } + entity.setRequestUri(requestUri); + entity.setRequestMethod(httpMethod); + + String methodName = point.getSignature().getName(); + entity.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, requestUri, methodName).getCode()); + entity.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, requestUri, methodName)); + entity.setClassMethod(point.getTarget().getClass().getName() + "." + methodName); + return entity; + } + + /** + * 构建请求参数。
+ * GET 优先使用 queryString;其余序列化方法入参,过滤掉请求/响应等不可序列化对象。
+ */ + private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) { + try { + if (request != null && "GET".equalsIgnoreCase(request.getMethod())) { + String qs = request.getQueryString(); + if (StringUtils.isNotBlank(qs)) { + return qs; + } + } + Object[] args = point.getArgs(); + if (args == null || args.length == 0) { + return ""; + } + List filtered = new ArrayList(); + for (Object arg : args) { + if (arg == null) { + continue; + } + if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse + || arg instanceof HttpSession) { + continue; + } + filtered.add(arg); + } + if (filtered.isEmpty()) { + return ""; + } + return JSON.toJSONString(filtered); + } catch (Exception e) { + return ""; + } + } + + /** + * 写入响应结果(GZIP 压缩 + 截断)。 + */ + private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse, int maxResponseSize) { + if (!saveResponse || result == null) { + return; + } + try { + String resp = JSON.toJSONString(result); + if (resp.length() > maxResponseSize) { + resp = resp.substring(0, maxResponseSize) + "...[截断]"; + } + entity.setResponseData(GzipUtil.compress(resp)); + } catch (Exception e) { + log.warn("操作日志响应结果序列化失败, uri={}", entity.getRequestUri(), e); + } + } + + private HttpServletRequest getRequest() { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attrs != null ? attrs.getRequest() : null; + } + + private String truncate(String text, int maxLen) { + if (text == null) { + return null; + } + return text.length() > maxLen ? text.substring(0, maxLen) : text; + } +} diff --git a/src/main/java/com/yunhai/userbase/common/GlobalExceptionHandler.java b/src/main/java/com/yunhai/userbase/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..32b2632 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/GlobalExceptionHandler.java @@ -0,0 +1,72 @@ +package com.yunhai.userbase.common; + +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 全局异常处理器:仅处理「参数校验」类异常,把 Spring 默认抛出的冗长消息收敛成「一句话」返回, + * 避免前端收到整段 Validation failed ... 堆栈文本。
+ * 返回 HTTP 200 + {@code {code:4, msg:..., data:null}} 结构(而非 HTTP 400), + * 与前端 alova errorHandle 的约定一致:status!=200 会被当作网络错误,code=4 才走「参数错误」分支。
+ * 注意:本处理器【不】处理业务异常(如 {@code DException}),业务异常交由项目框架统一处理 + * (返回 code=5、msg=异常消息),此处若拦截会覆盖框架行为。 + * + * @author chenx + */ +@RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +public class GlobalExceptionHandler { + + /** 参数校验失败的业务码,前端按「参数错误」处理 */ + private static final int CODE_PARAM_ERROR = 4; + + /** @RequestBody + @Validated/@Valid 失败:提取每个字段的 defaultMessage。 */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public Map handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { + return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors())); + } + + /** 普通 Bean 参数(如 SortChildren/Move 的 @Validated 请求体对象)校验失败。 */ + @ExceptionHandler(BindException.class) + public Map handleBind(BindException ex) { + return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors())); + } + + /** 方法级约束(@PathVariable/@RequestParam 上的 @NotNull 等)校验失败。 */ + @ExceptionHandler(ConstraintViolationException.class) + public Map handleConstraintViolation(ConstraintViolationException ex) { + String msg = ex.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining(";")); + return paramError(msg); + } + + private String fieldErrorsMessage(java.util.List fieldErrors) { + if (fieldErrors == null || fieldErrors.isEmpty()) { + return "参数校验失败"; + } + return fieldErrors.stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(";")); + } + + /** HTTP 200 + code/msg/data 结构体,前端根据 code 提示 msg */ + private Map paramError(String msg) { + Map body = new LinkedHashMap<>(); + body.put("code", CODE_PARAM_ERROR); + body.put("msg", msg); + body.put("data", null); + return body; + } +} diff --git a/src/main/java/com/yunhai/userbase/common/annotation/OperationLog.java b/src/main/java/com/yunhai/userbase/common/annotation/OperationLog.java new file mode 100644 index 0000000..db7b8d4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/annotation/OperationLog.java @@ -0,0 +1,35 @@ +package com.yunhai.userbase.common.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 操作日志注解。
+ * 标注在 Controller 方法上,AOP 拦截后自动记录操作日志并异步落库。
+ * + * @author chenx + * @date 2026/7/28 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface OperationLog { + + /** + * 操作描述,可选。 + */ + String value() default ""; + + /** + * 是否保存响应结果(GZIP 压缩后入库)。 + */ + boolean saveResponse() default true; + + /** + * 响应结果序列化后的最大字符数,超过则截断。默认 10KB。 + */ + int maxResponseSize() default 10240; +} diff --git a/src/main/java/com/yunhai/userbase/common/context/UserContext.java b/src/main/java/com/yunhai/userbase/common/context/UserContext.java new file mode 100644 index 0000000..1cca890 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/context/UserContext.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.common.context; + +public class UserContext { + + private static final ThreadLocal CURRENT_USER = new ThreadLocal<>(); + + public static void setCurrentUser(Integer userId) { + CURRENT_USER.set(userId); + } + + public static Integer getCurrentUser() { + return CURRENT_USER.get(); + } + + public static void clear() { + CURRENT_USER.remove(); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/common/converter/ClientEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/ClientEnumConverter.java new file mode 100644 index 0000000..e7a51c2 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/ClientEnumConverter.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.common.converter; + +import com.yunhai.userbase.enums.ClientEnum; + +import javax.persistence.AttributeConverter; + +/** + * ClientEnum 专用 JPA 转换器(int <-> 枚举)。
+ * 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。 + * + * @author chenx + */ +public class ClientEnumConverter extends CommonIntEnumConverter { + + public ClientEnumConverter() { + super(ClientEnum.class); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/converter/CommonIntEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/CommonIntEnumConverter.java new file mode 100644 index 0000000..33c8dc6 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/CommonIntEnumConverter.java @@ -0,0 +1,47 @@ +package com.yunhai.userbase.common.converter; + +import com.yunhai.userbase.enums.IEnum; + +import javax.persistence.AttributeConverter; + +/** + * 通用「int <-> 实现 IEnum<Integer> 的枚举」JPA 转换器抽象基类。
+ *

+ * 绑定机制(通用做法):为每个枚举写一个极薄的子类,在构造器里传入枚举 class 即可完成绑定, + * 然后实体字段用 {@code @Convert(converter = XxxEnumConverter.class)} 引用该子类。例如: + *

+ *   public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
+ *       public GroupTypeEnumConverter() { super(GroupTypeEnum.class); }
+ *   }
+ * 
+ * 这样同一套逻辑可服务任意枚举;又因为每个枚举拥有独立的转换器实例, + * 彼此之间不会串类型(标准 JPA 的 @Convert 是按 converter 类创建共享单例的, + * 若让多个不同枚举共用同一个泛型实例,则无法区分各自的枚举 class,故采用「每枚举一子类」的绑定方式)。 + *

+ * + * @param 必须实现 IEnum<Integer> 的枚举类型 + * @author chenx + */ +public abstract class CommonIntEnumConverter & IEnum> + implements AttributeConverter { + + private final Class enumCls; + + protected CommonIntEnumConverter(Class enumCls) { + this.enumCls = enumCls; + } + + @Override + public Integer convertToDatabaseColumn(E enumVal) { + return enumVal == null ? null : enumVal.getKey(); + } + + @Override + public E convertToEntityAttribute(Integer dbVal) { + if (dbVal == null) { + return null; + } + // 读库安全:无匹配的枚举值返回 null(不抛异常,避免脏数据导致整棵树加载失败) + return IEnum.getByKey(enumCls, dbVal); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/converter/GenderEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/GenderEnumConverter.java new file mode 100644 index 0000000..40cd93e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/GenderEnumConverter.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.common.converter; + +import com.yunhai.userbase.enums.GenderEnum; + +import javax.persistence.AttributeConverter; + +/** + * GenderEnum 专用 JPA 转换器(int <-> 枚举)。
+ * 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。 + * + * @author chenx + */ +public class GenderEnumConverter extends CommonIntEnumConverter { + + public GenderEnumConverter() { + super(GenderEnum.class); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/converter/GroupTypeEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/GroupTypeEnumConverter.java new file mode 100644 index 0000000..e604aff --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/GroupTypeEnumConverter.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.common.converter; + +import com.yunhai.userbase.enums.GroupTypeEnum; + +import javax.persistence.AttributeConverter; + +/** + * GroupTypeEnum 专用 JPA 转换器(int <-> 枚举)。
+ * 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可,无需重复转换逻辑。 + * + * @author chenx + */ +public class GroupTypeEnumConverter extends CommonIntEnumConverter { + + public GroupTypeEnumConverter() { + super(GroupTypeEnum.class); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/converter/IntegEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/IntegEnumConverter.java new file mode 100644 index 0000000..3be7835 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/IntegEnumConverter.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.common.converter; + +import com.yunhai.userbase.enums.IntegEnum; + +import javax.persistence.AttributeConverter; + +/** + * IntegEnum 专用 JPA 转换器(int <-> 枚举)。
+ * 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。 + * + * @author chenx + */ +public class IntegEnumConverter extends CommonIntEnumConverter { + + public IntegEnumConverter() { + super(IntegEnum.class); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/converter/ListIntegerJsonConverter.java b/src/main/java/com/yunhai/userbase/common/converter/ListIntegerJsonConverter.java new file mode 100644 index 0000000..ccef7bc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/ListIntegerJsonConverter.java @@ -0,0 +1,50 @@ +package com.yunhai.userbase.common.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.persistence.AttributeConverter; +import javax.persistence.Converter; +import java.util.Collections; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/14 + * @description 将 List 与 MySQL JSON 字符串互相转换。 + * 使用 Jackson 的 {@link TypeReference} 明确反序列化为 List, + * 避免 Hibernate 内置 JSON 类型把数组元素类型读错的问题。 + */ +@Converter +public class ListIntegerJsonConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> LIST_INTEGER_TYPE = new TypeReference>() { + }; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return null; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("角色列表序列化 JSON 失败: " + attribute, e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.trim().isEmpty()) { + return null; + } + try { + List list = MAPPER.readValue(dbData, LIST_INTEGER_TYPE); + return list == null ? Collections.emptyList() : list; + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("角色 JSON 解析失败: " + dbData, e); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/common/converter/StatusEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/StatusEnumConverter.java new file mode 100644 index 0000000..4e16f87 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/StatusEnumConverter.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.common.converter; + +import com.yunhai.userbase.enums.StatusEnum; + +import javax.persistence.AttributeConverter; + +/** + * StatusEnum 专用 JPA 转换器(int <-> 枚举)。
+ * 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。 + * + * @author chenx + */ +public class StatusEnumConverter extends CommonIntEnumConverter { + + public StatusEnumConverter() { + super(StatusEnum.class); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/converter/SysRoleEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/SysRoleEnumConverter.java new file mode 100644 index 0000000..f4c1bed --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/SysRoleEnumConverter.java @@ -0,0 +1,20 @@ +package com.yunhai.userbase.common.converter; + +import com.yunhai.userbase.enums.SysRoleEnum; + +import javax.persistence.AttributeConverter; + +/** + * SysRoleEnum 专用 JPA 转换器(int <-> 枚举)。
+ * 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
+ * 注意:sys_user.roles 是「角色 key 的 JSON 列表」,使用 ListIntegerJsonConverter, + * 若某处需要「单角色」字段,再引用本转换器即可。 + * + * @author chenx + */ +public class SysRoleEnumConverter extends CommonIntEnumConverter { + + public SysRoleEnumConverter() { + super(SysRoleEnum.class); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/converter/UserTypeEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/UserTypeEnumConverter.java new file mode 100644 index 0000000..6c9743a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/converter/UserTypeEnumConverter.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.common.converter; + +import com.yunhai.userbase.enums.UserTypeEnum; + +import javax.persistence.AttributeConverter; + +/** + * UserTypeEnum 专用 JPA 转换器(int <-> 枚举)。
+ * 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。 + * + * @author chenx + */ +public class UserTypeEnumConverter extends CommonIntEnumConverter { + + public UserTypeEnumConverter() { + super(UserTypeEnum.class); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java new file mode 100644 index 0000000..dc2beb4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java @@ -0,0 +1,48 @@ +package com.yunhai.userbase.common.interceptor; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.common.context.UserContext; +import com.yunhai.userbase.service.UserCenterService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * 把当前登录用户写入 {@link UserContext}(供 JPA 审计监听器 @PrePersist/@PreUpdate 与操作日志切面使用)。

+ * 登录名取自 CAS 客户端写入的 {@code request.getRemoteUser()}(cas-client-core 过滤器已建立会话); + * 再经本地 {@link UserCenterService} 组装 {@link BoaUser} 取其 id 写入 ThreadLocal。 + * 原 bsch 的 {@code BschBaseOpenApi.getUserInfoWithDept} 与 {@code LocalMemCache} 已移除, + * 统一改用基础平台用户模型与本地服务中心。 + */ +@Component +public class BoaUserContextInterceptor implements HandlerInterceptor { + + @Autowired + private UserCenterService userCenterService; + + @Override + public boolean preHandle(HttpServletRequest request, + HttpServletResponse response, + Object handler) { + String loginName = request.getRemoteUser(); + if (StringUtils.isBlank(loginName)) { + return true; + } + BoaUser user = userCenterService.getUserInfoWithDeptByAccount(loginName); + if (user != null && user.getId() != null && user.getId() != 0) { + UserContext.setCurrentUser(user.getId()); + } + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, + HttpServletResponse response, + Object handler, Exception ex) { + UserContext.clear(); + } +} diff --git a/src/main/java/com/yunhai/userbase/common/listener/UserAuditListener.java b/src/main/java/com/yunhai/userbase/common/listener/UserAuditListener.java new file mode 100644 index 0000000..8557f33 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/listener/UserAuditListener.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.common.listener; + +import com.bestsch.utils.DException; +import com.yunhai.userbase.common.context.UserContext; +import com.yunhai.userbase.entity.BaseEntity; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; + +public class UserAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + System.out.println("=== prePersist 触发 ==="); + Integer userId = UserContext.getCurrentUser(); + System.out.println("ThreadLocal userId: " + userId); + if (userId != null) { + entity.setCreateUser(userId); + entity.setUpdateUser(userId); + } + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + System.out.println("=== preUpdate 触发 ==="); + Integer userId = UserContext.getCurrentUser(); + System.out.println("ThreadLocal userId: " + userId); + + if (userId != null) { + entity.setUpdateUser(userId); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/userbase/common/validation/ValidationGroups.java new file mode 100644 index 0000000..3ead345 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/validation/ValidationGroups.java @@ -0,0 +1,11 @@ +package com.yunhai.userbase.common.validation; + +/** + * @author chenx + * @date 2026/7/15 22:11 + * @description + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java new file mode 100644 index 0000000..738087f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java @@ -0,0 +1,23 @@ +package com.yunhai.userbase.config; + +import com.yunhai.platform.appauth.AppAuthProperties; +import com.yunhai.platform.appauth.AppSecretProvider; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 用户中心验签密钥源:UserBase 作为密钥权威源,优先从本库 application_info 解析调用方密钥 + * (覆盖全部「已颁发」应用),并以配置式 {@code yh.app-auth.trustedApps} 兜底平台自身默认密钥。 + * 显式覆盖 yh-app-auth 默认的 {@code @ConditionalOnMissingBean} 实现,使开放接口签名校验 + * 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。 + */ +@Configuration +public class AppSecretConfig { + + @Bean + public AppSecretProvider appSecretProvider(ApplicationInfoRepository repository, + AppAuthProperties properties) { + return new UserBaseAppSecretProvider(repository, properties); + } +} diff --git a/src/main/java/com/yunhai/userbase/config/CacheConfig.java b/src/main/java/com/yunhai/userbase/config/CacheConfig.java new file mode 100644 index 0000000..4399a87 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/CacheConfig.java @@ -0,0 +1,183 @@ +package com.yunhai.userbase.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.yunhai.userbase.utils.ConstantUtil; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Collection; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@EnableCaching +@Configuration +public class CacheConfig { + + + /** + * 一级缓存:Redis 分布式缓存管理器。 + */ + @Bean + public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) { + Jackson2JsonRedisSerializer jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class); + ObjectMapper mapper = new ObjectMapper(); + // 允许任意字段可见,保证反序列化不依赖 getter/setter + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + // 记录完整类型信息,保证泛型/多态对象反序列化正常(解决 JDK 序列化乱码、泛型丢失) + mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); + jacksonSerializer.setObjectMapper(mapper); + + RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(ConstantUtil.CACHE_TTL) // 全局 5 分钟过期 + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer())) // key 用 String + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(jacksonSerializer)) // value 用 Jackson + .disableCachingNullValues(); // 禁止缓存 null(防穿透) + + return RedisCacheManager.builder(factory) + .cacheDefaults(cacheConfig) + .build(); + } + + /** + * 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。 + */ + @Bean + public CaffeineCacheManager caffeineCacheManager() { + CaffeineCacheManager manager = new CaffeineCacheManager(); + manager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 过期保持一致 + .maximumSize(10000) // 限制本地容量,防止内存溢出 + .recordStats()); + manager.setAllowNullValues(false); // 与 Redis 一致:禁止缓存 null + return manager; + } + + /** + * 默认 RedisTemplate(Jackson 序列化),供项目已有工具类(如 RedisUtil)及手动缓存管理使用。
+ * 覆盖 Boot 自动配置的 JDK 序列化默认实现,与 Spring Cache 注解缓存读写互不干扰。
+ * 当 @Cacheable 无法满足需求(如 Hash/List/Set 结构、按字段读写)时,注入本模板或 RedisUtil 手动管理即可。 + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(jacksonSerializer()); + template.setHashValueSerializer(jacksonSerializer()); + template.afterPropertiesSet(); + return template; + } + + /** + * 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。 + * 优先 Redis;Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。 + */ + @Primary + @Bean + public CacheManager cacheManager(RedisCacheManager redisCacheManager, + CaffeineCacheManager caffeineCacheManager, + RedisConnectionFactory redisConnectionFactory) { + return new FallbackCacheManager(redisCacheManager, caffeineCacheManager, redisConnectionFactory); + } + + /** + * 轻量故障转移缓存管理器:Redis 健康时优先 Redis,故障时降级 Caffeine,无业务报错。 + */ + static class FallbackCacheManager implements CacheManager { + + private final RedisCacheManager redisManager; + private final CaffeineCacheManager caffeineManager; + private final RedisConnectionFactory connectionFactory; + private final ScheduledExecutorService healthScheduler = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "redis-cache-health"); + t.setDaemon(true); + return t; + }); + private volatile boolean redisUp = true; + + FallbackCacheManager(RedisCacheManager redisManager, + CaffeineCacheManager caffeineManager, + RedisConnectionFactory connectionFactory) { + this.redisManager = redisManager; + this.caffeineManager = caffeineManager; + this.connectionFactory = connectionFactory; + if (connectionFactory != null) { + // 启动即探活(delay=0),之后每 10s 探活一次(Redis 恢复后自动切回) + healthScheduler.scheduleAtFixedRate(this::probe, 0, 10, TimeUnit.SECONDS); + } else { + redisUp = false; + } + } + + private void probe() { + if (connectionFactory == null) { + redisUp = false; + return; + } + RedisConnection connection = null; + try { + connection = connectionFactory.getConnection(); + redisUp = connection.ping() != null; + } catch (Exception e) { + redisUp = false; + } finally { + if (connection != null) { + try { + connection.close(); + } catch (Exception ignored) { + // 关闭失败忽略,下次探活重试 + } + } + } + } + + @Override + public Cache getCache(String name) { + // AI-FIX: 不在请求线程做同步探活。redisUp 仅由后台定时探活(每 10s)维护。 + // 否则 Redis 不可达时,每个 @Cacheable/@CacheEvict 都会同步阻塞一个连接超时(约 2s), + // resetAppSecret 等多次访问缓存的接口会累计到 4s+。Caffeine 降级必须零阻塞。 + return redisUp ? redisManager.getCache(name) : caffeineManager.getCache(name); + } + + @Override + public Collection getCacheNames() { + return redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames(); + } + } + + /** 统一构建带类型信息的 Jackson 序列化器(解决泛型/乱码),供 RedisTemplate 复用。 */ + private Jackson2JsonRedisSerializer jacksonSerializer() { + Jackson2JsonRedisSerializer jackson = new Jackson2JsonRedisSerializer<>(Object.class); + ObjectMapper mapper = new ObjectMapper(); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); + jackson.setObjectMapper(mapper); + return jackson; + } +} diff --git a/src/main/java/com/yunhai/userbase/config/Config.java b/src/main/java/com/yunhai/userbase/config/Config.java new file mode 100644 index 0000000..a4e3317 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/Config.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.config; + +import java.util.ResourceBundle; + +/** + * @author chenx + * @version 1.0 + * @date 2025/4/27 14:31 + */ + +public enum Config +{ + Inst; + + // 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成 + // "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠, + // 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。 + public static final String API_PATH = "/{platform}"; // manage / api / mobiapi + public static final String OPEN_API_PATH = "/oapi/"; + + public String getString(String key) + { + ResourceBundle resource = ResourceBundle.getBundle("config"); + return resource.getString(key); + } + + public int getInt(String key) + { + String value = getString(key); + return Integer.parseInt(value); + } + +} diff --git a/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java b/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java new file mode 100644 index 0000000..5695ada --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.config; + +import com.yunhai.userbase.common.interceptor.BoaUserContextInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class DWebMvcConfig implements WebMvcConfigurer +{ + @Autowired + private BoaUserContextInterceptor boaUserContextInterceptor; + + @Override + public void addCorsMappings(CorsRegistry registry) + { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE") + .allowedHeaders("*") + .maxAge(3600).allowCredentials(true); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(boaUserContextInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**"); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/config/FileStorageConfig.java b/src/main/java/com/yunhai/userbase/config/FileStorageConfig.java new file mode 100644 index 0000000..cdd603c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/FileStorageConfig.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.config; + +import com.yunhai.userbase.utils.FileUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; + +/** + * 文件存储配置注入。
+ * 在 Spring 启动完成后,将上传根目录与 Web 访问前缀注入 FileUtil 静态字段,
+ * 供上传工具类拼接完整可访问 URL 使用。
+ * + * @author chenx + */ +@Component +public class FileStorageConfig { + + @Value("${FILE_PATH}") + private String filePath; + + @Value("${FILE_WEB_PATH}") + private String fileWebPath; + + /** + * 启动时注入存储配置到 FileUtil。
+ */ + @PostConstruct + public void init() { + FileUtil.init(filePath, fileWebPath); + } +} diff --git a/src/main/java/com/yunhai/userbase/config/MyWebAppConfigurer.java b/src/main/java/com/yunhai/userbase/config/MyWebAppConfigurer.java new file mode 100644 index 0000000..c439d9d --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/MyWebAppConfigurer.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class MyWebAppConfigurer implements WebMvcConfigurer { + + @Value("${FILE_PATH}") + public String FILE_PATH; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String IMG_FILE_WEB_PATH = "/upload/image/"; + String File_FILE_WEB_PATH = "/upload/file/"; + String ICON_FILE_WEB_PATH = "/upload/icon/"; + String WORD_FILE_WEB_PATH = "/upload/word/"; + String HTML_FILE_WEB_PATH = "/upload/html/"; + registry.addResourceHandler(IMG_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/image/"); + registry.addResourceHandler(File_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/file/"); + registry.addResourceHandler(ICON_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/icon/"); + registry.addResourceHandler(WORD_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/word/"); + registry.addResourceHandler(HTML_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/html/"); + + } + +} diff --git a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java new file mode 100644 index 0000000..d5523af --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java @@ -0,0 +1,51 @@ +package com.yunhai.userbase.config; + +import com.yunhai.platform.appauth.AppAuthFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 强制开放接口必须带应用签名。

+ * yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流), + * 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**}) + * 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性: + * 已验签(属性存在)则放行,否则返回 401。

+ * 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。 + */ +@Configuration +public class OpenApiAuthEnforceConfiguration { + + @Bean + public FilterRegistrationBean requireAppSignFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new RequireAppSignFilter()); + bean.addUrlPatterns("/open/api/*", "/oapi/internal/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11); + return bean; + } + + public static class RequireAppSignFilter implements Filter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature"); + return; + } + chain.doFilter(request, response); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/config/PasswordEncoderConfig.java b/src/main/java/com/yunhai/userbase/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..83ca123 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/PasswordEncoderConfig.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * 密码编码器配置:统一使用 BCrypt。
+ * 用户中心存库与后续 CAS 登录校验共用同一编码器,保证加密/校验规则一致。
+ * + * @author chenx + * @date 2026/7/23 + * @description BCrypt 密码编码器 Bean + */ +@Configuration +public class PasswordEncoderConfig { + + /** + * BCrypt 密码编码器。
+ * strength 使用默认 10,兼顾安全与性能;CAS 侧用同一 encoder 的 matches 校验即可。
+ * + * @return PasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/yunhai/userbase/config/SwaggerConfig.java b/src/main/java/com/yunhai/userbase/config/SwaggerConfig.java new file mode 100644 index 0000000..3018a12 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/SwaggerConfig.java @@ -0,0 +1,37 @@ +package com.yunhai.userbase.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springdoc.core.customizers.GlobalOperationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.HandlerMethod; + +/** + * 接口文档(springdoc-openapi,替代旧 Springfox)。

+ * 提供统一文档元信息;并通过全局定制器隐藏由切面注入的 {@code BoaUser authUserWithDept} 参数, + * 避免其作为请求体出现在文档中(该参数不参与 HTTP 入参,由 {@code BoaUserArgumentResolver} 解析)。 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI userBaseOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("用户中心接口文档") + .description("UserBase 用户中心 OpenAPI 文档(springdoc-openapi)") + .version("0.0.1")); + } + + /** 隐藏切面注入的登录用户参数,保持文档整洁。 */ + @Bean + public GlobalOperationCustomizer hideAuthUserParam() { + return (operation, handlerMethod) -> { + if (operation.getParameters() != null) { + operation.getParameters().removeIf(p -> "authUserWithDept".equals(p.getName())); + } + return operation; + }; + } +} diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java new file mode 100644 index 0000000..46bcaed --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java @@ -0,0 +1,79 @@ +package com.yunhai.userbase.config; + +import com.yunhai.platform.appauth.AppAuthProperties; +import com.yunhai.platform.appauth.AppSecretProvider; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.repository.ApplicationInfoRepository; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * UserBase 作为「应用密钥权威源」的验签密钥提供方。

+ * 解析优先级:
+ * 1) 本库 application_info —— 覆盖全部「已颁发」应用,新增/重置密钥后即时生效;
+ * 2) 配置式受信应用 {@code yh.app-auth.trusted-apps} —— 平台自身默认密钥(如 应用中心 100616) + * 兜底,使其即便尚未落库也可验签,避免「默认 appKey 未建行」导致验签失败。

+ * 带 60s 本地 TTL 缓存,避免每次验签直打库(密钥变更最长 60s 后生效)。

+ * 这样 UserBase 作为颁发方不依赖 Redis 预热、也不局限于 trustedApps 写死的少量密钥, + * 能够验签所有它已颁发的应用。 + */ +public class UserBaseAppSecretProvider implements AppSecretProvider { + + private final ApplicationInfoRepository repository; + private final Map trustedApps; + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private static final long TTL_MS = 60_000L; + + public UserBaseAppSecretProvider(ApplicationInfoRepository repository, AppAuthProperties properties) { + this.repository = repository; + this.trustedApps = properties.getTrustedApps(); + } + + @Override + public String getSecret(String appKey) { + if (appKey == null) { + return null; + } + long now = System.currentTimeMillis(); + CacheEntry hit = cache.get(appKey); + if (hit != null && now - hit.ts < TTL_MS) { + return hit.secret; + } + String secret = null; + try { + Integer id = parse(appKey); + if (id != null) { + ApplicationInfo info = repository.findByAppKey(id); + if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) { + secret = info.getAppSecret(); + } + } + } catch (Exception ignored) { + // 查询异常不阻断,继续走配置兜底 + } + if (secret == null) { + secret = trustedApps.get(appKey); + } + cache.put(appKey, new CacheEntry(now, secret)); + return secret; + } + + private static Integer parse(String appKey) { + try { + return Integer.parseInt(appKey.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static final class CacheEntry { + final long ts; + final String secret; + + CacheEntry(long ts, String secret) { + this.ts = ts; + this.secret = secret; + } + } +} diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java new file mode 100644 index 0000000..96e2a65 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java @@ -0,0 +1,37 @@ +package com.yunhai.userbase.config; + +import com.yunhai.platform.auth.UserDetailsProvider; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.service.UserCenterService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后, + * 由本类查 UserBase 自身库,组装成基础平台统一的 {@link BoaUser}。

+ * 以 {@code @Component} 注册为 Bean,覆盖 yh-auth 默认的 NoOp 实现(@ConditionalOnMissingBean)。 + * 注意:UserBase 自身不引入 yh-openapi-spring-boot-starter,故 openApiUserDetailsProvider 不会被创建, + * 不会形成「用户中心回调自身」的环。 + */ +@Component +public class UserBaseUserDetailsProvider implements UserDetailsProvider { + + @Autowired + private UserCenterService userCenterService; + + @Override + public BoaUser loadByLoginName(String loginName) { + if (loginName == null) { + return null; + } + return userCenterService.getUserInfoWithDeptByAccount(loginName.trim()); + } + + @Override + public BoaUser loadByPhone(String phone) { + if (phone == null) { + return null; + } + return userCenterService.getUserInfoWithDeptByPhone(phone.trim()); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java new file mode 100644 index 0000000..887a313 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java @@ -0,0 +1,135 @@ +package com.yunhai.userbase.controller.api; + +import com.yunhai.userbase.common.annotation.OperationLog; +import com.yunhai.userbase.common.validation.ValidationGroups; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo; +import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest; +import com.yunhai.userbase.model.approle.AppRoleUserBindRequest; +import com.yunhai.userbase.model.approle.AppRoleUserVo; +import com.yunhai.userbase.model.approle.SysAppRoleQuery; +import com.yunhai.userbase.model.approle.SysAppRoleRequest; +import com.yunhai.userbase.model.approle.SysAppRoleVo; +import com.yunhai.userbase.model.approle.UserAppRoleBindRequest; +import com.yunhai.userbase.service.IAppRoleService; +import com.yunhai.userbase.utils.PageUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/27 + * @description 应用业务角色管理(角色 CRUD + 角色模块授权 + 用户角色绑定)。
+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/approle/") +public class AppRoleController { + + @Autowired + private IAppRoleService appRoleService; + + /** + * 角色分页列表:name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称。 + */ + @GetMapping("/RoleList") + public BasePageResult roleList(SysAppRoleQuery query, BasePageQuery pageQuery, + @SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) { + return PageUtils.toResult(appRoleService.list(query, PageUtils.toPageable(pageQuery, sort))); + } + + /** + * 新增角色:普通角色(allAppFlag=false,appId 必填,同应用下名称唯一);
+ * 全应用角色(allAppFlag=true,appId 为空,全应用角色之间名称唯一)。 + */ + @OperationLog("新增角色") + @PostMapping("RoleAdd") + public void roleAdd(@RequestBody @Validated(ValidationGroups.Create.class) SysAppRoleRequest request) { + appRoleService.add(request); + } + + /** + * 编辑角色:仅可改 name/remark/disabled;allAppFlag 与 appId 不允许修改。 + */ + @OperationLog("编辑角色") + @PostMapping("RoleEdit/{id}") + public void roleEdit(@PathVariable Integer id, + @RequestBody @Validated(ValidationGroups.Update.class) SysAppRoleRequest request) { + appRoleService.edit(id, request); + } + + /** + * 删除角色:级联清理该角色的模块授权与用户绑定记录。 + */ + @OperationLog("删除角色") + @PostMapping("RoleDelete/{id}") + public void roleDelete(@PathVariable Integer id) { + appRoleService.delete(id); + } + + /** + * 角色模块授权回显:返回该角色可授权的应用及模块树(已授权 checked=true)。
+ * 普通角色仅本应用;全应用角色返回全部应用(可跨应用授权)。 + */ + @GetMapping("/RoleModuleTree") + public List roleModuleTree(@RequestParam Integer roleId) { + return appRoleService.findRoleModuleTree(roleId); + } + + /** + * 角色模块授权保存:全量覆盖式更新该角色的模块授权记录。 + */ + @OperationLog("保存角色模块授权") + @PostMapping("SaveRoleModules") + public void saveRoleModules(@RequestBody @Validated AppRoleModuleAssignRequest request) { + appRoleService.saveRoleModules(request); + } + + /** + * 查询角色已绑定的用户列表。 + */ + @GetMapping("/RoleUsers") + public List roleUsers(@RequestParam Integer roleId) { + return appRoleService.findRoleUsers(roleId); + } + + /** + * 角色绑定用户保存(角色维度,全量覆盖)。 + */ + @OperationLog("保存角色用户绑定") + @PostMapping("SaveRoleUsers") + public void saveRoleUsers(@RequestBody @Validated AppRoleUserBindRequest request) { + appRoleService.saveRoleUsers(request); + } + + /** + * 用户绑定角色保存(用户维度,全量覆盖)。 + */ + @OperationLog("保存用户角色绑定") + @PostMapping("SaveUserRoles") + public void saveUserRoles(@RequestBody @Validated UserAppRoleBindRequest request) { + appRoleService.saveUserRoles(request); + } + + /** + * 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。
+ * 前端「用户管理 - 删除」调用此接口。 + */ + @OperationLog("移除角色用户绑定") + @PostMapping("RemoveRoleUser") + public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) { + appRoleService.removeRoleUser(roleId, userId); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java new file mode 100644 index 0000000..bf43b57 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java @@ -0,0 +1,184 @@ +package com.yunhai.userbase.controller.api; + +import com.bestsch.utils.DException; +import com.yunhai.userbase.common.annotation.OperationLog; +import com.yunhai.userbase.common.context.UserContext; +import com.yunhai.userbase.common.validation.ValidationGroups; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.enums.BookType; +import com.yunhai.userbase.model.BaseDict; +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import com.yunhai.userbase.model.Operator; +import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo; +import com.yunhai.userbase.model.appcategory.AppCategoryQuery; +import com.yunhai.userbase.model.appcategory.AppCategoryRequest; +import com.yunhai.userbase.model.appcategory.AppCategoryVo; +import com.yunhai.userbase.model.application.ApplicationInfoQuery; +import com.yunhai.userbase.model.application.ApplicationInfoRequest; +import com.yunhai.userbase.model.application.ApplicationInfoSortVo; +import com.yunhai.userbase.model.application.ApplicationInfoVo; +import com.yunhai.userbase.model.appmodule.AppModuleQuery; +import com.yunhai.userbase.model.appmodule.AppModuleRequest; +import com.yunhai.userbase.model.appmodule.AppModuleVo; +import com.yunhai.userbase.service.IAppCategoryService; +import com.yunhai.userbase.service.IApplicationInfoService; +import com.yunhai.userbase.service.IAppModuleService; +import com.yunhai.userbase.service.SignKeyCacheService; +import com.yunhai.userbase.utils.DicUtil; +import com.yunhai.userbase.utils.PageUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.tomcat.util.http.fileupload.util.Streams; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.io.BufferedOutputStream; +import java.io.InputStream; +import java.net.URLEncoder; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping(value = Config.API_PATH + "/app/") +public class AppSettingsController { + + @Autowired + private IAppCategoryService appCategoryService; + + @Autowired + private IApplicationInfoService applicationInfoService; + + @Autowired + private IAppModuleService appModuleService; + + @Autowired + private SignKeyCacheService signKeyCacheService; + + + + @OperationLog("新增应用类型") + @PostMapping("AddType") + public void addType(@RequestBody @Validated(ValidationGroups.Create.class) AppCategoryRequest request) { + appCategoryService.add(request); + } + + @OperationLog("编辑应用类型") + @PostMapping("EditType/{id}") + public void editType(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppCategoryRequest request) { + appCategoryService.edit(id, request); + } + + @OperationLog("删除应用类型") + @PostMapping("DeleteType/{id}") + public void deleteType(@PathVariable Integer id) { + appCategoryService.delete(id); + } + + @GetMapping("/AppTypeList") + public List appTypeList(AppCategoryQuery query) { + return appCategoryService.list(query); + } + + @OperationLog("应用类型排序") + @PostMapping("/AppTypeSort") + public void appTypeSort(@RequestBody List ids) { + appCategoryService.sort(ids); + } + + /** + * 应用授权用:获取所有应用类型及其类型下的应用(每个应用含 id/name/icon)。
+ * 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。 + */ + @GetMapping("/AppAuthzList") + public List appAuthzList() { + return appCategoryService.listWithApps(); + } + + @OperationLog("新增应用") + @PostMapping("AddApp") + public void addApp(@RequestBody @Validated(ValidationGroups.Create.class) ApplicationInfoRequest request) { + applicationInfoService.add(request); + } + + @OperationLog("编辑应用") + @PostMapping("EditApp/{id}") + public void editApp(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) ApplicationInfoRequest request) { + applicationInfoService.edit(id, request); + } + + @OperationLog("删除应用") + @PostMapping("DeleteApp/{id}") + public void deleteApp(@PathVariable Integer id) { + applicationInfoService.delete(id); + } + + @OperationLog("重置应用密钥") + @PostMapping("ResetAppSecret/{id}") + public String resetAppSecret(@PathVariable Integer id) { + applicationInfoService.resetAppSecret(id); + ApplicationInfo info= applicationInfoService.getById(id); + return info.getAppSecret(); + } + + @OperationLog("全量刷新应用签名密钥到Redis") + @PostMapping("RefreshSignKeys") + public Map refreshSignKeys() { + int count = signKeyCacheService.refreshAll(); + Map result = new LinkedHashMap<>(); + result.put("refreshed", count); + return result; + } + + @GetMapping("/AppList") + public BasePageResult appList(ApplicationInfoQuery query, BasePageQuery pageQuery, + @SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort) { + return PageUtils.toResult(applicationInfoService.list(query, PageUtils.toPageable(pageQuery, sort))); + } + + @OperationLog("应用排序") + @PostMapping("/AppSort") + public void appSort(@RequestBody List ids) { + applicationInfoService.sort(ids); + } + + @GetMapping("/AppSortList") + public List appSortList(@RequestParam Integer categoryId) { + return applicationInfoService.findListByCategoryId(categoryId); + } + + @OperationLog("新增模块") + @PostMapping("AddModule") + public void addModule(@RequestBody @Validated(ValidationGroups.Create.class) AppModuleRequest request) { + appModuleService.add(request); + } + + @OperationLog("编辑模块") + @PostMapping("EditModule/{id}") + public void editModule(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppModuleRequest request) { + appModuleService.edit(id, request); + } + + @OperationLog("删除模块") + @PostMapping("DeleteModule/{id}") + public void deleteModule(@PathVariable Integer id) { + appModuleService.delete(id); + } + + @GetMapping("/ModuleList") + public List moduleList(AppModuleQuery query) { + return appModuleService.list(query); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java new file mode 100644 index 0000000..e2f7107 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -0,0 +1,140 @@ +package com.yunhai.userbase.controller.api; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.enums.*; +import com.yunhai.userbase.utils.*; +import com.bestsch.utils.DException; +import org.springframework.web.multipart.MultipartFile; +import lombok.var; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.view.RedirectView; + +import java.util.*; +import java.util.function.Supplier; + +@RestController +@RequestMapping(value = Config.API_PATH +"/base/") +public class BaseController { + + + @Autowired + private RedisUtil redisUtil; + + /** + * 一般hashMap初始化大小 + */ + public static final int initialCapacity = 6; + + /** + * 通用上传允许的子目录(与 MyWebAppConfigurer.addResourceHandlers 的资源映射一一对应)。
+ * 注:html 因 FileUtil 的危险后缀黑名单被禁止通过此接口上传,故未列入,避免存储可被浏览器执行的 HTML 引发 XSS。 + */ + private static final Set ALLOWED_UPLOAD_TYPES = new HashSet<>(Arrays.asList("image", "file", "icon", "word")); + + @GetMapping("GetUserInfo") + public Object getUserInfo(BoaUser authUserWithDept){ + return authUserWithDept; + } + + //返回系统枚举 + @GetMapping("GetSysDict") + public Map getSysDict(BoaUser authUserWithDept){ + Map resultMap = new HashMap<>(7); + resultMap.put("grouptype", IEnum.toSimpleMapList(GroupTypeEnum.class, "k", "v")); + resultMap.put("gender", IEnum.toSimpleMapList(GenderEnum.class, "k", "v")); + resultMap.put("client", IEnum.toSimpleMapList(ClientEnum.class, "k", "v")); + resultMap.put("integ", IEnum.toSimpleMapList(IntegEnum.class, "k", "v")); + resultMap.put("status", IEnum.toSimpleMapList(StatusEnum.class, "k", "v")); + resultMap.put("sysrole", IEnum.toSimpleMapList(SysRoleEnum.class, "k", "v")); + resultMap.put("mode", IEnum.toSimpleMapList(SysModeEnum.class, "k", "v")); + return resultMap; + } + + /** + * 手动管理 Redis Hash 缓存的示例(@Cacheable 注解无法满足的场景)。
+ * 适用场景:需要把一个对象的多个字段按 Hash 结构存储,
+ * 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。
+ * 访问路径:GET {Config.API_PATH}/base/ManualHashCacheDemo。
+ * 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。 + * + * @return 本次演示各步骤的结果快照 + */ + @GetMapping("ManualHashCacheDemo") + public Map manualHashCacheDemo() { + Map result = new HashMap<>(8); + String hashKey = "demo:user:1001"; + // 1) 写入整张 hash(带 TTL):适合一次性缓存一个对象的多个属性 + Map profile = new HashMap<>(4); + profile.put("name", "yunhai"); + profile.put("age", 18); + profile.put("role", "admin"); + redisUtil.hmset(hashKey, profile, 300); + // 2) 只更新其中一个字段,无需整体覆盖 + redisUtil.hset(hashKey, "role", "superadmin"); + // 3) 读取单个字段 + result.put("roleAfterUpdate", redisUtil.hget(hashKey, "role")); + // 4) 读取整张 hash + result.put("wholeHash", redisUtil.hmget(hashKey)); + // 5) 判断字段是否存在 + result.put("hasAgeField", redisUtil.hHasKey(hashKey, "age")); + // 6) Cache-Aside(复用下方私有方法):字段不存在时回源并写回 Hash + Object nickname = getHashFieldWithFallback(hashKey, "nickname", () -> "yunhai-nick"); + result.put("nickname", nickname); + // 7) 删除某个字段 + redisUtil.hdel(hashKey, "age"); + result.put("afterDelAge", redisUtil.hmget(hashKey)); + return result; + } + + + + @GetMapping("RedirectTo/{id}") + public RedirectView RedirectTo(BoaUser authUserWithDept, @PathVariable(name = "id") String id) { + String url= OtherUtil.hexToString(id); + return new RedirectView(url,true); + } + + /** + * 通用文件上传接口(一个接口覆盖所有类型,无需按类型拆分)。
+ * type 决定存储子目录,与 MyWebAppConfigurer.addResourceHandlers 的 /upload/{type}/** 映射一一对应,
+ * 上传成功后可直接用返回的 URL 访问,无需再改资源映射配置。
+ * 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。
+ * 支持 type:image / file / icon / word(html 被 FileUtil 黑名单禁止上传)。
+ * + * @param file 上传文件(必填,单文件上限 50MB,见 application.yml 的 multipart 配置) + * @param type 存储子目录类型,默认 image + * @return 文件对外可访问的 Web URL + */ + @PostMapping("UploadFile") + public String uploadFile(@RequestParam("file") MultipartFile file, + @RequestParam(value = "type", defaultValue = "image") String type) { + if (!ALLOWED_UPLOAD_TYPES.contains(type)) { + throw new DException("不支持的上传类型: " + type + ",可选: image/file/icon/word"); + } + // FileUtil.upload 内部已处理空文件校验、危险后缀黑名单、目录创建与访问 URL 拼接 + return FileUtil.upload(file, type); + } + + /** + * 私有方法(可复用):演示 Hash 结构的 Cache-Aside(缓存旁路)模式。
+ * 先从 Redis Hash 读字段;读不到则回源(此处用 fallback 模拟数据库查询),再把结果写回 Hash。
+ * + * @param hashKey hash 键 + * @param field 字段名 + * @param fallback 回源加载器(真实场景替换为查数据库/远程调用) + * @return 字段值 + */ + private Object getHashFieldWithFallback(String hashKey, String field, Supplier fallback) { + if (redisUtil.hHasKey(hashKey, field)) { + return redisUtil.hget(hashKey, field); + } + Object value = fallback.get(); + if (value != null) { + redisUtil.hset(hashKey, field, value); + } + return value; + } + +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java b/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java new file mode 100644 index 0000000..3297b28 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java @@ -0,0 +1,179 @@ +package com.yunhai.userbase.controller.api; + +import com.yunhai.userbase.common.annotation.OperationLog; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest; +import com.yunhai.userbase.model.groupauthz.GroupAppVo; +import com.yunhai.userbase.model.groupauthz.GroupAuthzAppVo; +import com.yunhai.userbase.model.groupauthz.GroupAuthzCategoryVo; +import com.yunhai.userbase.model.groupauthz.GroupModuleAuthzItem; +import com.yunhai.userbase.model.groupauthz.GroupModulePermRequest; +import com.yunhai.userbase.model.groupauthz.SchoolAppAuthzRequest; +import com.yunhai.userbase.model.groupauthz.UserGroupNodeVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionUserVo; +import com.yunhai.userbase.model.groupauthz.UserRoleVo; +import com.yunhai.userbase.service.IGroupAuthzService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import javax.validation.Valid; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组应用权限管理(应用开通 + 组授权 + 权限判定)。
+ * 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。
+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/groupauthz/") +public class GroupAuthzController { + + @Autowired + private IGroupAuthzService groupAuthzService; + + /** + * 功能1回显:点击左侧组后,返回系统全量应用列表,已开通的 checked=true。 + */ + @GetMapping("/GroupAppList") + public List groupAppList(@RequestParam Integer groupId) { + return groupAuthzService.listGroupApps(groupId); + } + + /** + * 功能1保存:全量覆盖式更新该组的应用开通记录。 + */ + @OperationLog("保存组应用开通") + @PostMapping("AssignApps") + public void assignApps(@RequestBody @Validated GroupAppAssignRequest request) { + groupAuthzService.assignAppsToGroup(request); + } + + /** + * 功能2数据加载:返回该组已开通的应用及其模块权限树(含勾选与子组继承回显)。 + */ + @GetMapping("/GroupModuleTree") + public List groupModuleTree(@RequestParam Integer groupId) { + return groupAuthzService.findGroupAppsWithModules(groupId); + } + + /** + * 功能2数据加载(按应用分类分组):供组授权页面按分类面板展示,
+ * 返回全部分类及该组已开通的应用模块权限树。 + */ + @GetMapping("/GroupModuleTreeByCategory") + public List groupModuleTreeByCategory(@RequestParam Integer groupId) { + return groupAuthzService.findGroupAppsWithModulesByCategory(groupId); + } + + /** + * 功能2保存:全量覆盖式更新该组的模块权限(含子组继承开关)。 + */ + @OperationLog("保存组模块权限") + @PostMapping("SaveModulePerms") + public void saveModulePerms(@RequestBody @Validated GroupModulePermRequest request) { + groupAuthzService.saveModulePermissions(request); + } + + /** + * 权限判定:判断用户对某模块是否有权限(供登录/拦截器/联调验证使用)。 + */ + @GetMapping("/HasModulePerm") + public boolean hasModulePerm(@RequestParam Integer userId, @RequestParam Integer moduleId) { + return groupAuthzService.hasModulePermission(userId, moduleId); + } + + /** + * 权限一览:根据组ID获取该组下的直接用户列表(不含子组,不分页)。 + */ + @GetMapping("/GroupUsers") + public List groupUsers(@RequestParam Integer groupId) { + return groupAuthzService.findGroupUsers(groupId); + } + + /** + * 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。
+ * 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。 + */ + @GetMapping("/SearchUsers") + public List searchUsers(@RequestParam String keyword) { + return groupAuthzService.searchUsers(keyword); + } + + /** + * 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。
+ * 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。
+ * 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。 + */ + @GetMapping("/UserPermissionTree") + public List userPermissionTree(@RequestParam Integer userId, + @RequestParam(required = false) Integer groupId) { + return groupAuthzService.findUserPermissionTree(groupId, userId); + } + + /** + * 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。
+ * 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。 + */ + @GetMapping("/UserRoles") + public List userRoles(@RequestParam Integer userId) { + return groupAuthzService.findUserRoles(userId); + } + + /** + * 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。
+ * 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。 + */ + @GetMapping("/UserGroupTree") + public List userGroupTree(@RequestParam Integer userId) { + return groupAuthzService.findUserGroupTree(userId); + } + + // ===================== 新增:树节点式增量授权(左侧树 + 右侧 appAuthzList 勾选交互) ===================== + + /** + * 功能3回显:点击左侧树节点后,返回该组「已开通的应用ID列表」,供右侧 AppSettings.appAuthzList 回显勾选状态。
+ * 交互:左树节点 → 右 appAuthzList(全量应用)→ 本接口拿到已选 id → 前端把对应多选框置为勾选。
+ * 实现:应用开通不继承,直接查 sys_group_app_rel(group_id=nodeId) 返回 app_id 列表(详见 service.findAuthorizedAppIds)。
+ * 与 GroupAppList(返回全量应用+checked)互为补充:本接口更轻量,只给 id 集合。 + */ + @GetMapping("/GetSchoolAppId") + public List getSchoolAppId(@RequestParam Integer nodeId) { + return groupAuthzService.findAuthorizedAppIds(nodeId); + } + + /** + * 功能3保存(应用维):点击应用多选框触发,对该组做应用开通/取消。
+ * 入参(请求体,JSON):{@link SchoolAppAuthzRequest} —— schid=组ID(nodeId);appid=单个应用ID(一次只传一个,逐次点击);mode=操作模式。
+ * mode 语义(SysModeEnum):0=追加(开通应用) / 2=删除(取消开通);1=覆盖 不适用,会被拒绝。
+ * 实现:应用开通不继承,仅操作本组;删除时同步清理该组在该应用下模块的权限残留(详见 service.setSchoolApps)。
+ * 注意:appid 为单个 Integer,后端不再做多值/逗号解析;每次点击提交一次请求。 + */ + @OperationLog("设置组应用开通") + @PostMapping("SetSchoolApp") + public void setSchoolApp(@RequestBody @Valid SchoolAppAuthzRequest request) { + groupAuthzService.setSchoolApps(request); + } + + /** + * 功能4保存(模块维):前端「组授权」面板逐条勾选/取消时提交,对 (组,应用,模块) 做增量授权/取消。
+ * 入参:请求体为数组,元素见 {@link GroupModuleAuthzItem}:{groupId, appId, moduleId, mode, isAll}。
+ * mode:0=追加 / 2=删除(1=覆盖不适用)。
+ * isAll:是否对子组继承,映射 sys_group_module_perm.inherit_flag(1=子孙组自动继承 / 0=仅本组)。
+ * moduleId=0:表示「该应用下全部模块」,后端展开为应用下每个真实模块各写/删一条。
+ * 实现:逐条处理、顺序即执行顺序;追加前要求该组已开通该应用;越权写别的应用模块会被拒绝(详见 service.setGroupModulePerms)。
+ * 与现有 SaveModulePerms(全量覆盖)共存:本接口是单条增量,二者操作同一张表、互不冲突。 + */ + @OperationLog("设置组模块权限") + @PostMapping("SetGroupModule") + public void setGroupModule(@RequestBody @Valid List items) { + groupAuthzService.setGroupModulePerms(items); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java b/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java new file mode 100644 index 0000000..7252ed2 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java @@ -0,0 +1,97 @@ +package com.yunhai.userbase.controller.api; + +import com.yunhai.userbase.common.annotation.OperationLog; +import com.yunhai.userbase.common.validation.ValidationGroups; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupQuery; +import com.yunhai.userbase.model.sysgroup.SysGroupRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupVo; +import com.yunhai.userbase.service.ISysGroupService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/20 16:00 + * @description 系统组管理(组织架构树) + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/sysgroup/") +public class SysGroupController { + + @Autowired + private ISysGroupService sysGroupService; + + @OperationLog("新增组") + @PostMapping("Add") + public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) { + sysGroupService.add(request); + } + + @OperationLog("编辑组") + @PostMapping("Edit/{id}") + public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) { + sysGroupService.edit(id, request); + } + + @OperationLog("删除组") + @PostMapping("Delete/{id}") + public void delete(@PathVariable Integer id) { + sysGroupService.delete(id); + } + + @GetMapping("/List") + public List list(SysGroupQuery query) { + return sysGroupService.list(query); + } + + /** + * 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 + */ + @GetMapping("GetTree") + public List getTree() { + return sysGroupService.getTree(); + } + + /** + * 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;
+ * 子树中无学校组的分支整体剪掉;根节点始终保留。 + */ + @GetMapping("GetSchoolTree") + public List getSchoolTree() { + return sysGroupService.getSchoolTree(); + } + + /** + * 批量新增子组:请求体 { pid, type, items:[{name},...] }(JSON)。 + */ + @OperationLog("批量新增子组") + @PostMapping("BatchAdd") + public void batchAdd(@RequestBody @Validated SysGroupBatchRequest request) { + sysGroupService.batchAdd(request); + } + + /** + * 子组排序:传入 pid 与有序 ids(必须是 pid 的直接子节点),按传入顺序落库。 + */ + @OperationLog("子组排序") + @PostMapping("SortChildren") + public void sortChildren(@Validated SysGroupSortRequest request) { + sysGroupService.sortChildren(request); + } + + /** + * 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。 + */ + @OperationLog("移动组") + @PostMapping("Move") + public void move(@Validated SysGroupMoveRequest request) { + sysGroupService.move(request); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java b/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java new file mode 100644 index 0000000..630e2cf --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java @@ -0,0 +1,74 @@ +package com.yunhai.userbase.controller.api; + +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import com.yunhai.userbase.model.oplog.SysOperationLogListVo; +import com.yunhai.userbase.model.oplog.SysOperationLogQuery; +import com.yunhai.userbase.service.ISysOperationLogService; +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 操作日志查询与下载接口。
+ * 本控制器自身不标注 @OperationLog,避免记录日志查看行为。
+ * + * @author chenx + * @date 2026/7/28 + */ +@Slf4j +@RestController +@RequestMapping(value = Config.API_PATH + "/oplog/") +public class SysOperationLogController { + + @Autowired + private ISysOperationLogService sysOperationLogService; + + /** + * 分页查询操作日志列表(不含响应结果)。 + */ + @GetMapping("/List") + public BasePageResult list(SysOperationLogQuery query, BasePageQuery pageQuery) { + return sysOperationLogService.pageQuery(query, pageQuery); + } + + /** + * 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。
+ * 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。
+ * 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。 + */ + @GetMapping("/Download/{id}") + public ResponseEntity download(@PathVariable Long id) { + String data = sysOperationLogService.getResponseData(id); + if (data == null) { + data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。"; + } + try { + ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); + HttpHeaders headers = new HttpHeaders(); + headers.add("Cache-Control", "no-cache, no-store, must-revalidate"); + headers.add(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=" + new String(("response-" + id + ".txt").getBytes("gbk"), "iso8859-1")); + headers.add("Pragma", "no-cache"); + headers.add("Expires", "0"); + return ResponseEntity.ok().headers(headers) + .contentType(MediaType.parseMediaType("application/octet-stream")) + .body(new InputStreamResource(bis)); + } catch (Exception e) { + log.error("操作日志响应结果下载失败, id={}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java new file mode 100644 index 0000000..4dfc1d8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -0,0 +1,68 @@ +package com.yunhai.userbase.controller.api; + +import com.yunhai.userbase.common.annotation.OperationLog; +import com.yunhai.userbase.common.validation.ValidationGroups; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import com.yunhai.userbase.model.sysuser.SysUserQuery; +import com.yunhai.userbase.model.sysuser.SysUserRequest; +import com.yunhai.userbase.model.sysuser.SysUserVo; +import com.yunhai.userbase.service.ISysUserService; +import com.yunhai.userbase.utils.PageUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * @author chenx + * @date 2026/7/20 16:14 + * @description 组用户管理 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/sysuser/") +public class SysUserController { + + @Autowired + private ISysUserService sysUserService; + + @OperationLog("新增用户") + @PostMapping("Add") + public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { + sysUserService.add(request); + } + + @OperationLog("编辑用户") + @PostMapping("Edit/{id}") + public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) { + sysUserService.edit(id, request); + } + + @OperationLog("删除用户") + @PostMapping("Delete/{id}") + public void delete(@PathVariable Integer id) { + sysUserService.delete(id); + } + + /** 禁用用户(disabled=true) */ + @OperationLog("禁用用户") + @PostMapping("Disable/{id}") + public void disable(@PathVariable Integer id) { + sysUserService.changeStatus(id, true); + } + + /** 启用用户(disabled=false) */ + @OperationLog("启用用户") + @PostMapping("Enable/{id}") + public void enable(@PathVariable Integer id) { + sysUserService.changeStatus(id, false); + } + + @GetMapping("/List") + public BasePageResult list(SysUserQuery query, BasePageQuery pageQuery, + @SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) { + return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, sort))); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java b/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java new file mode 100644 index 0000000..bb187c9 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java @@ -0,0 +1,45 @@ +package com.yunhai.userbase.controller.oapi; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.service.UserCenterService; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /open/api/user/...})。

+ * 路径与基础平台 {@code DefaultUserCenterClient} 硬编码的契约严格一致: + * {@code GET /open/api/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}。
+ * 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求, + * 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。 + */ +@RestController +@RequestMapping("/open/api/user/") +public class OpenBaseController { + + @Autowired + private UserCenterService userCenterService; + + @GetMapping("byId") + public BoaUser byId(@RequestParam("id") Integer id) { + return userCenterService.getUserInfoWithDeptById(id); + } + + @GetMapping("byLoginName") + public BoaUser byLoginName(@RequestParam("loginName") String loginName) { + if (loginName == null || loginName.trim().isEmpty()) { + return null; + } + return userCenterService.getUserInfoWithDeptByAccount(loginName); + } + + @GetMapping("byPhone") + public BoaUser byPhone(@RequestParam("phone") String phone) { + if (phone == null || phone.trim().isEmpty()) { + return null; + } + return userCenterService.getUserInfoWithDeptByPhone(phone); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/SignKeyInternalController.java b/src/main/java/com/yunhai/userbase/controller/oapi/SignKeyInternalController.java new file mode 100644 index 0000000..b435789 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/oapi/SignKeyInternalController.java @@ -0,0 +1,70 @@ +package com.yunhai.userbase.controller.oapi; + +import com.yunhai.platform.appauth.AppAuthProperties; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 验签兜底端点:供其他业务服务在「共享 Redis 宕机且本地缓存也未命中」时直连获取某应用的密钥。

+ * 直接从 MySQL 读(绕过可能同宕的共享 Redis),是密钥的权威源;写入侧仍由 + * {@code SignKeyCacheService} 同步到 Redis。返回形状与 BasePlatform 读侧解析契约一致:
+ * {@code {"appKey":"100616","secret":"...","enabled":true}}。

+ * 解析优先级:
+ * 1) 本库 application_info(覆盖全部「已颁发」应用的实时密钥);
+ * 2) 配置式受信应用 {@code yh.app-auth.trusted-apps}(平台自身默认密钥,如 应用中心 100616, + * 即便尚未落库也可解析,避免「默认 appKey 未建行」时此处返回 404 导致验签链路失败);
+ * 两者皆无 → 404。

+ * 注意:此端点返回的是应用密钥,必须仅内网可达(由部署网络隔离保障),切勿暴露到公网。 + */ +@RestController +@RequestMapping(Config.OPEN_API_PATH + "internal") +public class SignKeyInternalController { + + private final ApplicationInfoRepository applicationInfoRepository; + private final AppAuthProperties appAuthProperties; + + public SignKeyInternalController(ApplicationInfoRepository applicationInfoRepository, + AppAuthProperties appAuthProperties) { + this.applicationInfoRepository = applicationInfoRepository; + this.appAuthProperties = appAuthProperties; + } + + @GetMapping("sign-key/{appId}") + public ResponseEntity> signKey(@PathVariable String appId) { + Integer key; + try { + key = Integer.parseInt(appId.trim()); + } catch (NumberFormatException e) { + return ResponseEntity.notFound().build(); + } + ApplicationInfo info = applicationInfoRepository.findByAppKey(key); + if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) { + return ok(info.getAppKey() == null ? appId : String.valueOf(info.getAppKey()), + info.getAppSecret()); + } + // 库内无此应用密钥:回退到配置式受信应用(平台自身默认密钥 100616 等), + // 避免「应用中心默认 appKey 未落库」时兜底查询返回 404。 + String cfgSecret = appAuthProperties.getTrustedApps().get(appId); + if (cfgSecret != null && !cfgSecret.trim().isEmpty()) { + return ok(appId, cfgSecret); + } + return ResponseEntity.notFound().build(); + } + + private static ResponseEntity> ok(String appKey, String secret) { + Map body = new LinkedHashMap<>(); + body.put("appKey", appKey); + body.put("secret", secret); + body.put("enabled", true); + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/yunhai/userbase/entity/AppCategory.java b/src/main/java/com/yunhai/userbase/entity/AppCategory.java new file mode 100644 index 0000000..15dccc3 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/AppCategory.java @@ -0,0 +1,28 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/15 15:53 + * @description + */ + +@NoArgsConstructor +@Data +@Entity +@Table(name = "app_category") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class AppCategory extends BaseEntity { + //类别名称 + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "sort_no") + private Integer sortNo; +} diff --git a/src/main/java/com/yunhai/userbase/entity/AppModule.java b/src/main/java/com/yunhai/userbase/entity/AppModule.java new file mode 100644 index 0000000..6ae9897 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/AppModule.java @@ -0,0 +1,81 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/22 + * @description 应用模块(树形,逻辑外键,最深5层)
+ */ +@NoArgsConstructor +@Data +@Entity +@Table( + name = "app_module", + indexes = { + @Index(name = "idx_app_module_app", columnList = "app_id"), + @Index(name = "idx_app_module_parent", columnList = "parent_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_app_module_custom", columnNames = {"app_id", "custom_id"}) + } +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class AppModule extends BaseEntity { + + /** + * 所属应用ID(逻辑外键 → ApplicationInfo.id),创建后不可改。
+ */ + @NotNull + @Column(name = "app_id", nullable = false, updatable = false) + private Integer appId; + + /** + * 父模块ID(逻辑外键 → AppModule.id);根模块为 null。
+ */ + @Column(name = "parent_id") + private Integer parentId; + + /** + * 模块名称。
+ */ + @NotBlank + @Column(name = "name", nullable = false) + private String name; + + /** + * 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。
+ */ + @NotBlank + @Column(name = "custom_id", nullable = false, length = 64) + private String customId; + + /** + * 模块路径(前端路由/资源路径,如 "/system/user")。
+ */ + @Column(name = "path", length = 255) + private String path; + + /** + * 层级:根=1,子=父+1,上限5(由 Service 校验)。
+ */ + @NotNull + @Column(name = "level", nullable = false) + private Integer level; + + /** + * 排序号(沿用 AppCategory 约定)。
+ */ + @Column(name = "sort_no") + private Integer sortNo; +} diff --git a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java new file mode 100644 index 0000000..da2fa88 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java @@ -0,0 +1,107 @@ +package com.yunhai.userbase.entity; + +import com.yunhai.userbase.common.converter.ListIntegerJsonConverter; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Convert; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/15 15:53 + * @description + */ + +@NoArgsConstructor +@Data +@Entity +@Table(name = "application_info") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class ApplicationInfo extends BaseEntity { + + /** + * 应用名称(页面*必填) + */ + private String name; + + /** + * 应用类型ID + */ + private Integer categoryId; + + /** + * 排序号,仅在同一个应用类型内生效;默认为 null(新增时不赋值)。
+ */ + @Column(name = "sort_no") + private Integer sortNo; + + /** + * 应用描述 + */ + @Column(name = "app_desc", length = 500) + private String desc; + + /** + * appKey 系统生成唯一标识,整型,从100000起自增。
+ */ + @NotNull + @Column(name = "app_key", unique = true, nullable = false) + private Integer appKey; + + /** + * appSecret 密钥,支持重设 + */ + @NotBlank + @Column(name = "app_secret", nullable = false, length = 128) + private String appSecret; + + //应用前端类型 + @Convert(converter = ListIntegerJsonConverter.class) +// @Column(name = "clients", columnDefinition = "json") + @Column(name = "clients", columnDefinition = "TEXT") + private List clients; + + /** + * PC应用访问地址 + */ + @Column(name = "pc_url") + private String pcUrl; + + /** + * PC应用图标 图片URL + */ + @Column(name = "pc_icon") + private String pcIcon; + + /** + * App访问地址(移动端主地址) + */ + @Column(name = "app_url") + private String appUrl; + + /** + * App应用图标 图片URL + */ + @Column(name = "app_icon") + private String appIcon; + + /** + * 公众号应用地址 + */ + @Column(name = "official_account_url") + private String officialAccountUrl; + + /** + * 钉钉应用地址 + */ + @Column(name = "dingtalk_url") + private String dingtalkUrl; + +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/entity/BaseEntity.java b/src/main/java/com/yunhai/userbase/entity/BaseEntity.java new file mode 100644 index 0000000..4ac3dcf --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/BaseEntity.java @@ -0,0 +1,63 @@ +package com.yunhai.userbase.entity; + + +import javax.persistence.*; +import javax.validation.constraints.NotNull; + +import com.yunhai.userbase.common.listener.UserAuditListener; +import com.yunhai.userbase.common.validation.ValidationGroups; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * @author chenx + * @date 2026/7/14 10:46 + * @description + */ + +@MappedSuperclass +@Getter +@Setter +@EntityListeners(UserAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @NotNull(groups = ValidationGroups.Update.class) + private Integer id; + + /** + * 创建人ID + */ + @Column(name = "create_user", nullable = false, updatable = false) + private Integer createUser; + + /** + * 创建时间 + */ + @CreationTimestamp + @Column(name = "create_time", nullable = false, updatable = false) + private LocalDateTime createTime; + + /** + * 更新人ID + */ + @Column(name = "update_user") + private Integer updateUser; + + /** + * 更新时间 + */ + @UpdateTimestamp + @Column(name = "update_time") + private LocalDateTime updateTime; + + +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysAppRole.java b/src/main/java/com/yunhai/userbase/entity/SysAppRole.java new file mode 100644 index 0000000..c8be052 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysAppRole.java @@ -0,0 +1,68 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/27 + * @description 应用业务角色。
+ * 普通角色:allAppFlag=false 且 appId 必填;
+ * 全应用角色:allAppFlag=true 且 appId 为空;
+ * 注意:这里的“业务角色”不等同于 {@code sys_user.roles} 对应的系统身份枚举。
+ */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_app_role", indexes = { + @Index(name = "idx_app_role_app", columnList = "app_id"), + @Index(name = "idx_app_role_all_app", columnList = "all_app_flag"), + @Index(name = "idx_app_role_name", columnList = "name") +}) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysAppRole extends BaseEntity { + + /** + * 角色名称。
+ */ + @NotBlank + @Column(name = "name", nullable = false, length = 100) + private String name; + + /** + * 所属应用ID(逻辑外键 → ApplicationInfo.id)。
+ * 普通角色必填;全应用角色为空。
+ */ + @Column(name = "app_id") + private Integer appId; + + /** + * 是否全应用角色。
+ * true=可跨应用勾选模块;false=仅能授权所属应用下的模块。
+ */ + @NotNull + @Column(name = "all_app_flag", nullable = false) + private Boolean allAppFlag = Boolean.FALSE; + + /** + * 角色备注。
+ */ + @Column(name = "remark", length = 500) + private String remark; + + /** + * 是否禁用。
+ * 禁用后角色关系可保留,但权限计算时应忽略该角色。
+ */ + @NotNull + @Column(name = "disabled", nullable = false) + private Boolean disabled = Boolean.FALSE; +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysAppRoleModuleRel.java b/src/main/java/com/yunhai/userbase/entity/SysAppRoleModuleRel.java new file mode 100644 index 0000000..fda1c4b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysAppRoleModuleRel.java @@ -0,0 +1,49 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/27 + * @description 业务角色-模块权限关系表(逻辑外键,无物理 FK)。
+ * 存在记录即代表该角色显式拥有该模块。
+ */ +@NoArgsConstructor +@Data +@Entity +@Table( + name = "sys_app_role_module_rel", + indexes = { + @Index(name = "idx_app_role_module_role", columnList = "role_id"), + @Index(name = "idx_app_role_module_module", columnList = "module_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_app_role_module", columnNames = {"role_id", "module_id"}) + } +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysAppRoleModuleRel extends BaseEntity { + + /** + * 角色ID(逻辑外键 → SysAppRole.id)。
+ */ + @NotNull + @Column(name = "role_id", nullable = false) + private Integer roleId; + + /** + * 模块ID(逻辑外键 → AppModule.id)。
+ */ + @NotNull + @Column(name = "module_id", nullable = false) + private Integer moduleId; +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysGroup.java b/src/main/java/com/yunhai/userbase/entity/SysGroup.java new file mode 100644 index 0000000..fcfb5b7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysGroup.java @@ -0,0 +1,42 @@ +package com.yunhai.userbase.entity; + +import com.yunhai.userbase.common.converter.GroupTypeEnumConverter; +import com.yunhai.userbase.enums.GroupTypeEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/13 22:48 + * @description 系统节点(组)表 + */ + +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_group") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysGroup extends BaseEntity { + + private String name; + + @Column(name = "parent_id", nullable = false) + private Integer parentId; + + @Column(name = "type") + @Convert(converter = GroupTypeEnumConverter.class) + private GroupTypeEnum type; + + private Integer sort; + + + // 一对多反向关联中间表 + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JoinColumn(name = "groupId", insertable = false, updatable = false) + private List userGroupRelList; + +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysGroupAppRel.java b/src/main/java/com/yunhai/userbase/entity/SysGroupAppRel.java new file mode 100644 index 0000000..3a99ffb --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysGroupAppRel.java @@ -0,0 +1,49 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组应用开通表(逻辑外键,无物理 FK)。
+ * 记录某个组显式开通了哪些应用;仅当本表存在记录时,「组授权」界面右侧才显示该应用。
+ * 开通不继承:子组要使用应用必须自己开通。
+ */ +@NoArgsConstructor +@Data +@Entity +@Table( + name = "sys_group_app_rel", + indexes = { + @Index(name = "idx_group_app_rel_app", columnList = "app_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_group_app", columnNames = {"group_id", "app_id"}) + } +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysGroupAppRel extends BaseEntity { + + /** + * 组ID(逻辑外键 → SysGroup.id)。
+ */ + @NotNull + @Column(name = "group_id", nullable = false) + private Integer groupId; + + /** + * 应用ID(逻辑外键 → ApplicationInfo.id)。
+ */ + @NotNull + @Column(name = "app_id", nullable = false) + private Integer appId; +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysGroupModulePerm.java b/src/main/java/com/yunhai/userbase/entity/SysGroupModulePerm.java new file mode 100644 index 0000000..c59c22a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysGroupModulePerm.java @@ -0,0 +1,56 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组模块权限表(逻辑外键,无物理 FK)。
+ * 存在记录即代表该组勾选了该模块;inheritFlag=true 时当前组及其所有子孙组自动继承该模块权限。
+ * 取消勾选时物理删除记录。
+ */ +@NoArgsConstructor +@Data +@Entity +@Table( + name = "sys_group_module_perm", + indexes = { + @Index(name = "idx_group_module_perm_module", columnList = "module_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_group_module", columnNames = {"group_id", "module_id"}) + } +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysGroupModulePerm extends BaseEntity { + + /** + * 组ID(逻辑外键 → SysGroup.id)。
+ */ + @NotNull + @Column(name = "group_id", nullable = false) + private Integer groupId; + + /** + * 模块ID(逻辑外键 → AppModule.id)。
+ */ + @NotNull + @Column(name = "module_id", nullable = false) + private Integer moduleId; + + /** + * 是否子组继承(false=仅本组生效,true=子孙组自动继承),对应 UI「子组继承」勾选框。
+ */ + @NotNull + @Column(name = "inherit_flag", nullable = false) + private Boolean inheritFlag = Boolean.FALSE; +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysOperationLog.java b/src/main/java/com/yunhai/userbase/entity/SysOperationLog.java new file mode 100644 index 0000000..ade5499 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysOperationLog.java @@ -0,0 +1,140 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.Table; +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +/** + * 系统操作日志实体。
+ * 记录用户的关键操作(增删改查),便于审计与问题排查。
+ * + * @author chenx + * @date 2026/7/28 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_operation_log", indexes = { + @Index(name = "idx_create_time", columnList = "create_time"), + @Index(name = "idx_user_time", columnList = "user_id, create_time"), + @Index(name = "idx_uri_time", columnList = "request_uri, create_time"), + @Index(name = "idx_level_time", columnList = "log_level, create_time"), + @Index(name = "idx_ip_time", columnList = "request_ip, create_time") +}) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysOperationLog implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @EqualsAndHashCode.Include + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 日志级别:1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) + */ + @Column(name = "log_level", nullable = false, columnDefinition = "TINYINT") + private Integer logLevel; + + /** + * 操作用户ID + */ + @Column(name = "user_id", nullable = false) + private Long userId; + + /** + * 操作用户名 + */ + @Column(name = "username", nullable = false, length = 64) + private String username; + + /** + * 请求IP + */ + @Column(name = "request_ip", nullable = false, length = 64) + private String requestIp; + + /** + * 请求路径 + */ + @Column(name = "request_uri", nullable = false, length = 512) + private String requestUri; + + /** + * 请求方法 GET/POST/PUT/DELETE + */ + @Column(name = "request_method", nullable = false, length = 16) + private String requestMethod; + + /** + * 操作类型 DELETE/CREATE/UPDATE/QUERY + */ + @Column(name = "operation_type", nullable = false, length = 32) + private String operationType; + + /** + * 类名.方法名 + */ + @Column(name = "class_method", nullable = false, length = 512) + private String classMethod; + + /** + * 请求参数JSON + */ + @Column(name = "request_params", columnDefinition = "TEXT") + private String requestParams; + + /** + * 响应结果(GZIP压缩) + */ + @Column(name = "response_data", columnDefinition = "MEDIUMBLOB") + private byte[] responseData; + + /** + * HTTP状态码 + */ + @Column(name = "status_code", nullable = false) + private Integer statusCode; + + /** + * 耗时(ms) + */ + @Column(name = "cost_time", nullable = false) + private Long costTime; + + /** + * 浏览器UA + */ + @Column(name = "user_agent", length = 512) + private String userAgent; + + /** + * 错误信息 + */ + @Column(name = "error_msg", columnDefinition = "TEXT") + private String errorMsg; + + /** + * 操作时间 + */ + @CreationTimestamp + @Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)") + private LocalDateTime createTime; + +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysUser.java b/src/main/java/com/yunhai/userbase/entity/SysUser.java new file mode 100644 index 0000000..1103b13 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysUser.java @@ -0,0 +1,73 @@ +package com.yunhai.userbase.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.yunhai.userbase.common.converter.ListIntegerJsonConverter; +import com.yunhai.userbase.common.converter.StatusEnumConverter; +import com.yunhai.userbase.common.converter.UserTypeEnumConverter; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.enums.UserTypeEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDate; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/13 23:30 + * @description 系统用户表 + */ + +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_user") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysUser extends BaseEntity { + + @Column(name = "username", nullable = false, unique = true) + private String account; + private String password; + //出生日期 + private LocalDate birthday; + + @JsonProperty("name") + private String realName; + + private String phone; + + private String idcard; + + //头像 + private String profilePicture; + + /** + * 角色列表,存储为 MySQL JSON 类型,读取时自动映射为 List + * 取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key,例如 [1,2,3] + */ + @Convert(converter = ListIntegerJsonConverter.class) +// @Column(name = "roles", columnDefinition = "json") + @Column(name = "roles",columnDefinition = "TEXT") + private List roles; + + // 一对多:关联用户分组中间表,实现多对多业务 + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JoinColumn(name = "userId", insertable = false, updatable = false) + private List userGroupRelList; + + // 是否禁用:存储为 StatusEnum(0=正常/未禁用,1=禁用),与 GroupTypeEnum 同机制;默认启用 + @Convert(converter = StatusEnumConverter.class) + private StatusEnum disabled; + //是否过期 + private Boolean expired=false; + + /** + * 用户类型:0=普通用户,1=超级管理员,2=学校管理员(见 UserTypeEnum)。 + * 由应用层根据用户账号/角色推导:内置超级管理员账号固定为 1,其余账号含 ADMIN(7) 角色则为 2,默认 0。 + */ + @Convert(converter = UserTypeEnumConverter.class) + private UserTypeEnum type; + +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserAppRoleRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserAppRoleRel.java new file mode 100644 index 0000000..631a609 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysUserAppRoleRel.java @@ -0,0 +1,49 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/27 + * @description 用户-业务角色关系表(逻辑外键,无物理 FK)。
+ * 一个用户可绑定多个业务角色;一个角色也可绑定多个用户。
+ */ +@NoArgsConstructor +@Data +@Entity +@Table( + name = "sys_user_app_role_rel", + indexes = { + @Index(name = "idx_user_app_role_user", columnList = "user_id"), + @Index(name = "idx_user_app_role_role", columnList = "role_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_user_app_role", columnNames = {"user_id", "role_id"}) + } +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysUserAppRoleRel extends BaseEntity { + + /** + * 用户ID(逻辑外键 → SysUser.id)。
+ */ + @NotNull + @Column(name = "user_id", nullable = false) + private Integer userId; + + /** + * 角色ID(逻辑外键 → SysAppRole.id)。
+ */ + @NotNull + @Column(name = "role_id", nullable = false) + private Integer roleId; +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java b/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java new file mode 100644 index 0000000..8e78664 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java @@ -0,0 +1,48 @@ +package com.yunhai.userbase.entity; + +import com.yunhai.userbase.common.validation.ValidationGroups; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * @author chenx + * @date 2026/7/15 15:05 + * @description 用户当前角色 + */ +@Data +@NoArgsConstructor +@Entity +@Table( + name = "sys_user_current_role", + uniqueConstraints = @UniqueConstraint(columnNames = {"userId"}), + indexes = {@Index(columnList = "userId")} +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysUserCurrentRole { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @NotNull(groups = ValidationGroups.Update.class) + private Integer id; + + /** + * 用户ID,一对一唯一 + */ + @Column(nullable = false) + private Integer userId; + + /** + * 用户切换选中的学校ID(根级group) + */ + private Integer schoolId; + /** + * 用户切换后生效角色ID + */ + private Integer roleId; + +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java new file mode 100644 index 0000000..4e483b3 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java @@ -0,0 +1,50 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * @author chenx + * @date 2026/7/15 15:03 + * @description 组用户多对多关联表 + */ + +@Data +@NoArgsConstructor +@Entity +@Table(name = "sys_user_group_rel") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysUserGroupRel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @NotNull(groups = Update.class) + private Integer id; + /** 用户ID */ + private Integer userId; + /** 分组ID */ + private Integer groupId; + /** 是否主归属 1=本职主学校/部门 0=兼职兼任 */ + private Integer isMain; + + private LocalDateTime createTime; + + // 关联用户,仅查询,不维护外键 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "userId", referencedColumnName = "id", insertable = false, updatable = false) + private SysUser user; + + // 关联分组,仅查询,不维护外键 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "groupId", referencedColumnName = "id", insertable = false, updatable = false) + private SysGroup group; + + + public @interface Update { + } +} diff --git a/src/main/java/com/yunhai/userbase/enums/BizType.java b/src/main/java/com/yunhai/userbase/enums/BizType.java new file mode 100644 index 0000000..625634c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/BizType.java @@ -0,0 +1,39 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Arrays; +import java.util.Optional; + +/** + * @author chenx + * @date 2026/1/5 11:15 + * @description 虚拟节点类型 + */ + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public enum BizType { + + ROOT (1), + TEXTBOOK (2), + GRADE (3), + KNOWLEDGE (4), + REALLY (5), + ; + + private Integer key; + + public static BizType getByKey(Integer key){ + BizType[] enums = BizType.values(); + Optional opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny(); + if(opt.isPresent()){ + return opt.get(); + }else { + return null; + } + } +} diff --git a/src/main/java/com/yunhai/userbase/enums/BookType.java b/src/main/java/com/yunhai/userbase/enums/BookType.java new file mode 100644 index 0000000..c4b8ad9 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/BookType.java @@ -0,0 +1,50 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Arrays; +import java.util.Optional; + +/** + * @author chenx + * @date 2026/1/16 9:32 + * @description 资源类型 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public enum BookType { + /** 教材 */ + TextBook (1,"教材"), + /** 教辅 */ + TeachingTutoring (2,"教辅"), + /** 知识点 */ + KnowledgePoint (3,"知识点"), + ; + + private Integer key; + private String value; + + public static BookType getByKey(Integer key){ + BookType[] enums = BookType.values(); + Optional opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny(); + if(opt.isPresent()){ + return opt.get(); + }else { + return null; + } + } + + public static String getValue(Integer key) { + for (BookType bookType : BookType.values()) { + if (bookType.getKey().equals(key)) { + return bookType.getValue(); + } + } + return null; + } + + +} diff --git a/src/main/java/com/yunhai/userbase/enums/ClientEnum.java b/src/main/java/com/yunhai/userbase/enums/ClientEnum.java new file mode 100644 index 0000000..1451dc8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/ClientEnum.java @@ -0,0 +1,35 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/07/13 + * @description 客户端来源 + */ +@Getter +@AllArgsConstructor +public enum ClientEnum implements IEnum { + + PC_BROWSER(1, "Pc浏览器"), + MOBILE_APP(2, "移动App"), + SYSTEM_MODULE(4, "系统模块"), + WECHAT_OFFICIAL(8, "微信公众号"), + WORK_WECHAT(16, "企业微信"), + DING_TALK(32, "钉钉"), + STUDENT_DEVICE(64, "学伴机"); + + private Integer key; + private String value; + + @Override + public Integer getKey() { + return this.key; + } + + @Override + public String getValue() { + return this.value; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/enums/GenderEnum.java b/src/main/java/com/yunhai/userbase/enums/GenderEnum.java new file mode 100644 index 0000000..9650178 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/GenderEnum.java @@ -0,0 +1,30 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/07/13 + * @description 性别 + */ +@Getter +@AllArgsConstructor +public enum GenderEnum implements IEnum { + + FEMALE(0, "女"), + MALE(1, "男"); + + private Integer key; + private String value; + + @Override + public Integer getKey() { + return this.key; + } + + @Override + public String getValue() { + return this.value; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/enums/GroupTypeEnum.java b/src/main/java/com/yunhai/userbase/enums/GroupTypeEnum.java new file mode 100644 index 0000000..19c3c55 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/GroupTypeEnum.java @@ -0,0 +1,36 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/07/13 + * @description 分组类型 + */ +@Getter +@AllArgsConstructor +public enum GroupTypeEnum implements IEnum { + + NORMAL_GROUP(1, "普通组"), + SCHOOL_GROUP(2, "学校组"), + GRADE_GROUP(3, "年级组"), + CLASS_GROUP(4, "班级组"), + AREA_GROUP(5, "区域组"), + STAGE_GROUP(6, "学段组"), + TEACHER_GROUP(7, "教师组"), + STUDENT_GROUP(8, "学生组"); + + private Integer key; + private String value; + + @Override + public Integer getKey() { + return this.key; + } + + @Override + public String getValue() { + return this.value; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/enums/IEnum.java b/src/main/java/com/yunhai/userbase/enums/IEnum.java new file mode 100644 index 0000000..491f759 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/IEnum.java @@ -0,0 +1,65 @@ +package com.yunhai.userbase.enums; + +import com.yunhai.userbase.utils.EnumUtils; +import java.util.List; +import java.util.Map; + +/** + * 统一枚举规范接口,仅定义契约getKey/getValue + * 内置静态工具方法,业务直接 IEnum.xxx(枚举类) 调用 + * @param key类型 + */ +public interface IEnum { + + /** 获取编码key */ + T getKey(); + + /** 获取中文展示value */ + String getValue(); + + // ====================== 静态工具入口(统一对外调用) ====================== + /** 获取枚举全部实例列表 */ + static & IEnum> List toList(Class clazz) { + return EnumUtils.toList(clazz); + } + + /** 根据key获取枚举,无匹配返回null */ + static & IEnum> E getByKey(Class clazz, Object key) { + return EnumUtils.getEnumByKey(clazz, key); + } + + /** 根据key严格获取,不存在抛异常(对应fromKey) */ + static & IEnum> E fromKey(Class clazz, Object key) { + return EnumUtils.fromKey(clazz, key); + } + + /** 根据key获取中文value,无匹配返回空串 */ + static & IEnum> String getValueByKey(Class clazz, Object key) { + return EnumUtils.getValue(clazz, key); + } + + /** 根据中文value获取key字符串,无匹配返回空串 */ + static & IEnum> String getKeyByValue(Class clazz, String value) { + return EnumUtils.getKey(clazz, value); + } + + /** 判断key是否存在 */ + static & IEnum> boolean existsKey(Class clazz, Object key) { + return EnumUtils.isExistKey(clazz, key); + } + + /** 判断value是否存在 */ + static & IEnum> boolean existsValue(Class clazz, String value) { + return EnumUtils.isExistValue(clazz, value); + } + + /** 转完整Map列表(包含enum实例、name、key、value) */ + static & IEnum> List> toFullMapList(Class clazz) { + return EnumUtils.toMapList(clazz); + } + + /** 转下拉简易Map列表,自定义key/value字段名 */ + static & IEnum> List> toSimpleMapList(Class clazz, String keyMapName, String valueMapName) { + return EnumUtils.toMapList(clazz, keyMapName, valueMapName); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/enums/IntegEnum.java b/src/main/java/com/yunhai/userbase/enums/IntegEnum.java new file mode 100644 index 0000000..81095fb --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/IntegEnum.java @@ -0,0 +1,31 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/07/13 + * @description 集成方式 + */ +@Getter +@AllArgsConstructor +public enum IntegEnum implements IEnum { + + INNER(0, "内置"), + WEB(1, "Web"), + APP_JUMP(3, "App跳转"); + + private Integer key; + private String value; + + @Override + public Integer getKey() { + return this.key; + } + + @Override + public String getValue() { + return this.value; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/enums/LogLevel.java b/src/main/java/com/yunhai/userbase/enums/LogLevel.java new file mode 100644 index 0000000..606d3e8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/LogLevel.java @@ -0,0 +1,41 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 操作日志级别。
+ * 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)。
+ * + * @author chenx + * @date 2026/7/28 + */ +@Getter +@AllArgsConstructor +public enum LogLevel { + + IMPORTANT(1, "重要(删除)"), + SECONDARY(2, "次要(新增/编辑)"), + GENERAL(3, "一般(查询)"); + + private final int code; + private final String desc; + + /** + * 根据 code 反查级别。 + * + * @param code 级别编码 + * @return 对应级别,未知编码默认次要 + */ + public static LogLevel fromCode(Integer code) { + if (code == null) { + return SECONDARY; + } + for (LogLevel level : values()) { + if (level.code == code) { + return level; + } + } + return SECONDARY; + } +} diff --git a/src/main/java/com/yunhai/userbase/enums/ResourceSourceEnum.java b/src/main/java/com/yunhai/userbase/enums/ResourceSourceEnum.java new file mode 100644 index 0000000..a0d2c4f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/ResourceSourceEnum.java @@ -0,0 +1,21 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/1/5 13:53 + * @description 资源来源 + */ +@AllArgsConstructor +@Getter +public enum ResourceSourceEnum { + ZIYUAN("ZIYUAN","资源库"), + LOCAL("LOCAL","平台资源库"), + ; + + private final String enKey; + private final String cnKey; + +} diff --git a/src/main/java/com/yunhai/userbase/enums/RootNode.java b/src/main/java/com/yunhai/userbase/enums/RootNode.java new file mode 100644 index 0000000..dac0df3 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/RootNode.java @@ -0,0 +1,49 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Arrays; +import java.util.Optional; + +/** + * @author chenx + * @date 2025/12/15 13:29 + * @description 根目录树类型 + */ + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public enum RootNode { + + ZIYUANKU (1,"ZIYUAN","资源"), + TUTORING (2,"TUTORING","辅导"), + QUESTION (3,"QUESTION","题库"), + SHIJUAN (4,"SHIJUAN","试卷"), + ZHUANXIANG (5,"ZHUANXIANG","专项"), + ; + + private Integer key; + private String enKey; + private String value; + + + public static RootNode getByKey(Integer key){ + RootNode[] enums = RootNode.values(); + Optional opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny(); + if(opt.isPresent()){ + return opt.get(); + }else { + return null; + } + } + + public static RootNode fromKey(Integer key) { + for (RootNode n : values()) if (n.key.equals(key)) return n; + throw new IllegalArgumentException("非法 RootNode key: " + key); + } + + +} diff --git a/src/main/java/com/yunhai/userbase/enums/SchoolStage.java b/src/main/java/com/yunhai/userbase/enums/SchoolStage.java new file mode 100644 index 0000000..512d41b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/SchoolStage.java @@ -0,0 +1,66 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Arrays; +import java.util.Optional; + +/** + * @author chenx + * @date 2025/12/15 15:55 + * @description 学段 + */ + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public enum SchoolStage { + + PRIMARY (2,"PRIMARY","小学"), + MIDDLE (3,"MIDDLE","初中"), + HIGH (4,"HIGH","高中"), + ; + + private Integer key; + private String enKey; + private String value; + + + public static SchoolStage getByKey(Integer key){ + SchoolStage[] enums = SchoolStage.values(); + Optional opt = Arrays.stream(enums).filter(item -> item.getKey().equals(key)).findAny(); + if(opt.isPresent()){ + return opt.get(); + }else { + return null; + } + } + + public static String getName(Integer key) { + for (SchoolStage stage : SchoolStage.values()) { + if (stage.getKey().equals(key)) { + return stage.getValue(); + } + } + return null; + } + + + public static SchoolStage getByValue(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return Arrays.stream(values()) + .filter(item -> item.getValue().equals(value)) + .findAny() + .orElse(null); + } + + + public static SchoolStage fromKey(Integer key) { + for (SchoolStage n : values()) if (n.key.equals(key)) return n; + throw new IllegalArgumentException("非法 SchoolStage key: " + key); + } +} diff --git a/src/main/java/com/yunhai/userbase/enums/StatusEnum.java b/src/main/java/com/yunhai/userbase/enums/StatusEnum.java new file mode 100644 index 0000000..0531e46 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/StatusEnum.java @@ -0,0 +1,30 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/07/13 + * @description 通用启用禁用状态 + */ +@Getter +@AllArgsConstructor +public enum StatusEnum implements IEnum { + + NORMAL(0, "正常"), + DISABLED(1, "禁用"); + + private Integer key; + private String value; + + @Override + public Integer getKey() { + return this.key; + } + + @Override + public String getValue() { + return this.value; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/enums/SysModeEnum.java b/src/main/java/com/yunhai/userbase/enums/SysModeEnum.java new file mode 100644 index 0000000..1f4a58b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/SysModeEnum.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/07/25 + * @description 操作模式枚举(应用授权等场景:追加/覆盖/删除) + */ +@Getter +@AllArgsConstructor +public enum SysModeEnum implements IEnum { + + APPEND(0, "追加"), + OVERWRITE(1, "覆盖"), + DELETE(2, "删除"); + + private Integer key; + private String value; + + @Override + public Integer getKey() { + return this.key; + } + + @Override + public String getValue() { + return this.value; + } + +} diff --git a/src/main/java/com/yunhai/userbase/enums/SysRoleEnum.java b/src/main/java/com/yunhai/userbase/enums/SysRoleEnum.java new file mode 100644 index 0000000..059d55e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/SysRoleEnum.java @@ -0,0 +1,40 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Arrays; +import java.util.Optional; + +/** + * @author chenx + * @date 2026/07/13 + * @description 用户角色枚举 + */ +@Getter +@AllArgsConstructor +public enum SysRoleEnum implements IEnum{ + + TEACHER(1, "老师"), + SCHOOL_LEADER(2, "校领导"), + STUDENT(3, "学生"), + PARENT(4, "家长"), + BUREAU_LEADER(5, "局领导"), + WORK_ACCOUNT(6, "工作号"), + ADMIN(7, "管理员"); + + private Integer key; + private String value; + + @Override + public Integer getKey() { + return this.key; + } + + @Override + public String getValue() { + return this.value; + } + +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/enums/SystemGrade.java b/src/main/java/com/yunhai/userbase/enums/SystemGrade.java new file mode 100644 index 0000000..7bc5ac3 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/SystemGrade.java @@ -0,0 +1,38 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @ Description : java类作用描述 + * @Author : cls + * @ CreateDate : 2020/2/21 18:39 + * @ Company : bestsch + */ +@AllArgsConstructor +@NoArgsConstructor +@Getter +public enum SystemGrade { + + one(1,"一年级",2),two(2,"二年级",2), + three(3,"三年级",2),four(4,"四年级",2), + five(5,"五年级",2),six(6,"六年级",2), + seven(7,"七年级",3),eight(8,"八年级",3), + nine(9,"九年级",3),ten(10,"高一",4), + eleven(11,"高二",4),twelve(12,"高三",4); + + private Integer gradeId; + private String gradeName; + private Integer stage; + + public static String getName(Integer gradeId) { + for (SystemGrade grade : SystemGrade.values()) { + if (grade.getGradeId().equals(gradeId)) { + return grade.gradeName; + } + } + return null; + } + +} diff --git a/src/main/java/com/yunhai/userbase/enums/UserType.java b/src/main/java/com/yunhai/userbase/enums/UserType.java new file mode 100644 index 0000000..73c9e56 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/UserType.java @@ -0,0 +1,28 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +/** + * @program: UserProfile + * @description: 用户类别 + * @author: chenx + * @create: 2023-05-26 10:46 + **/ + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public enum UserType { + + TEACHER (1,"教师","Teacher"), + STUDENT (2,"学生","Student"), + ; + + private Integer key; + private String value; + private String code; + +} diff --git a/src/main/java/com/yunhai/userbase/enums/UserTypeEnum.java b/src/main/java/com/yunhai/userbase/enums/UserTypeEnum.java new file mode 100644 index 0000000..f83297b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/UserTypeEnum.java @@ -0,0 +1,34 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/08/03 + * @description 用户类型枚举 + * - NORMAL(0) 普通用户:默认类型 + * - SUPER_ADMIN(1) 超级管理员:系统内置种子账号(yunhaisch / yunhaisch2 / yunhaisch3),由初始化脚本写入 + * - SCHOOL_ADMIN(2) 学校管理员:拥有管理身份(角色含 ADMIN(7))的普通账号(非超级管理员) + */ +@Getter +@AllArgsConstructor +public enum UserTypeEnum implements IEnum { + + NORMAL(0, "普通用户"), + SUPER_ADMIN(1, "超级管理员"), + SCHOOL_ADMIN(2, "学校管理员"); + + private Integer key; + private String value; + + @Override + public Integer getKey() { + return this.key; + } + + @Override + public String getValue() { + return this.value; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/BaseDict.java b/src/main/java/com/yunhai/userbase/model/BaseDict.java new file mode 100644 index 0000000..5873a5e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/BaseDict.java @@ -0,0 +1,34 @@ +package com.yunhai.userbase.model; + +/** + * @program: QualityEvaluate + * @description: 系统字典类 + * @author: chenx + * @create: 2023-05-15 17:17 + **/ +public class BaseDict { + public Integer k; + public String v; + + + public int getK() { + return this.k; + } + + public void setK(int k) { + this.k = k; + } + + public String getV() { + return this.v; + } + + public void setV(String v) { + this.v = v; + } + + public BaseDict(Integer k, String v) { + this.k = k; + this.v = v; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/BasePageQuery.java b/src/main/java/com/yunhai/userbase/model/BasePageQuery.java new file mode 100644 index 0000000..ca04263 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/BasePageQuery.java @@ -0,0 +1,16 @@ +package com.yunhai.userbase.model; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/7/21 21:41 + * @description + */ +@Data +public class BasePageQuery { + private int pageIndex = 1; + private int pageSize = 10; + /** 通用模糊搜索关键词 */ + private String keyword; +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/model/BasePageResult.java b/src/main/java/com/yunhai/userbase/model/BasePageResult.java new file mode 100644 index 0000000..7e60ed1 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/BasePageResult.java @@ -0,0 +1,39 @@ +package com.yunhai.userbase.model; + +import lombok.Data; + +import java.util.List; + +/** + * 统一分页响应外壳。
+ * 由 Controller 层负责装配(见 {@link com.yunhai.userbase.utils.PageUtils#toResult}), + * Service 层仍返回 Spring Data 的 {@link org.springframework.data.domain.Page},保持与传输协议解耦。
+ * + * @author chenx + * @date 2026/7/21 21:40 + * @description 统一分页响应外壳 + */ +@Data +public class BasePageResult { + /** 总记录数 */ + private int total; + /** 当前页数据 */ + private List content; + /** 当前页码(从 1 起,与 {@link BasePageQuery#getPageIndex()} 约定一致) */ + private int pageIndex; + /** 每页条数 */ + private int pageSize; + /** 总页数 */ + private int totalPages; + /** 是否还有下一页 */ + private boolean hasNext; + + public BasePageResult(List content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) { + this.content = content; + this.total = total; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + this.totalPages = totalPages; + this.hasNext = hasNext; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/BaseTree.java b/src/main/java/com/yunhai/userbase/model/BaseTree.java new file mode 100644 index 0000000..f8f93cc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/BaseTree.java @@ -0,0 +1,19 @@ +package com.yunhai.userbase.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class BaseTree { + public Integer id; + public String name; + public Integer pid ; + public Integer type; + public List children; + +} diff --git a/src/main/java/com/yunhai/userbase/model/Excel/ExcelKnowledge.java b/src/main/java/com/yunhai/userbase/model/Excel/ExcelKnowledge.java new file mode 100644 index 0000000..edc2d24 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/Excel/ExcelKnowledge.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.model.Excel; + +import cn.afterturn.easypoi.excel.annotation.Excel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author chenx + * @date 2026/1/19 19:45 + * @description 教材导入 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ExcelKnowledge { + + @Excel(name = "第一层", width = 20) + private String first; + + @Excel(name = "第二层", width = 20) + private String second; + + @Excel(name = "第三层", width =20) + private String third; + + @Excel(name = "第四层", width = 20) + private String fourth; + + @Excel(name = "第五层", width = 20) + private String five; + +} diff --git a/src/main/java/com/yunhai/userbase/model/Excel/ExcelResearchFill.java b/src/main/java/com/yunhai/userbase/model/Excel/ExcelResearchFill.java new file mode 100644 index 0000000..68256ad --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/Excel/ExcelResearchFill.java @@ -0,0 +1,28 @@ +package com.yunhai.userbase.model.Excel; + +import cn.afterturn.easypoi.excel.annotation.Excel; +import lombok.Data; + +/** + * @program: ResearchLearn + * @description: 导入研学活动填报实体类 + * @author: chenx + * @create: 2023-07-21 14:12 + **/ + +@Data +public class ExcelResearchFill { + + @Excel(name = "年级") + private String gradeName; + + @Excel(name = "班级") + private String className; + + @Excel(name = "姓名") + private String stuName; + + @Excel(name = "研学/活动名称") + private String researchName; + +} diff --git a/src/main/java/com/yunhai/userbase/model/Operator.java b/src/main/java/com/yunhai/userbase/model/Operator.java new file mode 100644 index 0000000..3f90288 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/Operator.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.model; + +import com.bestsch.openapi.UserSysRole; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; + +/** + *功能描述 :操作人信息 + * @author chenx + * @date 2026/1/5 13:31 + * @description + */ +@AllArgsConstructor +@NoArgsConstructor +@Data +public class Operator { + private Integer userId; + private Integer schoolSid; + private Integer roleId; + private Date createTime=new Date(); + + public static Operator of(UserSysRole curRole){ + return new Operator(curRole.getUserId(),curRole.getSchId(),curRole.getRoleId(),new Date()); + } + +} diff --git a/src/main/java/com/yunhai/userbase/model/Page.java b/src/main/java/com/yunhai/userbase/model/Page.java new file mode 100644 index 0000000..e58b366 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/Page.java @@ -0,0 +1,72 @@ +package com.yunhai.userbase.model; + +import org.apache.commons.lang3.StringUtils; + +public class Page { + private int current; + private int pageSize; + private long count; + + public Page() { + } + + public int getCurrent() { + return this.current; + } + + public void setCurrent(int current) { + this.current = current; + } + + public int getPageSize() { + return this.pageSize == 0 ? 10 : this.pageSize; + } + + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } + + public void setPageSize(String pageSize) { + if (!StringUtils.isEmpty(pageSize) && StringUtils.isNumeric(pageSize)) { + this.pageSize = Integer.valueOf(pageSize); + } + + } + + public long getCount() { + return this.count; + } + + public void setCount(long count) { + this.count = count; + } + + + + public int getPageCount() { + return this.getPageSize() != 0 ? (int)Math.ceil((double)this.count / (double)this.getPageSize()) : 0; + } + + public int getStartIndex() { + return (this.getCurrent() - 1) * this.getPageSize(); + } + + public void setCurrent(String current) { + if (!StringUtils.isEmpty(current) && StringUtils.isNumeric(current)) { + this.current = Integer.valueOf(current); + } + + } + + public void setCount(String count) { + if (!StringUtils.isEmpty(count) && StringUtils.isNumeric(count)) { + this.count = Long.valueOf(count); + } + + } + + public int getEndIndex() { + return this.getStartIndex() + this.getPageSize(); + } + +} diff --git a/src/main/java/com/yunhai/userbase/model/StartEndDate.java b/src/main/java/com/yunhai/userbase/model/StartEndDate.java new file mode 100644 index 0000000..94c1ea7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/StartEndDate.java @@ -0,0 +1,16 @@ +package com.yunhai.userbase.model; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class StartEndDate { + private Date startDate; + private Date endDate; +} diff --git a/src/main/java/com/yunhai/userbase/model/Tree.java b/src/main/java/com/yunhai/userbase/model/Tree.java new file mode 100644 index 0000000..2523235 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/Tree.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class Tree { + private String label; + private String value; + private Integer type = 0; + private List children; +} diff --git a/src/main/java/com/yunhai/userbase/model/appauthz/AppAuthzAppRow.java b/src/main/java/com/yunhai/userbase/model/appauthz/AppAuthzAppRow.java new file mode 100644 index 0000000..15248e8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/appauthz/AppAuthzAppRow.java @@ -0,0 +1,37 @@ +package com.yunhai.userbase.model.appauthz; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 应用授权查询的扁平投影行(含 categoryId 供分组)。
+ * 仅取授权列表所需字段,避免加载 appSecret 等无关大字段。
+ * + * @author chenx + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AppAuthzAppRow { + + /** + * 应用类型ID(分组键)。
+ */ + private Integer categoryId; + + /** + * 应用ID。
+ */ + private Integer id; + + /** + * 应用名称。
+ */ + private String name; + + /** + * 应用图标(pcIcon)。
+ */ + private String icon; +} diff --git a/src/main/java/com/yunhai/userbase/model/appauthz/AppAuthzCategoryVo.java b/src/main/java/com/yunhai/userbase/model/appauthz/AppAuthzCategoryVo.java new file mode 100644 index 0000000..7021c9b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/appauthz/AppAuthzCategoryVo.java @@ -0,0 +1,48 @@ +package com.yunhai.userbase.model.appauthz; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 应用授权视图:应用类型 + 该类型下的应用列表。
+ * + * @author chenx + */ +@Data +public class AppAuthzCategoryVo { + + /** + * 应用类型ID。
+ */ + private Integer id; + + /** + * 应用类型名称。
+ */ + private String name; + + /** + * 该类型下的应用列表。
+ */ + private List app = new ArrayList<>(); + + /** + * 授权用的应用精简项(id/name/icon)。
+ */ + @Data + public static class AppItem { + + /** + * 应用ID。
+ */ + private Integer id; + + /** + * 应用名称。
+ */ + private String name; + + } +} diff --git a/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryQuery.java b/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryQuery.java new file mode 100644 index 0000000..e0d659d --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryQuery.java @@ -0,0 +1,13 @@ +package com.yunhai.userbase.model.appcategory; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/7/15 22:35 + * @description + */ +@Data +public class AppCategoryQuery { + private String name; +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryRequest.java b/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryRequest.java new file mode 100644 index 0000000..be296a3 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryRequest.java @@ -0,0 +1,22 @@ +package com.yunhai.userbase.model.appcategory; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import com.yunhai.userbase.common.validation.ValidationGroups; +import lombok.Data; + +/** + * @author chenx + * @date 2026/7/15 22:35 + * @description + */ +@Data +public class AppCategoryRequest { + @NotNull(groups = ValidationGroups.Update.class, message = "编辑时ID不能为空") + private Integer id; + + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "类别名称不能为空") + private String name; + + private Integer sortNo; +} diff --git a/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryVo.java b/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryVo.java new file mode 100644 index 0000000..65a2060 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/appcategory/AppCategoryVo.java @@ -0,0 +1,15 @@ +package com.yunhai.userbase.model.appcategory; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/7/15 22:35 + * @description + */ +@Data +public class AppCategoryVo { + private Integer id; + private String name; + private Integer sortNo; +} diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoQuery.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoQuery.java new file mode 100644 index 0000000..a78b759 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoQuery.java @@ -0,0 +1,22 @@ +package com.yunhai.userbase.model.application; + +import lombok.Data; + +/** + * 应用信息查询条件。
+ * 用于列表查询时封装前端传入的筛选参数。
+ * + * @author chenx + */ +@Data +public class ApplicationInfoQuery { + /** + * 应用名称,用于模糊匹配。
+ */ + private String name; + + /** + * 应用类别ID,用于精确筛选。
+ */ + private Integer categoryId; +} diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java new file mode 100644 index 0000000..99abc9a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java @@ -0,0 +1,76 @@ +package com.yunhai.userbase.model.application; + +import com.yunhai.userbase.common.validation.ValidationGroups; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 应用信息新增/修改入参。
+ * 用于接收前端提交的应用数据,并按分组做参数校验。
+ * 说明:appKey/appSecret 为系统生成字段,不在此入参中暴露。
+ * + * @author chenx + */ +@Data +public class ApplicationInfoRequest { + /** + * 主键ID,编辑时必填。
+ */ + @NotNull(groups = ValidationGroups.Update.class, message = "编辑时ID不能为空") + private Integer id; + + /** + * 应用名称,新增与编辑均必填。
+ */ + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用名称不能为空") + private String name; + + /** + * 应用类别ID,新增与编辑均必填。
+ */ + @NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空") + private Integer categoryId; + + /** + * 应用描述。
+ */ + private String desc; + + /** + * 应用前端类型列表。
+ */ + private List clients; + + /** + * PC应用访问地址。
+ */ + private String pcUrl; + + /** + * PC应用图标地址。
+ */ + private String pcIcon; + + /** + * App访问地址(移动端主地址)。
+ */ + private String appUrl; + + /** + * App应用图标地址。
+ */ + private String appIcon; + + /** + * 公众号应用地址。
+ */ + private String officialAccountUrl; + + /** + * 钉钉应用地址。
+ */ + private String dingtalkUrl; +} diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoSortVo.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoSortVo.java new file mode 100644 index 0000000..ddc0a93 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoSortVo.java @@ -0,0 +1,34 @@ +package com.yunhai.userbase.model.application; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 应用信息返回视图。
+ * 用于接口返回给前端的应用数据。
+ * + * @author chenx + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationInfoSortVo { + /** + * 主键ID。
+ */ + private Integer id; + + /** + * 应用名称。
+ */ + private String name; + + /** + * 排序号,仅在同一个应用类型内有效;未排序时为 null。
+ */ + private Integer sortNo; + +} diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java new file mode 100644 index 0000000..5b669ef --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java @@ -0,0 +1,84 @@ +package com.yunhai.userbase.model.application; + +import lombok.Data; + +import java.util.List; + +/** + * 应用信息返回视图。 + * 用于接口返回给前端的应用数据。 + * + * @author chenx + */ +@Data +public class ApplicationInfoVo { + /** + * 主键ID。
+ */ + private Integer id; + + /** + * 应用名称。
+ */ + private String name; + + /** + * 应用类别ID。
+ */ + private Integer categoryId; + + /** + * 排序号,仅在同一个应用类型内有效;未排序时为 null。
+ */ + private Integer sortNo; + + /** + * 应用描述。
+ */ + private String desc; + + /** + * 系统生成的唯一标识(appKey),整型,从100000起自增。
+ */ + private Integer appKey; + + /** + * 密钥(appSecret),与数据库 app_secret 字段一致,字符串类型。
+ */ + private String appSecret; + + /** + * 应用前端类型列表。
+ */ + private List clients; + + /** + * PC应用访问地址。
+ */ + private String pcUrl; + + /** + * PC应用图标地址。
+ */ + private String pcIcon; + + /** + * App访问地址。
+ */ + private String appUrl; + + /** + * App应用图标地址。
+ */ + private String appIcon; + + /** + * 公众号应用地址。
+ */ + private String officialAccountUrl; + + /** + * 钉钉应用地址。
+ */ + private String dingtalkUrl; +} diff --git a/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleQuery.java b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleQuery.java new file mode 100644 index 0000000..e16dc7c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleQuery.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.model.appmodule; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/7/22 + * @description 应用模块查询条件。
+ */ +@Data +public class AppModuleQuery { + /** 所属应用ID(按应用过滤模块树)。
*/ + private Integer appId; + /** 模块名称(模糊匹配)。
*/ + private String name; + /** 父模块ID(传入则以该模块为根,递归返回其完整子树;不传则顶层只显示 level-1 根模块)。
*/ + private Integer parentId; +} diff --git a/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java new file mode 100644 index 0000000..ff49dfd --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java @@ -0,0 +1,36 @@ +package com.yunhai.userbase.model.appmodule; + +import com.yunhai.userbase.common.validation.ValidationGroups; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/22 + * @description 应用模块请求体。
+ */ +@Data +public class AppModuleRequest { + @NotNull(groups = ValidationGroups.Update.class, message = "编辑时ID不能为空") + private Integer id; + + /** 所属应用ID(创建时必填,创建后不可改)。
*/ + @NotNull(groups = ValidationGroups.Create.class, message = "所属应用不能为空") + private Integer appId; + + /** 父模块ID;根模块传 null。
*/ + private Integer parentId; + + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空") + private String name; + + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空") + private String customId; + + /** 模块路径(前端路由/资源路径)。
*/ + private String path; + + private Integer sortNo; +} diff --git a/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleVo.java b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleVo.java new file mode 100644 index 0000000..85641b0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleVo.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.model.appmodule; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/22 + * @description 应用模块视图(树形,children 递归存放子模块)。
+ */ +@Data +public class AppModuleVo { + private Integer id; + private Integer appId; + private Integer parentId; + private String name; + private String customId; + private String path; + private Integer level; + private Integer sortNo; + /** 子模块(递归);默认空列表,避免 JSON 序列化出现 null。 */ + private List children = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzAppVo.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzAppVo.java new file mode 100644 index 0000000..0d41d16 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzAppVo.java @@ -0,0 +1,34 @@ +package com.yunhai.userbase.model.approle; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 角色授权页应用节点视图:应用信息 + 其下模块权限树。
+ * 普通角色只返回其所属应用;全应用角色返回系统全部应用。
+ * + * @author chenx + */ +@Data +public class AppRoleAuthzAppVo { + + /** 应用ID。 */ + private Integer id; + + /** 应用名称。 */ + private String name; + + /** 应用唯一标识 appKey。 */ + private Integer appKey; + + /** PC 应用图标地址。 */ + private String pcIcon; + + /** 排序号。 */ + private Integer sortNo; + + /** 该应用下的模块权限树(含勾选回显)。 */ + private List modules = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzModuleVo.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzModuleVo.java new file mode 100644 index 0000000..1ece8da --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzModuleVo.java @@ -0,0 +1,42 @@ +package com.yunhai.userbase.model.approle; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 角色授权页模块节点视图(树形):模块信息 + 该角色勾选状态。
+ * + * @author chenx + */ +@Data +public class AppRoleAuthzModuleVo { + + /** 模块ID。 */ + private Integer id; + + /** 父模块ID;根模块为 null。 */ + private Integer parentId; + + /** 模块名称。 */ + private String name; + + /** 自定义ID(应用内唯一业务编码)。 */ + private String customId; + + /** 模块路径。 */ + private String path; + + /** 层级(根=1)。 */ + private Integer level; + + /** 排序号。 */ + private Integer sortNo; + + /** 该角色是否已勾选该模块(sys_app_role_module_rel 存在记录即勾选)。 */ + private Boolean checked = Boolean.FALSE; + + /** 子模块(递归);默认空列表,避免 JSON 序列化出现 null。 */ + private List children = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleModuleAssignRequest.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleModuleAssignRequest.java new file mode 100644 index 0000000..25949f8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleModuleAssignRequest.java @@ -0,0 +1,27 @@ +package com.yunhai.userbase.model.approle; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 角色模块授权保存入参(全量覆盖)。
+ * moduleIds 为该角色本次勾选的模块ID全量列表;传空列表即清空该角色全部模块授权。
+ * + * @author chenx + */ +@Data +public class AppRoleModuleAssignRequest { + + /** + * 角色ID。
+ */ + @NotNull(message = "角色ID不能为空") + private Integer roleId; + + /** + * 勾选的模块ID全量列表。
+ */ + private List moduleIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBindRequest.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBindRequest.java new file mode 100644 index 0000000..8a4b952 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBindRequest.java @@ -0,0 +1,27 @@ +package com.yunhai.userbase.model.approle; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 角色绑定用户保存入参(全量覆盖,角色维度)。
+ * userIds 为该角色本次绑定的用户ID全量列表;传空列表即解绑该角色全部用户。
+ * + * @author chenx + */ +@Data +public class AppRoleUserBindRequest { + + /** + * 角色ID。
+ */ + @NotNull(message = "角色ID不能为空") + private Integer roleId; + + /** + * 绑定的用户ID全量列表。
+ */ + private List userIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserVo.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserVo.java new file mode 100644 index 0000000..d6e66a9 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserVo.java @@ -0,0 +1,21 @@ +package com.yunhai.userbase.model.approle; + +import lombok.Data; + +/** + * 角色已绑定用户列表项视图。
+ * + * @author chenx + */ +@Data +public class AppRoleUserVo { + + /** 用户ID。 */ + private Integer id; + + /** 账号。 */ + private String account; + + /** 真实姓名。 */ + private String realName; +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleQuery.java b/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleQuery.java new file mode 100644 index 0000000..6f20bc3 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleQuery.java @@ -0,0 +1,24 @@ +package com.yunhai.userbase.model.approle; + +import lombok.Data; + +/** + * 业务角色列表查询条件。
+ * + * @author chenx + */ +@Data +public class SysAppRoleQuery { + + /** 角色名称(模糊匹配)。 */ + private String name; + + /** 所属应用ID(精确匹配,仅命中普通角色)。 */ + private Integer appId; + + /** 是否全应用角色。 */ + private Boolean allAppFlag; + + /** 是否禁用。 */ + private Boolean disabled; +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleRequest.java b/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleRequest.java new file mode 100644 index 0000000..7140af5 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleRequest.java @@ -0,0 +1,50 @@ +package com.yunhai.userbase.model.approle; + +import com.yunhai.userbase.common.validation.ValidationGroups; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +/** + * 业务角色新增/修改入参。
+ * 普通角色:allAppFlag=false 且 appId 必填;全应用角色:allAppFlag=true 且 appId 必须为空(服务层校验)。
+ * + * @author chenx + */ +@Data +public class SysAppRoleRequest { + + /** + * 主键ID,编辑时必填。
+ */ + @NotNull(groups = ValidationGroups.Update.class, message = "编辑时ID不能为空") + private Integer id; + + /** + * 角色名称,新增与编辑均必填。
+ */ + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "角色名称不能为空") + private String name; + + /** + * 所属应用ID;普通角色必填,全应用角色必须为空(appId 与 allAppFlag 的匹配关系在服务层校验)。
+ */ + private Integer appId; + + /** + * 是否全应用角色,新增与编辑均必填。
+ */ + @NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "角色类型不能为空") + private Boolean allAppFlag; + + /** + * 角色备注。
+ */ + private String remark; + + /** + * 是否禁用;空值按不禁用处理。
+ */ + private Boolean disabled; +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleVo.java b/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleVo.java new file mode 100644 index 0000000..b4826cb --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/SysAppRoleVo.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.model.approle; + +import lombok.Data; + +/** + * 业务角色列表项视图。
+ * + * @author chenx + */ +@Data +public class SysAppRoleVo { + + /** 角色ID。 */ + private Integer id; + + /** 角色名称。 */ + private String name; + + /** 所属应用ID;全应用角色为 null。 */ + private Integer appId; + + /** 所属应用名称;全应用角色为 null。 */ + private String appName; + + /** 是否全应用角色。 */ + private Boolean allAppFlag; + + /** 角色备注。 */ + private String remark; + + /** 是否禁用。 */ + private Boolean disabled; +} diff --git a/src/main/java/com/yunhai/userbase/model/approle/UserAppRoleBindRequest.java b/src/main/java/com/yunhai/userbase/model/approle/UserAppRoleBindRequest.java new file mode 100644 index 0000000..18e038e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/approle/UserAppRoleBindRequest.java @@ -0,0 +1,27 @@ +package com.yunhai.userbase.model.approle; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 用户绑定角色保存入参(全量覆盖,用户维度)。
+ * roleIds 为该用户本次绑定的角色ID全量列表;传空列表即解绑该用户全部角色。
+ * + * @author chenx + */ +@Data +public class UserAppRoleBindRequest { + + /** + * 用户ID。
+ */ + @NotNull(message = "用户ID不能为空") + private Integer userId; + + /** + * 绑定的角色ID全量列表。
+ */ + private List roleIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAppAssignRequest.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAppAssignRequest.java new file mode 100644 index 0000000..7b63405 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAppAssignRequest.java @@ -0,0 +1,23 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 应用开通保存请求(功能1,全量覆盖式)。
+ * 前端传递组ID与勾选的应用ID列表;appIds 为空或空列表代表取消该组全部应用开通。
+ */ +@Data +public class GroupAppAssignRequest { + + /** 组ID。
*/ + @NotNull(message = "组ID不能为空") + private Integer groupId; + + /** 勾选的应用ID列表(全量);为空代表全部取消开通。
*/ + private List appIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAppVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAppVo.java new file mode 100644 index 0000000..6a8b799 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAppVo.java @@ -0,0 +1,30 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/7/23 + * @description 应用开通视图(功能1):系统全量应用 + 该组是否已开通。
+ */ +@Data +public class GroupAppVo { + + /** 应用ID。
*/ + private Integer id; + + /** 应用名称。
*/ + private String name; + + /** 应用类别ID。
*/ + private Integer categoryId; + + /** 应用唯一标识 appKey。
*/ + private Integer appKey; + + /** PC应用图标地址。
*/ + private String pcIcon; + + /** 该组是否已开通(用于复选框回显)。
*/ + private Boolean checked = Boolean.FALSE; +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzAppVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzAppVo.java new file mode 100644 index 0000000..c90d124 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzAppVo.java @@ -0,0 +1,35 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组授权应用视图(功能2):该组已开通的应用 + 其下模块权限树。
+ */ +@Data +public class GroupAuthzAppVo { + + /** 应用ID。
*/ + private Integer id; + + /** 应用名称。
*/ + private String name; + + /** 应用唯一标识 appKey。
*/ + private Integer appKey; + + /** PC应用图标地址。
*/ + private String pcIcon; + + /** 应用类型ID;用于按应用分类分组展示组授权面板。
*/ + private Integer categoryId; + + private Integer sortNo; + + /** 该应用下的模块权限树;默认空列表,避免 JSON 序列化出现 null。 */ + private List modules = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzCategoryVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzCategoryVo.java new file mode 100644 index 0000000..515af61 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzCategoryVo.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 组授权分类视图:按应用类型分组后,每个分类下挂载已开通的应用及其模块权限树。
+ * + * @author chenx + * @date 2026/7/23 + */ +@Data +public class GroupAuthzCategoryVo { + + /** 应用类型ID。 */ + private Integer id; + + /** 应用类型名称。 */ + private String name; + + /** 该分类下已开通的应用列表(含模块权限树)。 */ + private List apps = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzModuleVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzModuleVo.java new file mode 100644 index 0000000..3bc34a0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzModuleVo.java @@ -0,0 +1,45 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组授权模块节点视图(功能2,树形):模块信息 + 该组勾选状态与子组继承开关。
+ */ +@Data +public class GroupAuthzModuleVo { + + /** 模块ID。
*/ + private Integer id; + + /** 父模块ID;根模块为 null。
*/ + private Integer parentId; + + /** 模块名称。
*/ + private String name; + + /** 自定义ID(应用内唯一业务编码)。
*/ + private String customId; + + /** 模块路径。
*/ + private String path; + + /** 层级(根=1)。
*/ + private Integer level; + + /** 排序号。
*/ + private Integer sortNo; + + /** 该组是否已勾选该模块(sys_group_module_perm 存在记录即勾选)。
*/ + private Boolean checked = Boolean.FALSE; + + /** 是否子组继承(仅 checked=true 时有意义)。
*/ + private Boolean inheritFlag = Boolean.FALSE; + + /** 子模块(递归);默认空列表,避免 JSON 序列化出现 null。 */ + private List children = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModuleAuthzItem.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModuleAuthzItem.java new file mode 100644 index 0000000..ef3b2c4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModuleAuthzItem.java @@ -0,0 +1,47 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import javax.validation.constraints.NotNull; + +/** + * 组模块授权单项(增量式:对某个组的「某应用下的某模块」做追加/删除)。
+ * 与 GroupModulePermRequest(全量覆盖式 SaveModulePerms)互为补充:本类用于单条/批量增量操作,
+ * 由前端「组授权」面板逐条勾选或取消时提交。
+ *
+ * 字段语义(务必与前端对齐):
+ * - groupId:目标组ID
+ * - appId:应用ID(被授权的模块必须属于该应用,越权写别的应用的模块会被拒绝)
+ * - moduleId:模块ID;传 0 表示「该应用下的全部模块」(前端勾选应用根/整应用时用)
+ * - mode:操作模式,取值见 SysModeEnum —— 0=追加(APPEND,授予授权) / 2=删除(DELETE,取消授权);1=覆盖 不适用于单条增量,会被拒绝
+ * - isAll:是否对子组继承,直接映射现有 sys_group_module_perm.inherit_flag 列 —— 1=子孙组自动继承 / 0=仅本组生效
+ * + * @author chenx + */ +@Data +public class GroupModuleAuthzItem { + + /** 目标组ID。 */ + @NotNull(message = "组ID不能为空") + private Integer groupId; + + /** 应用ID(模块必须属于该应用)。 */ + @NotNull(message = "应用ID不能为空") + private Integer appId; + + /** + * 模块ID;传 0 表示「该应用下全部模块」。
+ * 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,
+ * 而是把 0 展开为应用下每个真实模块各写一条权限记录。 + */ + @NotNull(message = "模块ID不能为空(整应用传 0)") + private Integer moduleId; + + /** 操作模式:0=追加 / 2=删除(1=覆盖不适用)。 */ + @NotNull(message = "操作模式不能为空(0=追加/2=删除)") + private Integer mode; + + /** 是否对子组继承:1=子孙组继承 / 0=仅本组。映射 inherit_flag。 */ + @NotNull(message = "isAll不能为空(0=仅本组/1=子组继承)") + private Integer isAll; +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModulePermRequest.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModulePermRequest.java new file mode 100644 index 0000000..33a4202 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModulePermRequest.java @@ -0,0 +1,38 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组授权保存请求(功能2,全量覆盖式)。
+ * perms 为该组当前勾选的模块权限全量列表:有旧记录则更新 inheritFlag,无则新增,
+ * 不在列表中的旧记录物理删除(即取消勾选)。
+ */ +@Data +public class GroupModulePermRequest { + + /** 组ID。
*/ + @NotNull(message = "组ID不能为空") + private Integer groupId; + + /** 勾选的模块权限全量列表;为空代表全部取消勾选。
*/ + @Valid + private List perms; + + /** 单个模块权限项。
*/ + @Data + public static class ModulePermItem { + + /** 模块ID。
*/ + @NotNull(message = "模块ID不能为空") + private Integer moduleId; + + /** 是否子组继承;不传默认 false。
*/ + private Boolean inheritFlag; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/SchoolAppAuthzRequest.java b/src/main/java/com/yunhai/userbase/model/groupauthz/SchoolAppAuthzRequest.java new file mode 100644 index 0000000..af3d91c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/SchoolAppAuthzRequest.java @@ -0,0 +1,38 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import javax.validation.constraints.NotNull; + +/** + * 学校组应用授权增量请求(对应前端点击单个应用多选框勾选/取消时提交)。
+ * 与 {@link GroupAppAssignRequest}(全量覆盖式 AssignApps)互为补充:本类用于「单条」增量开通或取消应用,
+ * 前端一次只传一个应用ID(逐次点击触发),因此 appid 为单个 Integer,而非集合/逗号串。
+ *
+ * 字段语义(务必与前端对齐):
+ * - schid:目标学校组ID(即左侧树点击的 nodeId)
+ * - appid:单个应用ID(每次只传一个)
+ * - mode:操作模式,取值见 SysModeEnum —— 0=追加(APPEND,开通应用) / 2=删除(DELETE,取消开通);1=覆盖 不适用于单条增量,会被拒绝
+ *
+ * 设计说明:每次请求只携带一个 appid,控制器/服务均不再做多值解析;逐次点击即多次提交。 + * + * @author chenx + */ +@Data +public class SchoolAppAuthzRequest { + + /** 目标学校组ID(左侧树节点 nodeId)。 */ + @NotNull(message = "组ID不能为空") + private Integer schid; + + /** + * 单个应用ID。
+ * 前端逐次点击应用多选框触发,每次只传一个应用ID,故为 Integer 而非集合/字符串。 + */ + @NotNull(message = "应用ID不能为空") + private Integer appid; + + /** 操作模式:0=追加 / 2=删除(1=覆盖不适用)。 */ + @NotNull(message = "操作模式不能为空(0=追加/2=删除)") + private Integer mode; +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/UserGroupNodeVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/UserGroupNodeVo.java new file mode 100644 index 0000000..5212e4f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/UserGroupNodeVo.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 权限一览:用户所属部门树节点(左侧上方「所属部门」区域)。
+ * 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组), + * 不包含无关的兄弟/子孙节点。
+ * + * @author chenx + */ +@Data +public class UserGroupNodeVo { + + /** 组ID。 */ + private Integer id; + + /** 组名称。 */ + private String name; + + /** 是否用户直接归属的组(叶子高亮用);祖先组为 false。 */ + private Boolean own; + + /** 子节点(仅含祖先链上的节点)。 */ + private List children = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionAppVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionAppVo.java new file mode 100644 index 0000000..23a1394 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionAppVo.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 权限一览:用户已授权应用节点。
+ * 父节点为 {@link UserPermissionCategoryVo},子节点为 {@link UserPermissionModuleVo}。 + */ +@Data +public class UserPermissionAppVo { + + /** 应用ID。 */ + private Integer id; + + /** 应用名称。 */ + private String name; + + /** 应用唯一标识 appKey。 */ + private Integer appKey; + + /** PC 应用图标地址。 */ + private String pcIcon; + + /** 排序号。 */ + private Integer sortNo; + + /** 该应用下已授权的模块树。 */ + private List modules = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionCategoryVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionCategoryVo.java new file mode 100644 index 0000000..360e92f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionCategoryVo.java @@ -0,0 +1,26 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 权限一览:用户已授权应用分类节点。
+ * 一级子节点为 {@link UserPermissionAppVo}。 + */ +@Data +public class UserPermissionCategoryVo { + + /** 分类ID。 */ + private Integer id; + + /** 分类名称。 */ + private String name; + + /** 排序号。 */ + private Integer sortNo; + + /** 该分类下已授权的应用列表。 */ + private List apps = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionModuleVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionModuleVo.java new file mode 100644 index 0000000..50edcc8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionModuleVo.java @@ -0,0 +1,38 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 权限一览:用户已授权模块节点(递归树)。
+ * 仅包含有权限的模块,无权限的模块及其子节点均不显示。 + */ +@Data +public class UserPermissionModuleVo { + + /** 模块ID。 */ + private Integer id; + + /** 父模块ID;根模块为 null。 */ + private Integer parentId; + + /** 模块名称。 */ + private String name; + + /** 自定义ID(应用内唯一业务编码)。 */ + private String customId; + + /** 模块路径。 */ + private String path; + + /** 层级(根=1)。 */ + private Integer level; + + /** 排序号。 */ + private Integer sortNo; + + /** 子模块(递归)。 */ + private List children = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionUserVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionUserVo.java new file mode 100644 index 0000000..4ec8fb0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionUserVo.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +/** + * 权限一览:用户列表项(组下用户或全局搜索用户)。
+ */ +@Data +public class UserPermissionUserVo { + + /** 用户ID。 */ + private Integer id; + + /** 账号。 */ + private String account; + + /** 真实姓名。 */ + private String realName; + + /** 是否主归属:1=本职主学校/部门,0=兼职兼任。 */ + private Integer isMain; + + /** 用户所属部门/组织路径,如 /上海市仙霞高级中学/教师/行政。 */ + private String groupPath; +} diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/UserRoleVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/UserRoleVo.java new file mode 100644 index 0000000..7e5adc8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/UserRoleVo.java @@ -0,0 +1,30 @@ +package com.yunhai.userbase.model.groupauthz; + +import lombok.Data; + +/** + * 权限一览:用户所属业务角色列表项(中间下方「所属角色」区域)。
+ * + * @author chenx + */ +@Data +public class UserRoleVo { + + /** 角色ID。 */ + private Integer id; + + /** 角色名称。 */ + private String name; + + /** 是否全应用角色。 */ + private Boolean allAppFlag; + + /** 所属应用ID;全应用角色为 null。 */ + private Integer appId; + + /** 所属应用名称;全应用角色为 null。 */ + private String appName; + + /** 是否禁用(禁用角色仍展示,但不参与权限计算)。 */ + private Boolean disabled; +} diff --git a/src/main/java/com/yunhai/userbase/model/oplog/SysOperationLogListVo.java b/src/main/java/com/yunhai/userbase/model/oplog/SysOperationLogListVo.java new file mode 100644 index 0000000..c731bfc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/oplog/SysOperationLogListVo.java @@ -0,0 +1,77 @@ +package com.yunhai.userbase.model.oplog; + +import com.alibaba.fastjson.annotation.JSONField; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 操作日志列表项视图。
+ * 不含 response_data(响应结果),列表不展示,需通过下载接口单独获取。
+ * + * @author chenx + * @date 2026/7/28 + */ +@Data +public class SysOperationLogListVo { + + /** 主键 */ + private Long id; + /** 日志级别 1-重要 2-次要 3-一般 */ + private Integer logLevel; + /** 操作用户ID */ + private Long userId; + /** 操作用户名 */ + private String username; + /** 请求IP */ + private String requestIp; + /** 请求路径 */ + private String requestUri; + /** 请求方法 GET/POST/PUT/DELETE */ + private String requestMethod; + /** 操作类型 DELETE/CREATE/UPDATE/QUERY */ + private String operationType; + /** 类名.方法名 */ + private String classMethod; + /** 请求参数JSON */ + private String requestParams; + /** HTTP 状态码 */ + private Integer statusCode; + /** 耗时(ms) */ + private Long costTime; + /** 浏览器UA */ + private String userAgent; + /** 错误信息 */ + private String errorMsg; + /** 操作时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + /** + * 全参构造,顺序需与 {@code SysOperationLog} 的列表投影字段一致。
+ * 用于 JPA Criteria {@code cb.construct} 投影查询。
+ */ + public SysOperationLogListVo(Long id, Integer logLevel, Long userId, String username, + String requestIp, String requestUri, String requestMethod, + String operationType, String classMethod, String requestParams, + Integer statusCode, Long costTime, String userAgent, + String errorMsg, LocalDateTime createTime) { + this.id = id; + this.logLevel = logLevel; + this.userId = userId; + this.username = username; + this.requestIp = requestIp; + this.requestUri = requestUri; + this.requestMethod = requestMethod; + this.operationType = operationType; + this.classMethod = classMethod; + this.requestParams = requestParams; + this.statusCode = statusCode; + this.costTime = costTime; + this.userAgent = userAgent; + this.errorMsg = errorMsg; + this.createTime = createTime; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/oplog/SysOperationLogQuery.java b/src/main/java/com/yunhai/userbase/model/oplog/SysOperationLogQuery.java new file mode 100644 index 0000000..ca67306 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/oplog/SysOperationLogQuery.java @@ -0,0 +1,59 @@ +package com.yunhai.userbase.model.oplog; + +import lombok.Data; + +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +/** + * 操作日志查询条件。
+ * 覆盖时间、用户、URI、级别、IP 等高频查询场景,与表索引前缀对齐。
+ * + * @author chenx + * @date 2026/7/28 + */ +@Data +public class SysOperationLogQuery { + + /** 日志级别(1-重要 2-次要 3-一般) */ + private Integer logLevel; + + /** 操作用户ID */ + private Long userId; + + /** 操作用户名(模糊) */ + private String username; + + /** 请求路径(模糊) */ + private String requestUri; + + /** 请求IP(模糊) */ + private String requestIp; + + /** 操作类型 DELETE/CREATE/UPDATE/QUERY */ + private String operationType; + + /** HTTP 状态码 */ + private Integer statusCode; + + /** + * 成功/失败筛选。
+ * true=仅 HTTP 200(成功),false=非 200(失败)。 + */ + private Boolean success; + + /** + * 关键字,同时匹配接口名称(requestUri)与用户名(username)。
+ * 前端单输入框搜索这两个字段。 + */ + private String keyword; + + /** 操作起始时间(含) */ + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime startTime; + + /** 操作结束时间(含) */ + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime endTime; +} diff --git a/src/main/java/com/yunhai/userbase/model/other/OperationDetailsModel.java b/src/main/java/com/yunhai/userbase/model/other/OperationDetailsModel.java new file mode 100644 index 0000000..c0f146b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/other/OperationDetailsModel.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.model.other; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @program: UserProfile + * @description: 接口日志操作应用详情 + * @author: chenx + * @create: 2024-07-05 12:34 + **/ + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class OperationDetailsModel { + + private Integer id;//应用id + private String name;//应用名称 + private Long time;//requestTime + private String url; + private String errorMsg; + + @JsonIgnore + private Integer indicatorId; + @JsonIgnore + private String resultMsg; + @JsonIgnore + private Double computeScore;//计算后的分数 + +} diff --git a/src/main/java/com/yunhai/userbase/model/other/RoleInfoModel.java b/src/main/java/com/yunhai/userbase/model/other/RoleInfoModel.java new file mode 100644 index 0000000..fd8d6da --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/other/RoleInfoModel.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.model.other; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @program: UserProfile + * @description: 用户当前角色信息 + * @author: chenx + * @create: 2024-07-09 17:30 + **/ + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class RoleInfoModel { + + private Integer roleId; + private String roleName; + private Integer schId; + private String schName; + private Integer childId; + private String childName; +} diff --git a/src/main/java/com/yunhai/userbase/model/other/SchLenGradeModel.java b/src/main/java/com/yunhai/userbase/model/other/SchLenGradeModel.java new file mode 100644 index 0000000..a41d539 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/other/SchLenGradeModel.java @@ -0,0 +1,22 @@ +package com.yunhai.userbase.model.other; + +import lombok.Data; + +/** + * @author chenx + * @date 2025/12/25 21:28 + * @description 学校学制年级实体 + */ + +@Data +public class SchLenGradeModel { + private Integer gradeId; + private String gradeName; + private Integer stage; + + public SchLenGradeModel(Integer gradeId, String gradeName, Integer stage) { + this.gradeId = gradeId; + this.gradeName = gradeName; + this.stage = stage; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/other/SchLenLevelModel.java b/src/main/java/com/yunhai/userbase/model/other/SchLenLevelModel.java new file mode 100644 index 0000000..fc11539 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/other/SchLenLevelModel.java @@ -0,0 +1,19 @@ +package com.yunhai.userbase.model.other; + +import com.yunhai.userbase.enums.SchoolStage; +import lombok.Data; + +import java.util.List; + +/** + * @author chenx + * @date 2025/12/25 17:44 + * @description 学校学段学制年级实体 + */ + +@Data +public class SchLenLevelModel { + private SchoolStage stage; + private List grades; +} + diff --git a/src/main/java/com/yunhai/userbase/model/other/UserInfoModel.java b/src/main/java/com/yunhai/userbase/model/other/UserInfoModel.java new file mode 100644 index 0000000..a158910 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/other/UserInfoModel.java @@ -0,0 +1,26 @@ +package com.yunhai.userbase.model.other; + +import com.yunhai.userbase.enums.UserType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @program: UserProfile + * @description: 用户信息 + * @author: chenx + * @create: 2024-07-09 17:30 + **/ + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class UserInfoModel { + + private Integer userId; + private String userName; + private Integer schId; + private Integer groupId; + private String photo; + private UserType userType; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/AcademicYear.java b/src/main/java/com/yunhai/userbase/model/serialize/AcademicYear.java new file mode 100644 index 0000000..a8c41f8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/AcademicYear.java @@ -0,0 +1,20 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +/** +* @Description: 学年学期 +* @Author: chenx +* @Date: 2023/9/5 +*/ + +@Data +public class AcademicYear { + public Integer id; + public String name; + public Integer year; + public Integer semester; + public String startDate; + public String endDate; + public String intervalDate;//日期区间 +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/AcademicYearSeq.java b/src/main/java/com/yunhai/userbase/model/serialize/AcademicYearSeq.java new file mode 100644 index 0000000..23f6b1f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/AcademicYearSeq.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +import java.util.Date; + +/** +* @Description: 班级管理学年学期序列化 +* @Author: chenx +* @Date: 2023/9/5 +*/ + +@Data +public class AcademicYearSeq { + private int id; + private String name; + private Date startDate; + private Date endDate; + private Date progressionDate; + private int year; + private Date firstSemesterStartDate; + private Date firstSemesterEndDate; + private Date secondSemesterStartDate; + private Date secondSemesterEndDate; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/BaseSchool.java b/src/main/java/com/yunhai/userbase/model/serialize/BaseSchool.java new file mode 100644 index 0000000..029444e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/BaseSchool.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +/** + * @author chenx + * @date 2025/12/30 19:31 + * @description 学校列表 + */ + +@Data +public class BaseSchool { + private String SchSerID; + private int Type; + private String PID; + private int ID; + private String Name; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/GradeClassStu.java b/src/main/java/com/yunhai/userbase/model/serialize/GradeClassStu.java new file mode 100644 index 0000000..5e87ee4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/GradeClassStu.java @@ -0,0 +1,22 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +/** +* @Description: 班级管理接口序列化 :GetClaArrStulist +* @Author: chenx +* @Date: 2023/6/8 +*/ + +@Data +public class GradeClassStu { + private Integer gradeId; + private String gradeName; + private Integer classId; + private String className; + private Integer stuId; + private String stuName; + private String CardID;//学号 + private Integer StuSex;//性别 + private String StuPhoto; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/SchClass.java b/src/main/java/com/yunhai/userbase/model/serialize/SchClass.java new file mode 100644 index 0000000..bdfe307 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/SchClass.java @@ -0,0 +1,15 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +@Data +public class SchClass { + + public String SchSerID; + public int SchID; + public String GradeName; + public String ClassName; + public String ClassID; + public int GradeID; + public int CID; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/SchClassModel.java b/src/main/java/com/yunhai/userbase/model/serialize/SchClassModel.java new file mode 100644 index 0000000..d8913a7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/SchClassModel.java @@ -0,0 +1,12 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +@Data +public class SchClassModel { + public Integer classID; + public Integer gradeID; + public String gradeName; + public String className; + +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/SchLenLevel.java b/src/main/java/com/yunhai/userbase/model/serialize/SchLenLevel.java new file mode 100644 index 0000000..fd8d7d8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/SchLenLevel.java @@ -0,0 +1,23 @@ +package com.yunhai.userbase.model.serialize; + +import com.alibaba.fastjson.annotation.JSONField; +import lombok.Data; + +/** + * @author chenx + * @date 2025/12/25 15:56 + * @description 学校学制年级信息 + */ + +@Data +public class SchLenLevel { + private Integer code; + @JSONField(name = "OwnerId") + private Integer ownerId; + private String sx; + @JSONField(name = "Id") + private Integer id; + private Integer levell; + @JSONField(name = "Name") + private String name; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/SchTeaInfo.java b/src/main/java/com/yunhai/userbase/model/serialize/SchTeaInfo.java new file mode 100644 index 0000000..7ee4969 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/SchTeaInfo.java @@ -0,0 +1,35 @@ +package com.yunhai.userbase.model.serialize; + +/** + * @program: ResearchLearn + * @description: 全部教师信息 + * @author: chenx + * @create: 2024-06-07 10:16 + **/ + +import lombok.Data; + +@Data +public class SchTeaInfo { + private String SchSerID; + private String Teapolitical; + private String TeaWorkStart; + private String Account; + private String role; + private Integer TeaSex; + private String IsLeadingMiddle; + private String TeaOriginPlace; + private String Photo; + private String TeaIDCard; + private String Name; + private Integer GroupID; + private String SchName; + private String TeaTeachStart; + private String TeaSerID; + private Integer UserID; + private String TeaNation; + private String Tel; + private String TeaWorkType; + private Integer TeaNo; + private String TeaBirth; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/StuInfo.java b/src/main/java/com/yunhai/userbase/model/serialize/StuInfo.java new file mode 100644 index 0000000..e5a32b4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/StuInfo.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +/** + * @program: QualityEvaluate + * @description: 获取班级列表的学生信息序列化 + * @author: chenx + * @create: 2023-08-21 10:27 + **/ + +@Data +public class StuInfo { + private String SchSerID; + private String Account; + private String StuStatus; + private String invitecode; + private int StuSex; + private String ClassID; + private String CardID; + private String ClassStuID; + private String StuPhoto; + private long GroupID; + private String StuName; + private long UserStuSerID; + private boolean Graduated; + private String StuIDCard; + private String StuCurrentTel; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/StuInfo2.java b/src/main/java/com/yunhai/userbase/model/serialize/StuInfo2.java new file mode 100644 index 0000000..4c65fe6 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/StuInfo2.java @@ -0,0 +1,31 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +import java.util.Date; + +/** + * @program: QualityEvaluate + * @description: 获取单个学生信息序列化 + * @author: chenx + * @create: 2023-09-06 14:46 + **/ + +@Data +public class StuInfo2 { + private int status_code; + private String idCard; + private int sex; + private String photo; + private int IsGraduation; + private Date birth; + private String ClaName; + private String stuName; + private String SchName; + private String studentId; + private long classId; + private String GradeName; + private long UserStuSerID; + private String ClassName; + private int IsDelete; +} diff --git a/src/main/java/com/yunhai/userbase/model/serialize/TeaFirstClass.java b/src/main/java/com/yunhai/userbase/model/serialize/TeaFirstClass.java new file mode 100644 index 0000000..1eb1237 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/serialize/TeaFirstClass.java @@ -0,0 +1,13 @@ +package com.yunhai.userbase.model.serialize; + +import lombok.Data; + +@Data +public class TeaFirstClass { + public String gradeName; + public int gradeId ; + public String alias; + public String className ; + public String name ; + public int id ; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupBatchRequest.java b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupBatchRequest.java new file mode 100644 index 0000000..7102524 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupBatchRequest.java @@ -0,0 +1,48 @@ +package com.yunhai.userbase.model.sysgroup; + +import lombok.Data; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 系统组批量新增请求:在指定父组下,按 items 列表逐个创建子组。
+ * 请求体示例:
+ * {
+ * "pid": 11285,
+ * "type": 4,
+ * "items": [
+ * { "name": "高一1班" },
+ * { "name": "高一2班" }
+ * ]
+ * } + */ +@Data +public class SysGroupBatchRequest { + + /** 父组ID(待生成子组的挂载点,必须存在)。 */ + @NotNull(message = "父组ID不能为空") + private Integer pid; + + /** 组类型(GroupTypeEnum.key,如 4=班级组)。 */ + @NotNull(message = "组类型不能为空") + private Integer type; + + /** 待新增的子组列表,每一项仅需提供 name。 */ + @NotEmpty(message = "批量新增的子组列表不能为空") + @Valid + private List items; + + /** 批量新增的单项。 */ + @Data + public static class BatchItem { + /** 子组名称。 */ + @javax.validation.constraints.NotBlank(message = "组名称不能为空") + private String name; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupMoveRequest.java b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupMoveRequest.java new file mode 100644 index 0000000..3643b72 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupMoveRequest.java @@ -0,0 +1,23 @@ +package com.yunhai.userbase.model.sysgroup; + +import lombok.Data; + +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/7/22 + * @description 移动组请求:将 moveId 节点移动到 pid 节点下。
+ * 不支持移动到自身或自身后代下(环检测)。 + */ +@Data +public class SysGroupMoveRequest { + + /** 待移动节点ID。 */ + @NotNull(message = "待移动节点ID不能为空") + private Integer moveId; + + /** 目标父节点ID(移动后的挂载点,必须存在)。 */ + @NotNull(message = "目标父节点ID不能为空") + private Integer pid; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupQuery.java b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupQuery.java new file mode 100644 index 0000000..ece4fc8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupQuery.java @@ -0,0 +1,15 @@ +package com.yunhai.userbase.model.sysgroup; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/7/20 16:00 + * @description 系统组查询条件 + */ +@Data +public class SysGroupQuery { + private String name; + + private Integer parentId; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupRequest.java b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupRequest.java new file mode 100644 index 0000000..405668c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupRequest.java @@ -0,0 +1,31 @@ +package com.yunhai.userbase.model.sysgroup; + +import com.yunhai.userbase.common.validation.ValidationGroups; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.NotBlank; + +/** + * @author chenx + * @date 2026/7/20 16:00 + * @description 系统组新增/编辑请求 + */ +@Data +public class SysGroupRequest { + // ID 通过路径参数传递,body 无需携带,故不在校验组内强制 + private Integer id; + + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "组名称不能为空") + private String name; + + // 新增时必填;编辑不通过此字段移动节点(移动请走 Move 接口),故编辑时可选 + @NotNull(groups = ValidationGroups.Create.class, message = "上级组ID不能为空") + private Integer parentId; + + // 新增时必填;编辑时可选(仅当需要变更类型时传入) + @NotNull(groups = ValidationGroups.Create.class, message = "组类型不能为空") + private Integer type; + + private Integer sort; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupSortRequest.java b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupSortRequest.java new file mode 100644 index 0000000..d01ecd1 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupSortRequest.java @@ -0,0 +1,24 @@ +package com.yunhai.userbase.model.sysgroup; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/22 + * @description 子组排序请求:将 pid 的直接子节点按给定 ids 顺序重新排列并落库。
+ * ids 必须全部是 pid 下一级的直接子节点,否则报错。 + */ +@Data +public class SysGroupSortRequest { + + /** 父组ID。 */ + @NotNull(message = "父组ID不能为空") + private Integer pid; + + /** 期望的、pid 直接子节点有序 ID 列表(按传入顺序生效)。 */ + @NotNull(message = "子节点ID列表不能为空") + private List ids; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupVo.java b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupVo.java new file mode 100644 index 0000000..8aadbe5 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupVo.java @@ -0,0 +1,21 @@ +package com.yunhai.userbase.model.sysgroup; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/20 16:00 + * @description 系统组返回对象(树形:children 递归存放子组)。
+ */ +@Data +public class SysGroupVo { + private Integer id; + private String name; + private Integer type; + private Integer sort; + /** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */ + private List children = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserQuery.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserQuery.java new file mode 100644 index 0000000..2f35855 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserQuery.java @@ -0,0 +1,27 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/7/20 16:14 + * @description 系统用户列表查询条件 + */ +@Data +public class SysUserQuery { + + /** 分组ID(列表以某个组节点为上下文查询其下用户) */ + private Integer groupId; + + /** + * 是否包含子组用户:0=仅查询该节点下的用户;1=查询该节点及其所有子孙组下的用户。
+ * 默认 0。
+ */ + private Integer withSub = 0; + + /** 关键字:同时匹配账号、姓名、身份证(模糊)与手机号(精确) */ + private String name; + + /** 启用状态过滤(可选);对应实体 disabled 字段(StatusEnum key:0=正常/未禁用,1=禁用) */ + private Integer disabled; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java new file mode 100644 index 0000000..d94d80b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java @@ -0,0 +1,43 @@ +package com.yunhai.userbase.model.sysuser; + +import com.yunhai.userbase.common.validation.ValidationGroups; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/20 16:14 + * @description 系统用户新增/编辑请求 + */ +@Data +public class SysUserRequest { + @NotNull(groups = ValidationGroups.Update.class, message = "编辑时ID不能为空") + private Integer id; + + /** 所属分组ID,新增时必填(用户创建后写入组用户关联表) */ + @NotNull(groups = ValidationGroups.Create.class, message = "所属分组不能为空") + private Integer groupId; + + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "账号不能为空") + private String account; + + @NotBlank(groups = ValidationGroups.Create.class, message = "密码不能为空") + private String password; + + /** 确认密码:与 password 一致性由 Service 校验(编辑时若不修改密码可不传) */ + private String confirmPassword; + + private String realName; + private String phone; + private LocalDate birthday; + private String idcard; + private String profilePicture; + private List roles; + /** 启用状态:对应 StatusEnum 的 key(0=正常/未禁用,1=禁用);不传则默认启用 */ + private Integer disabled; + private Boolean expired; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java new file mode 100644 index 0000000..73c0206 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java @@ -0,0 +1,31 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * @author chenx + * @date 2026/7/20 16:14 + * @description 系统用户返回对象 + */ +@Data +public class SysUserVo { + private Integer id; + private String account; + private LocalDate birthday; + private String realName; + private String phone; + private String idcard; + private String profilePicture; + private List roles; + /** 启用状态:StatusEnum 的 key(0=正常/未禁用,1=禁用) */ + private Integer disabled; + /** 用户类型:UserTypeEnum 的 key(0=普通用户,1=超级管理员,2=学校管理员) */ + private Integer type; + private Boolean expired; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/userbase/repository/AppCategoryRepository.java b/src/main/java/com/yunhai/userbase/repository/AppCategoryRepository.java new file mode 100644 index 0000000..1401768 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/AppCategoryRepository.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.AppCategory; +import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/15 21:29 + * @description + */ +public interface AppCategoryRepository extends JpaRepository, JpaSpecificationExecutor { + + @Query("SELECT MAX(c.sortNo) FROM AppCategory c") + Integer findMaxSortNo(); + + List findAllByOrderBySortNoAsc(); + + List findByNameContainingOrderBySortNoAsc(String name); + + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Integer id); + + @Modifying + @Query("UPDATE AppCategory c SET c.sortNo = :sortNo WHERE c.id = :id") + void updateSortNoById(@Param("id") Integer id, @Param("sortNo") Integer sortNo); +} diff --git a/src/main/java/com/yunhai/userbase/repository/AppModuleRepository.java b/src/main/java/com/yunhai/userbase/repository/AppModuleRepository.java new file mode 100644 index 0000000..e1306d9 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/AppModuleRepository.java @@ -0,0 +1,46 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.AppModule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/22 + * @description 应用模块仓储。
+ */ +public interface AppModuleRepository extends JpaRepository, JpaSpecificationExecutor { + + /** + * 动态过滤:appId/parentId/name 均可空;
+ * 排序:sortNo 正序(空视为 999),再按 id 正序。 + */ + @Query("SELECT m FROM AppModule m " + + "WHERE (:appId IS NULL OR m.appId = :appId) " + + "AND (:parentId IS NULL OR m.parentId = :parentId) " + + "AND (:name IS NULL OR m.name LIKE CONCAT('%', :name, '%')) " + + "ORDER BY CASE WHEN m.sortNo IS NULL THEN 999 ELSE m.sortNo END ASC, m.id ASC") + List findModules(@Param("appId") Integer appId, + @Param("parentId") Integer parentId, + @Param("name") String name); + + boolean existsByAppIdAndCustomId(Integer appId, String customId); + + boolean existsByAppIdAndCustomIdAndIdNot(Integer appId, String customId, Integer id); + + long countByParentId(Integer parentId); + + /** + * 按应用ID集合查询全部模块(组授权按已开通应用批量加载模块树用)。
+ */ + List findByAppIdIn(List appIds); + + /** + * 按应用ID级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理整棵子树)。
+ */ + void deleteByAppId(Integer appId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java new file mode 100644 index 0000000..c1cef04 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java @@ -0,0 +1,74 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.appauthz.AppAuthzAppRow; +import com.yunhai.userbase.model.application.ApplicationInfoSortVo; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/15 21:29 + * @description + */ +public interface ApplicationInfoRepository extends JpaRepository, JpaSpecificationExecutor { + Integer countByCategoryId(Integer id); + + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Integer id); + + /** + * 查询当前最大的 appKey 值。
+ * 用于新增应用时生成从 100000 起自增的整型 appKey。
+ * + * @return 当前最大 appKey,无记录时返回 null + */ + @Query("SELECT MAX(a.appKey) FROM ApplicationInfo a") + Integer findMaxAppKey(); + + /** + * 按整型 appKey 查单个应用(验签兜底端点直连读取用)。
+ * appKey 由新增应用自增生成,业务上唯一。 + * + * @param appKey 应用标识(整数) + * @return 匹配的应用实体,无则 null + */ + ApplicationInfo findByAppKey(Integer appKey); + + /** + * 按 id 更新排序号(同一应用类型内排序使用)。
+ * + * @param id 应用ID + * @param sortNo 新的排序号 + */ + @Modifying + @Query("UPDATE ApplicationInfo a SET a.sortNo = :sortNo WHERE a.id = :id") + void updateSortNoById(@Param("id") Integer id, @Param("sortNo") Integer sortNo); + + /** + * 按应用类型查询排序所需的精简视图(仅 id/name/sortNo),不分页。
+ * 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。
+ * + * @param categoryId 应用类型ID + * @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底 + */ + @Query("SELECT new com.yunhai.userbase.model.application.ApplicationInfoSortVo(a.id, a.name, a.sortNo) " + + "FROM ApplicationInfo a WHERE a.categoryId = :categoryId ORDER BY a.sortNo ASC, a.id ASC") + List findSortVoByCategoryId(@Param("categoryId") Integer categoryId); + + /** + * 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。
+ * 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。
+ * + * @return 全部应用的授权投影行 + */ + @Query("SELECT new com.yunhai.userbase.model.appauthz.AppAuthzAppRow(a.categoryId, a.id, a.name, a.pcIcon) " + + "FROM ApplicationInfo a ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC") + List findAllAuthzRows(); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysAppRoleModuleRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysAppRoleModuleRelRepository.java new file mode 100644 index 0000000..d94a60c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysAppRoleModuleRelRepository.java @@ -0,0 +1,47 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysAppRoleModuleRel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/27 + * @description 业务角色-模块权限关系仓储。
+ */ +public interface SysAppRoleModuleRelRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByRoleId(Integer roleId); + + /** + * 权限计算用:批量查询一组角色的全部模块授权记录。
+ */ + List findByRoleIdIn(List roleIds); + + /** + * 按角色ID批量物理删除模块授权记录(bulk delete 直接发 SQL)。
+ * 用于「角色模块授权」全量覆盖式保存的先删后插,避免 Hibernate flush 先 insert 后 delete 触发唯一键冲突。
+ */ + @Modifying + @Query("DELETE FROM SysAppRoleModuleRel r WHERE r.roleId = :roleId") + void deleteByRoleIdBulk(@Param("roleId") Integer roleId); + + /** + * 按角色ID集合批量物理删除模块授权记录(删除应用时级联清理该应用下角色)。
+ */ + @Modifying + @Query("DELETE FROM SysAppRoleModuleRel r WHERE r.roleId IN :roleIds") + void deleteByRoleIdInBulk(@Param("roleIds") List roleIds); + + /** + * 按模块ID集合批量物理删除模块授权记录(删除模块/应用时级联清理)。
+ */ + @Modifying + @Query("DELETE FROM SysAppRoleModuleRel r WHERE r.moduleId IN :moduleIds") + void deleteByModuleIdInBulk(@Param("moduleIds") List moduleIds); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysAppRoleRepository.java b/src/main/java/com/yunhai/userbase/repository/SysAppRoleRepository.java new file mode 100644 index 0000000..4c30a65 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysAppRoleRepository.java @@ -0,0 +1,40 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysAppRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/27 + * @description 应用业务角色仓储。
+ */ +public interface SysAppRoleRepository extends JpaRepository, JpaSpecificationExecutor { + + /** + * 普通角色重名校验:同一应用下角色名称唯一(新增用)。
+ */ + boolean existsByNameAndAppId(String name, Integer appId); + + /** + * 普通角色重名校验:同一应用下角色名称唯一,排除自身(编辑用)。
+ */ + boolean existsByNameAndAppIdAndIdNot(String name, Integer appId, Integer id); + + /** + * 全应用角色重名校验:全应用角色之间名称唯一(新增用)。
+ */ + boolean existsByNameAndAllAppFlagTrue(String name); + + /** + * 全应用角色重名校验:全应用角色之间名称唯一,排除自身(编辑用)。
+ */ + boolean existsByNameAndAllAppFlagTrueAndIdNot(String name, Integer id); + + /** + * 查询某应用下的全部普通角色(删除应用时级联清理用)。
+ */ + List findByAppId(Integer appId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysGroupAppRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysGroupAppRelRepository.java new file mode 100644 index 0000000..d6bff29 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysGroupAppRelRepository.java @@ -0,0 +1,47 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysGroupAppRel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组应用开通仓储。
+ */ +public interface SysGroupAppRelRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByGroupId(Integer groupId); + + boolean existsByGroupIdAndAppId(Integer groupId, Integer appId); + + long countByAppId(Integer appId); + + /** + * 按组ID批量物理删除开通记录(bulk delete 直接发 SQL)。
+ * 用于「应用开通」全量覆盖式保存的先删后插,避免 Hibernate flush 先 insert 后 delete 触发唯一键冲突。
+ */ + @Modifying + @Query("DELETE FROM SysGroupAppRel r WHERE r.groupId = :groupId") + void deleteByGroupIdBulk(@Param("groupId") Integer groupId); + + /** + * 按应用ID批量物理删除开通记录(删除应用时级联清理)。
+ */ + @Modifying + @Query("DELETE FROM SysGroupAppRel r WHERE r.appId = :appId") + void deleteByAppIdBulk(@Param("appId") Integer appId); + + /** + * 按 (组ID + 应用ID) 物理删除单条开通记录(增量取消开通用)。
+ * 用于 SetSchoolApp 的「删除」模式:取消某个组下某个应用的开通。
+ */ + @Modifying + @Query("DELETE FROM SysGroupAppRel r WHERE r.groupId = :groupId AND r.appId = :appId") + void deleteByGroupIdAndAppIdBulk(@Param("groupId") Integer groupId, @Param("appId") Integer appId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysGroupModulePermRepository.java b/src/main/java/com/yunhai/userbase/repository/SysGroupModulePermRepository.java new file mode 100644 index 0000000..c63ad22 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysGroupModulePermRepository.java @@ -0,0 +1,60 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysGroupModulePerm; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组模块权限仓储。
+ */ +public interface SysGroupModulePermRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByGroupId(Integer groupId); + + /** + * 增量 upsert 用:查询某组对某模块是否已有权限记录。
+ */ + SysGroupModulePerm findByGroupIdAndModuleId(Integer groupId, Integer moduleId); + + /** + * 权限判定用:查询目标模块在指定组ID集合(祖先链)上的全部权限记录。
+ */ + List findByModuleIdAndGroupIdIn(Integer moduleId, List groupIds); + + /** + * 按组ID批量物理删除权限记录(删除组时级联清理)。
+ */ + @Modifying + @Query("DELETE FROM SysGroupModulePerm p WHERE p.groupId = :groupId") + void deleteByGroupIdBulk(@Param("groupId") Integer groupId); + + /** + * 权限一览用:批量查询一组模块在指定组集合上的权限记录。
+ */ + List findByModuleIdInAndGroupIdIn(List moduleIds, List groupIds); + @Modifying + @Query("DELETE FROM SysGroupModulePerm p WHERE p.moduleId IN :moduleIds") + void deleteByModuleIdInBulk(@Param("moduleIds") List moduleIds); + + /** + * 删除某组在指定模块集合上的权限记录(组取消开通应用时清理该应用下模块的残留权限)。
+ */ + @Modifying + @Query("DELETE FROM SysGroupModulePerm p WHERE p.groupId = :groupId AND p.moduleId IN :moduleIds") + void deleteByGroupIdAndModuleIdInBulk(@Param("groupId") Integer groupId, @Param("moduleIds") List moduleIds); + + /** + * 按 (组ID + 模块ID) 物理删除单条权限记录(增量取消模块授权用)。
+ * 用于 SetGroupModule 的「删除」模式。
+ */ + @Modifying + @Query("DELETE FROM SysGroupModulePerm p WHERE p.groupId = :groupId AND p.moduleId = :moduleId") + void deleteByGroupIdAndModuleIdBulk(@Param("groupId") Integer groupId, @Param("moduleId") Integer moduleId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysGroupRepository.java b/src/main/java/com/yunhai/userbase/repository/SysGroupRepository.java new file mode 100644 index 0000000..a9c2f02 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysGroupRepository.java @@ -0,0 +1,31 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysGroup; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +/** + * @author chenx + * @date 2026/7/20 16:00 + * @description 系统组仓储 + */ +public interface SysGroupRepository extends JpaRepository, JpaSpecificationExecutor { + + @Query("SELECT MAX(g.sort) FROM SysGroup g") + Integer findMaxSort(); + + @Query("SELECT MAX(g.sort) FROM SysGroup g WHERE g.parentId = :parentId") + Integer findMaxSortByParentId(Integer parentId); + + List findAllByOrderBySortAsc(); + + List findByNameContainingOrderBySortAsc(String name); + + List findByParentIdOrderBySortAsc(Integer parentId); + + long countByParentId(Integer parentId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/userbase/repository/SysOperationLogRepository.java new file mode 100644 index 0000000..d8faaac --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysOperationLogRepository.java @@ -0,0 +1,27 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysOperationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.time.LocalDateTime; + +/** + * 系统操作日志仓储。
+ * + * @author chenx + * @date 2026/7/28 + */ +public interface SysOperationLogRepository extends JpaRepository, + JpaSpecificationExecutor { + + /** + * 按日志级别 + 创建时间批量删除(单条 DELETE 语句,不先加载实体)。
+ * 用于定时清理:一般日志保留 6 个月,重要/次要日志保留 12 个月。
+ * + * @param logLevel 日志级别编码 + * @param createTime 截止时间(早于该时间的记录被删除) + * @return 删除条数 + */ + long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime createTime); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserAppRoleRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserAppRoleRelRepository.java new file mode 100644 index 0000000..82b7231 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysUserAppRoleRelRepository.java @@ -0,0 +1,51 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysUserAppRoleRel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/27 + * @description 用户-业务角色关系仓储。
+ */ +public interface SysUserAppRoleRelRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByUserId(Integer userId); + + List findByRoleId(Integer roleId); + + /** + * 按角色ID批量物理删除绑定记录(bulk delete 直接发 SQL)。
+ * 用于「角色绑定用户」全量覆盖式保存的先删后插,以及删除角色时级联清理。
+ */ + @Modifying + @Query("DELETE FROM SysUserAppRoleRel r WHERE r.roleId = :roleId") + void deleteByRoleIdBulk(@Param("roleId") Integer roleId); + + /** + * 按角色ID集合批量物理删除绑定记录(删除应用时级联清理该应用下角色)。
+ */ + @Modifying + @Query("DELETE FROM SysUserAppRoleRel r WHERE r.roleId IN :roleIds") + void deleteByRoleIdInBulk(@Param("roleIds") List roleIds); + + /** + * 按用户ID批量物理删除绑定记录(「用户绑定角色」全量覆盖保存 + 删除用户时级联清理)。
+ */ + @Modifying + @Query("DELETE FROM SysUserAppRoleRel r WHERE r.userId = :userId") + void deleteByUserIdBulk(@Param("userId") Integer userId); + + /** + * 按角色ID + 用户ID 删除单条绑定记录(移除单个用户的角色关联,避免全量重提)。
+ */ + @Modifying + @Query("DELETE FROM SysUserAppRoleRel r WHERE r.roleId = :roleId AND r.userId = :userId") + void deleteByRoleIdAndUserId(@Param("roleId") Integer roleId, @Param("userId") Integer userId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java new file mode 100644 index 0000000..8152e53 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysUserGroupRel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/20 16:00 + * @description 组用户关联仓储(用于删除组/用户时校验关联数据) + */ +public interface SysUserGroupRelRepository extends JpaRepository, JpaSpecificationExecutor { + + long countByGroupId(Integer groupId); + + long countByUserId(Integer userId); + + List findByUserId(Integer userId); + + List findByGroupId(Integer groupId); + + void deleteByUserId(Integer userId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java new file mode 100644 index 0000000..f885532 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java @@ -0,0 +1,23 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +/** + * @author chenx + * @date 2026/7/20 16:14 + * @description 系统用户仓储 + */ +public interface SysUserRepository extends JpaRepository, JpaSpecificationExecutor { + + long countByAccount(String account); + + long countByAccountAndIdNot(String account, Integer id); + + /** 按登录账号(username)查用户,供用户中心组装 BoaUser。 */ + SysUser findByAccount(String account); + + /** 按手机号查用户,供用户中心组装 BoaUser。 */ + SysUser findByPhone(String phone); +} diff --git a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java new file mode 100644 index 0000000..05678d1 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java @@ -0,0 +1,36 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.entity.AppCategory; +import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo; +import com.yunhai.userbase.model.appcategory.AppCategoryQuery; +import com.yunhai.userbase.model.appcategory.AppCategoryRequest; +import com.yunhai.userbase.model.appcategory.AppCategoryVo; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/15 21:30 + * @description + */ +public interface IAppCategoryService { + + void add(AppCategoryRequest request); + + void edit(Integer id, AppCategoryRequest request); + + void delete(Integer id); + + List list(AppCategoryQuery query); + + void sort(List ids); + + /** + * 应用授权用:获取所有应用类型及其类型下的应用(id/name/icon)。
+ * 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。
+ * + * @return 应用类型 + 应用列表的授权视图 + */ + List listWithApps(); + +} diff --git a/src/main/java/com/yunhai/userbase/service/IAppModuleService.java b/src/main/java/com/yunhai/userbase/service/IAppModuleService.java new file mode 100644 index 0000000..aa0d1b7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/IAppModuleService.java @@ -0,0 +1,23 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.model.appmodule.AppModuleQuery; +import com.yunhai.userbase.model.appmodule.AppModuleRequest; +import com.yunhai.userbase.model.appmodule.AppModuleVo; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/22 + * @description 应用模块服务。
+ */ +public interface IAppModuleService { + + void add(AppModuleRequest request); + + void edit(Integer id, AppModuleRequest request); + + void delete(Integer id); + + List list(AppModuleQuery query); +} diff --git a/src/main/java/com/yunhai/userbase/service/IAppRoleService.java b/src/main/java/com/yunhai/userbase/service/IAppRoleService.java new file mode 100644 index 0000000..681240c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/IAppRoleService.java @@ -0,0 +1,105 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo; +import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest; +import com.yunhai.userbase.model.approle.AppRoleUserBindRequest; +import com.yunhai.userbase.model.approle.AppRoleUserVo; +import com.yunhai.userbase.model.approle.SysAppRoleQuery; +import com.yunhai.userbase.model.approle.SysAppRoleRequest; +import com.yunhai.userbase.model.approle.SysAppRoleVo; +import com.yunhai.userbase.model.approle.UserAppRoleBindRequest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/27 + * @description 应用业务角色服务(角色 CRUD + 角色模块授权 + 用户角色绑定)。
+ */ +public interface IAppRoleService { + + /** + * 分页查询角色列表(name 模糊、appId/allAppFlag/disabled 精确)。
+ * 普通角色回填所属应用名称 appName。
+ * + * @param query 查询条件 + * @param pageable 分页参数 + * @return 分页角色列表 + */ + Page list(SysAppRoleQuery query, Pageable pageable); + + /** + * 新增角色。
+ * 规则:普通角色 allAppFlag=false 且 appId 必填(应用须存在),同应用下名称唯一;
+ * 全应用角色 allAppFlag=true 且 appId 必须为空,全应用角色之间名称唯一。
+ * + * @param request 角色入参 + */ + void add(SysAppRoleRequest request); + + /** + * 编辑角色(可改 name/remark/disabled)。
+ * 角色类型 allAppFlag 与所属应用 appId 不允许修改(修改会导致已授权模块越界),需删除后重建。
+ * 重名校验规则与新增一致(排除自身)。
+ * + * @param id 角色ID + * @param request 角色入参 + */ + void edit(Integer id, SysAppRoleRequest request); + + /** + * 删除角色,并级联清理其模块授权记录与用户绑定记录(逻辑外键,无物理 FK)。
+ * + * @param id 角色ID + */ + void delete(Integer id); + + /** + * 角色模块授权页回显:返回该角色可授权的应用及其模块树,已授权模块 checked=true。
+ * 普通角色仅返回其所属应用;全应用角色返回系统全部应用(可跨应用授权)。
+ * + * @param roleId 角色ID + * @return 应用列表(含模块权限树) + */ + List findRoleModuleTree(Integer roleId); + + /** + * 角色模块授权保存:全量覆盖式更新 sys_app_role_module_rel(bulk 先删后插)。
+ * 校验:模块必须存在;普通角色只能授权其所属应用下的模块,越权写其他应用的模块会被拒绝。
+ * + * @param request 角色ID + 勾选的模块ID全量列表 + */ + void saveRoleModules(AppRoleModuleAssignRequest request); + + /** + * 查询角色已绑定的用户列表。
+ * + * @param roleId 角色ID + * @return 用户列表 + */ + List findRoleUsers(Integer roleId); + + /** + * 角色绑定用户保存(角色维度,全量覆盖):bulk 先删该角色全部绑定再批量插入。
+ * + * @param request 角色ID + 用户ID全量列表 + */ + void saveRoleUsers(AppRoleUserBindRequest request); + + /** + * 用户绑定角色保存(用户维度,全量覆盖):bulk 先删该用户全部绑定再批量插入。
+ * + * @param request 用户ID + 角色ID全量列表 + */ + void saveUserRoles(UserAppRoleBindRequest request); + + /** + * 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。
+ * + * @param roleId 角色ID + * @param userId 用户ID + */ + void removeRoleUser(Integer roleId, Integer userId); +} diff --git a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java new file mode 100644 index 0000000..b91c55f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java @@ -0,0 +1,85 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.application.ApplicationInfoQuery; +import com.yunhai.userbase.model.application.ApplicationInfoRequest; +import com.yunhai.userbase.model.application.ApplicationInfoSortVo; +import com.yunhai.userbase.model.application.ApplicationInfoVo; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +/** + * 应用信息业务接口(缓存由 Spring Cache 注解统一管控)。 + */ +public interface IApplicationInfoService { + + /** + * 根据 id 查询应用信息(带缓存,防击穿)。 + */ + ApplicationInfo getById(Integer id); + + /** + * 保存/更新应用信息(写后同步更新缓存)。 + */ + ApplicationInfo save(ApplicationInfo info); + + /** + * 根据 id 删除应用信息(同步清除缓存)。 + */ + void deleteById(Integer id); + + /** + * 新增应用信息。
+ * 由入参构造实体并落库;appKey 为整型,从 100000 起按数据库最大值自增,appSecret 为 32 位 UUID。
+ * + * @param request 应用新增入参 + */ + void add(ApplicationInfoRequest request); + + /** + * 编辑应用信息。
+ * 按 id 查找后拷贝入参字段并保存,appKey/appSecret 不随编辑变更。
+ * + * @param id 应用ID + * @param request 应用编辑入参 + */ + void edit(Integer id, ApplicationInfoRequest request); + + /** + * 根据 id 删除应用信息。
+ * + * @param id 应用ID + */ + void delete(Integer id); + + /** + * 重设应用密钥(appSecret)。
+ * 按 id 查找应用并生成新的 32 位 UUID 密钥后保存。
+ * + * @param id 应用ID + */ + void resetAppSecret(Integer id); + + /** + * 根据条件分页查询应用列表。
+ * + * @param query 查询条件(name 关键词按名称模糊或 appKey 精确匹配,categoryId 按类别精确匹配) + * @param pageable 分页参数(page 从 0 起,size 每页条数) + * @return 分页后的应用视图(含总条数 totalElements) + */ + Page list(ApplicationInfoQuery query, Pageable pageable); + + List findListByCategoryId(Integer categoryId); + + /** + * 同一应用类型内排序。
+ * 入参为该类型下应用的完整有序 id 列表,按列表顺序写入 sortNo(1 起)。
+ * 接口会额外校验:所有 id 必须存在且同属一个应用类型,避免跨类型串号导致排序错乱。
+ * + * @param ids 同一应用类型下的应用ID有序列表 + */ + void sort(List ids); +} diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java new file mode 100644 index 0000000..d12a66c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -0,0 +1,161 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.model.groupauthz.*; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组应用权限服务(应用开通 + 组授权 + 权限判定)。
+ */ +public interface IGroupAuthzService { + + /** + * 功能1回显:查询系统全量应用列表,并标记该组已开通的应用(checked)。
+ * + * @param groupId 组ID + * @return 全量应用列表(含开通勾选状态) + */ + List listGroupApps(Integer groupId); + + /** + * 功能1保存:全量覆盖式更新组的应用开通记录(先删旧记录再批量插入)。
+ * 被取消开通的应用,同步清理该组在其模块上的权限记录,避免残留脏数据。
+ * + * @param request 组ID + 勾选的应用ID全量列表 + */ + void assignAppsToGroup(GroupAppAssignRequest request); + + /** + * 功能2数据加载:查询该组已开通的应用及其模块树,并回显勾选状态与子组继承开关。
+ * 仅返回 sys_group_app_rel 中有记录的应用;未开通的应用不显示。
+ * + * @param groupId 组ID + * @return 已开通应用列表(含模块权限树) + */ + List findGroupAppsWithModules(Integer groupId); + + /** + * 功能2数据加载(按应用分类分组版):与 {@link #findGroupAppsWithModules(Integer)} 数据一致,
+ * 但按应用类型分类聚合,便于组授权页面按分类面板展示。
+ * 返回全部分类(含空数组),保证前端分类筛选下拉完整。
+ * + * @param groupId 组ID + * @return 按应用分类分组的已开通应用列表 + */ + List findGroupAppsWithModulesByCategory(Integer groupId); + + /** + * 功能2保存:全量覆盖式更新组的模块权限。
+ * 有旧记录则更新 inheritFlag,无则新增,不在传入列表中的旧记录物理删除。
+ * + * @param request 组ID + 勾选的模块权限全量列表(moduleId + inheritFlag) + */ + void saveModulePermissions(GroupModulePermRequest request); + + /** + * 权限判定(供登录/拦截器使用):判断用户对某模块是否有权限。
+ * 规则:用户任一所属组,沿祖先链回溯——自身显式勾选即通过;祖先勾选且 inheritFlag=true 即通过。
+ * 前置校验:该所属组必须已开通模块所属应用(最终权限 = 继承的模块权限 ∩ 该组已开通的应用)。
+ * 角色授权并集:用户经「启用状态」业务角色获得该模块授权也通过,且不要求组已开通模块所属应用;禁用角色不参与判定。
+ * + * @param userId 用户ID + * @param moduleId 模块ID + * @return true=有权限 + */ + boolean hasModulePermission(Integer userId, Integer moduleId); + + /** + * 权限一览:查询指定组下的直接用户列表(不含子组,不分页)。
+ * + * @param groupId 组ID + * @return 用户列表 + */ + List findGroupUsers(Integer groupId); + + /** + * 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。
+ * 返回用户及其主归属部门/组织路径。 + * + * @param keyword 搜索关键字 + * @return 用户列表 + */ + List searchUsers(String keyword); + + /** + * 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。
+ * 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。
+ * 权限判定复用 {@link #hasModulePermission(Integer, Integer)} 的祖先链继承逻辑。
+ * 角色授权并集:最终模块权限 = 组授权模块 ∪ 启用角色授权模块;
+ * 最终应用范围 = 组开通应用 ∪ 角色模块所属应用(角色模块不要求组必须先开通应用)。
+ * + * @param groupId 组ID(上下文,可选;目前仅用于校验用户存在性,实际聚合以 userId 为准) + * @param userId 用户ID + * @return 按应用类型分组的已授权应用模块树 + */ + List findUserPermissionTree(Integer groupId, Integer userId); + + /** + * 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。
+ * 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。
+ * + * @param userId 用户ID + * @return 用户所属角色列表 + */ + List findUserRoles(Integer userId); + + /** + * 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。
+ * 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组), + * 多个归属组时合并为一棵树(公共祖先只出现一次)。
+ * + * @param userId 用户ID + * @return 所属部门树(根节点列表);用户无组归属时返回空列表 + */ + List findUserGroupTree(Integer userId); + + // ===================== 新增:树节点式增量授权(供左侧树 + 右侧 appAuthzList 的勾选交互) ===================== + + /** + * 获取某组「已开通的应用ID列表」,供前端右侧应用清单回显勾选状态。
+ * 实现思路:应用开通「不继承」(见 SysGroupAppRel 设计)——直接查 sys_group_app_rel 中 group_id=nodeId 的记录,
+ * 返回其 app_id 集合;不沿祖先链回填父组开通的应用(父组开通不代表子组自动拥有)。
+ * 与现有 GroupAppList(返回全量应用 + checked)的区别:本接口只返回已选 id 列表,更轻量,适合做勾选回显。 + * + * @param nodeId 左侧树点击的组节点ID + * @return 该组已开通的应用ID列表(无则空列表) + */ + List findAuthorizedAppIds(Integer nodeId); + + /** + * 增量设置某组的应用开通/取消(对应前端「应用多选框」勾选/取消)。
+ * 入参:{@link SchoolAppAuthzRequest} 封装 schid / appids(逗号分隔字符串) / mode。
+ * 实现思路:
+ * - mode=0(追加/APPEND):对 appids 中每个应用,若 sys_group_app_rel 尚无记录则插入(已存在则跳过,幂等)。
+ * - mode=2(删除/DELETE):对每个应用删除开通记录,并同步清理该组在该应用下模块的权限残留(与现有 assignAppsToGroup 取消开通时行为一致,避免脏数据)。
+ * - 应用开通「不继承」,故仅操作传入的 schId 自身,不做子组级联。
+ * - mode=1(覆盖) 不适用于单条增量,直接拒绝。 + * + * @param request 学校组应用授权请求(组ID + 应用ID字符串 + 操作模式) + */ + void setSchoolApps(SchoolAppAuthzRequest request); + + /** + * 增量设置「组-应用-模块」的模块授权(对应前端「组授权」面板逐条勾选/取消)。
+ * 实现思路:逐条处理 items 列表(顺序即执行顺序,后者覆盖前者):
+ * - mode=0(追加):对 (groupId, 展开后的 moduleId) upsert 一条 sys_group_module_perm,inherit_flag=isAll。
+ * - mode=2(删除):删除对应权限记录。
+ * - moduleId=0 表示「该应用下全部模块」,展开为应用下每个真实模块各写/删一条(不写 module_id=0 伪记录)。
+ * - isAll 的语义(重点):直接映射现有 sys_group_module_perm.inherit_flag 列。
+ *   isAll=1 → inherit_flag=true:子孙组在 hasModulePermission 判定时沿祖先链自动继承该模块权限(逻辑继承,不物理写子组);
+ *   isAll=0 → inherit_flag=false:仅本组生效。
+ * 这与「应用不继承 / 模块可继承」的对称性一致,且复用现有权限判定逻辑,不破坏其他功能。
+ * - 越权防护:模块必须属于该 appId;追加前要求该组已开通该应用(与现有 SaveModulePerms 边界一致)。
+ *
+ * 若产品实际想要「物理写子组」(子组各存一条 perm,而非逻辑继承),把 isAll=1 分支改成「对 groupId + 其全部子孙组分别 upsert」即可,详见实现类注释。 + * + * @param items 模块授权条目列表(groupId/appId/moduleId/mode/isAll) + */ + void setGroupModulePerms(List items); +} diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java new file mode 100644 index 0000000..ae3ad47 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -0,0 +1,54 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupQuery; +import com.yunhai.userbase.model.sysgroup.SysGroupRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupVo; + +import java.util.List; + +/** + * @author chenx + * @date 2026/7/20 16:00 + * @description 系统组服务接口 + */ +public interface ISysGroupService { + + void add(SysGroupRequest request); + + void edit(Integer id, SysGroupRequest request); + + void delete(Integer id); + + List list(SysGroupQuery query); + + /** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */ + List getTree(); + + /** + * 获取「学校组」剪枝树:末端叶子一定是学校组。
+ * 规则:遇到学校组即截断(学校组作为叶子,其下所有组不显示);
+ * 子树中不含任何学校组的非学校组节点整体剪掉;根节点始终保留。
+ */ + List getSchoolTree(); + + /** 在指定父组下按规则批量生成子组。 */ + void batchAdd(SysGroupBatchRequest request); + + /** 对 pid 的直接子节点按给定顺序重排并落库(ids 必须全是直接子节点)。 */ + void sortChildren(SysGroupSortRequest request); + + /** 将 moveId 移动到 pid 下(环检测)。 */ + void move(SysGroupMoveRequest request); + + /** + * 收集指定组自身及其所有子孙组的ID(沿 parentId 向下遍历)。
+ * 用于「查询该节点及其子组下所有用户」等子树聚合场景。
+ * + * @param groupId 组ID + * @return 含自身在内的组ID集合;组不存在时返回仅含自身的集合 + */ + List listSelfAndDescendantIds(Integer groupId); +} diff --git a/src/main/java/com/yunhai/userbase/service/ISysOperationLogService.java b/src/main/java/com/yunhai/userbase/service/ISysOperationLogService.java new file mode 100644 index 0000000..9808163 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/ISysOperationLogService.java @@ -0,0 +1,50 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.entity.SysOperationLog; +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import com.yunhai.userbase.model.oplog.SysOperationLogListVo; +import com.yunhai.userbase.model.oplog.SysOperationLogQuery; + +/** + * 系统操作日志业务接口。
+ * + * @author chenx + * @date 2026/7/28 + */ +public interface ISysOperationLogService { + + /** + * 异步保存操作日志。
+ * 由 AOP 在 finally 中调用,失败仅记录日志,不影响主业务。
+ * + * @param entity 日志实体 + */ + void saveAsync(SysOperationLog entity); + + /** + * 分页查询操作日志列表。
+ * 列表不含响应结果(response_data),避免加载 MEDIUMBLOB 影响性能。
+ * + * @param query 查询条件 + * @param pageQuery 分页参数(pageIndex 从 1 起) + * @return 分页结果 + */ + BasePageResult pageQuery(SysOperationLogQuery query, BasePageQuery pageQuery); + + /** + * 获取某条日志的响应结果(解压后原文)。
+ * + * @param id 日志ID + * @return 解压后的响应字符串,无记录或无响应数据返回 null + */ + String getResponseData(Long id); + + /** + * 清理过期日志。
+ * 一般日志(level=3)保留 6 个月,重要(level=1)/次要(level=2)保留 12 个月。
+ * + * @return 删除条数 + */ + long cleanupExpired(); +} diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java new file mode 100644 index 0000000..d76f50a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -0,0 +1,42 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.model.sysuser.SysUserQuery; +import com.yunhai.userbase.model.sysuser.SysUserRequest; +import com.yunhai.userbase.model.sysuser.SysUserVo; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** + * @author chenx + * @date 2026/7/20 16:14 + * @description 系统用户服务接口 + */ +public interface ISysUserService { + + /** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。 */ + void add(SysUserRequest request); + + /** 编辑用户;密码为空时不修改密码。 */ + void edit(Integer id, SysUserRequest request); + + /** 删除用户:同时清理其组用户关联。 */ + void delete(Integer id); + + /** + * 启用/禁用用户。
+ * + * @param id 用户ID + * @param disabled true=禁用,false=启用 + */ + void changeStatus(Integer id, boolean disabled); + + /** + * 分页查询组下用户。
+ * 依据 query.groupId 与 query.withSub 决定组范围,query.name 做多字段匹配。
+ * + * @param query 查询条件 + * @param pageable 分页与排序 + * @return 用户分页 + */ + Page list(SysUserQuery query, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java b/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java new file mode 100644 index 0000000..f5f259e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java @@ -0,0 +1,151 @@ +package com.yunhai.userbase.service; + +import com.alibaba.fastjson.JSON; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 应用签名密钥的 Redis 桥接服务。

+ * 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的 + * common-sign-starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。

+ * 约定(读侧 starter 必须严格一致):
+ * 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};
+ * 2) value 为 fastjson 序列化的 JSON 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};
+ * 3) 不设过期时间,依赖 Redis 持久化(AOF/RDB)保证重启不丢;删除应用时直接删 key(等价于禁用)。 + */ +@Service +public class SignKeyCacheService implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(SignKeyCacheService.class); + + /** Redis key 前缀;读侧 SignKeyResolver 必须保持一致:sign:app:{appKey} */ + public static final String KEY_PREFIX = "sign:app:"; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + /** + * 同步单条应用到 Redis(新增 / 编辑 / 重置密钥时调用)。 + */ + public void syncOne(ApplicationInfo info) { + if (info == null || info.getAppKey() == null || info.getAppSecret() == null) { + return; + } + String key = KEY_PREFIX + info.getAppKey(); + SignAppKeyPayload payload = new SignAppKeyPayload( + String.valueOf(info.getAppKey()), info.getAppSecret(), true); + stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(payload)); + log.info("[SignKeyCache] sync appKey={} -> redis", info.getAppKey()); + } + + /** + * 从 Redis 移除某应用的密钥(删除应用时调用,等价于禁用该应用签名)。 + */ + public void removeOne(Integer appKey) { + if (appKey == null) { + return; + } + stringRedisTemplate.delete(KEY_PREFIX + appKey); + log.info("[SignKeyCache] remove appKey={} from redis", appKey); + } + + /** + * 全量刷新:把数据库全部应用重新写入 Redis,并清理库中已不存在的残留 key。
+ * 用于 Redis 数据丢失 / 不一致后的手动恢复,以及首次部署预热。 + * + * @return 实际写入的密钥条数 + */ + public int refreshAll() { + List all = applicationInfoRepository.findAll(); + Set dbKeys = new HashSet<>(); + for (ApplicationInfo info : all) { + if (info.getAppKey() == null || info.getAppSecret() == null) { + continue; + } + syncOne(info); + dbKeys.add(KEY_PREFIX + info.getAppKey()); + } + // 清理残留:DB 已删除但 Redis 仍存在的 key(防止被删应用仍可验签) + Set redisKeys = stringRedisTemplate.keys(KEY_PREFIX + "*"); + if (redisKeys != null) { + for (String k : redisKeys) { + if (!dbKeys.contains(k)) { + stringRedisTemplate.delete(k); + log.info("[SignKeyCache] prune stale key={}", k); + } + } + } + log.info("[SignKeyCache] refreshAll done, synced={}", dbKeys.size()); + return dbKeys.size(); + } + + /** + * 启动预热:应用启动后尽力把密钥同步到 Redis。 + * Redis 不可用时仅告警,不影响应用启动(此时验签依赖后续手动刷新或 Redis 恢复)。 + */ + @Override + public void run(ApplicationArguments args) { + try { + int n = refreshAll(); + log.info("[SignKeyCache] startup warm-up synced={}", n); + } catch (Exception e) { + log.warn("[SignKeyCache] startup warm-up failed (redis may be unavailable): {}", e.getMessage()); + } + } + + /** + * Redis value 的 JSON 载体。字段名即契约,读侧 starter 的解析类需一一对应。 + */ + public static class SignAppKeyPayload { + private String appKey; + private String secret; + private boolean enabled; + + public SignAppKeyPayload() { + } + + public SignAppKeyPayload(String appKey, String secret, boolean enabled) { + this.appKey = appKey; + this.secret = secret; + this.enabled = enabled; + } + + public String getAppKey() { + return appKey; + } + + public void setAppKey(String appKey) { + this.appKey = appKey; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } +} diff --git a/src/main/java/com/yunhai/userbase/service/UserCenterService.java b/src/main/java/com/yunhai/userbase/service/UserCenterService.java new file mode 100644 index 0000000..1bc177c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/UserCenterService.java @@ -0,0 +1,145 @@ +package com.yunhai.userbase.service; + +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * 用户中心核心服务:把 UserBase 自身的 sys_user / sys_group 组装成基础平台统一的 + * {@link BoaUser}(含部门/学校/教育局多级组织)。

+ * 这是「UserBase 由子应用升级为用户中心」的关键:平台侧 {@code UserDetailsProvider} + * ({@code UserBaseUserDetailsProvider})与开放接口({@code OpenBaseController})都复用本服务, + * 保证 CAS 会话流与签名开放流返回的 BoaUser 结构完全一致。 + */ +@Service +public class UserCenterService { + + /** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */ + private static final int ROOT_PARENT_ID = 0; + + @Autowired + private SysUserRepository sysUserRepository; + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + @Autowired + private SysGroupRepository sysGroupRepository; + + public BoaUser getUserInfoWithDeptByAccount(String account) { + if (account == null) { + return null; + } + return resolve(sysUserRepository.findByAccount(account.trim())); + } + + public BoaUser getUserInfoWithDeptByPhone(String phone) { + if (phone == null) { + return null; + } + return resolve(sysUserRepository.findByPhone(phone.trim())); + } + + public BoaUser getUserInfoWithDeptById(Integer id) { + if (id == null) { + return null; + } + return resolve(sysUserRepository.findById(id).orElse(null)); + } + + private BoaUser resolve(SysUser user) { + if (user == null) { + return null; + } + BoaUser boa = new BoaUser(); + boa.setId(user.getId()); + boa.setAccount(user.getAccount()); + boa.setName(user.getRealName()); + boa.setIdcard(user.getIdcard()); + boa.setPhone(user.getPhone()); + boa.setProfilePicture(user.getProfilePicture()); + // 用户表无 email / gender 列,置 null + boa.setEmail(null); + boa.setGender(null); + boa.setType(user.getType() != null ? user.getType().getKey() : null); + // 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用) + boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0); + boa.setRoleIds(user.getRoles()); + + List groups = buildGroups(user.getId()); + boa.setGroups(groups); + deriveOrg(groups, boa); + return boa; + } + + /** + * 组装用户组织链:取主归属组(isMain=1,否则首个关联),沿 parentId 向上回溯到根, + * 顺序为「主组 -> ... -> 根方向」(近 -> 远)。 + */ + private List buildGroups(Integer userId) { + List rels = sysUserGroupRelRepository.findByUserId(userId); + if (rels == null || rels.isEmpty()) { + return new ArrayList<>(); + } + SysUserGroupRel mainRel = rels.stream() + .filter(r -> r.getIsMain() != null && r.getIsMain() == 1) + .findFirst() + .orElse(rels.get(0)); + Integer groupId = mainRel.getGroupId(); + if (groupId == null) { + return new ArrayList<>(); + } + + List chain = new ArrayList<>(); + Integer cursor = groupId; + Set seen = new HashSet<>(); + while (cursor != null && cursor != ROOT_PARENT_ID && seen.add(cursor)) { + SysGroup g = sysGroupRepository.findById(cursor).orElse(null); + if (g == null) { + break; + } + BoaGroup bg = new BoaGroup(); + bg.setId(g.getId()); + bg.setName(g.getName()); + bg.setParentId(g.getParentId()); + bg.setType(g.getType() != null ? g.getType().getKey() : null); + chain.add(bg); + cursor = g.getParentId(); + } + return chain; + } + + /** 从组织链推导 school / bureau / dept。 */ + private void deriveOrg(List groups, BoaUser boa) { + if (groups == null || groups.isEmpty()) { + return; + } + BoaGroup school = null; + BoaGroup bureau = null; + for (BoaGroup g : groups) { + if (school == null && Objects.equals(g.getType(), GroupTypeEnum.SCHOOL_GROUP.getKey())) { + school = g; + } + if (bureau == null && Objects.equals(g.getType(), GroupTypeEnum.AREA_GROUP.getKey())) { + bureau = g; + } + } + boa.setSchool(school); + boa.setBureau(bureau); + // 部门:主组(链的近端第一个);若主组本身为学校,则部门即学校 + boa.setDept(groups.get(0)); + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java new file mode 100644 index 0000000..bc98b2b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java @@ -0,0 +1,138 @@ +package com.yunhai.userbase.service.impl; + + +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.entity.AppCategory; +import com.yunhai.userbase.model.appauthz.AppAuthzAppRow; +import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo; +import com.yunhai.userbase.model.appcategory.AppCategoryQuery; +import com.yunhai.userbase.model.appcategory.AppCategoryRequest; +import com.yunhai.userbase.model.appcategory.AppCategoryVo; +import com.yunhai.userbase.repository.AppCategoryRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.service.IAppCategoryService; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author chenx + * @date 2026/7/15 21:30 + * @description + */ +@Service +public class AppCategoryService implements IAppCategoryService { + @Autowired + private AppCategoryRepository appCategoryRepository; + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + + @Override + @Transactional + public void add(AppCategoryRequest request) { + // 重名校验:新增类别名称不允许重复 + if (appCategoryRepository.existsByName(request.getName())) { + throw new DException("应用类型已存在"); + } + AppCategory entity = new AppCategory(); + entity.setName(request.getName()); + + if (request.getSortNo() == null) { + Integer maxSortNo = appCategoryRepository.findMaxSortNo(); + entity.setSortNo(maxSortNo == null ? 1 : maxSortNo + 1); + } else { + entity.setSortNo(request.getSortNo()); + } + + appCategoryRepository.save(entity); + } + + @Override + @Transactional + public void edit(Integer id, AppCategoryRequest request) { + AppCategory entity = appCategoryRepository.findById(id) + .orElseThrow(() -> new RuntimeException("应用类别不存在")); + // 重名校验:编辑时排除自身 + if (appCategoryRepository.existsByNameAndIdNot(request.getName(), id)) { + throw new DException("应用类型已存在"); + } + entity.setName(request.getName()); + if (request.getSortNo() != null) { + entity.setSortNo(request.getSortNo()); + } + appCategoryRepository.save(entity); + } + + @Override + @Transactional + public void delete(Integer id) { + if (applicationInfoRepository.countByCategoryId(id) > 0) { + throw new RuntimeException("该类别下存在应用,无法删除"); + } + appCategoryRepository.deleteById(id); + } + + @Override + public List list(AppCategoryQuery query) { + List list; + if (StringUtils.hasText(query.getName())) { + list = appCategoryRepository.findByNameContainingOrderBySortNoAsc(query.getName()); + } else { + list = appCategoryRepository.findAllByOrderBySortNoAsc(); + } + return list.stream().map(this::convertToVo).collect(Collectors.toList()); + } + + @Override + @Transactional + public void sort(List ids) { + for (int i = 0; i < ids.size(); i++) { + if (!appCategoryRepository.existsById(ids.get(i))) { + throw new RuntimeException("类别不存在: " + ids.get(i)); + } + appCategoryRepository.updateSortNoById(ids.get(i), i + 1); + } + } + + @Override + public List listWithApps() { + List categories = appCategoryRepository.findAllByOrderBySortNoAsc(); + List rows = applicationInfoRepository.findAllAuthzRows(); + // categoryId -> 该类型下应用列表(查询已按 sortNo、id 升序,插入顺序即排序) + Map> appMap = new HashMap<>(); + for (AppAuthzAppRow row : rows) { + AppAuthzCategoryVo.AppItem item = new AppAuthzCategoryVo.AppItem(); + item.setId(row.getId()); + item.setName(row.getName()); + appMap.computeIfAbsent(row.getCategoryId(), k -> new ArrayList<>()).add(item); + } + List result = new ArrayList<>(); + for (AppCategory category : categories) { + AppAuthzCategoryVo vo = new AppAuthzCategoryVo(); + vo.setId(category.getId()); + vo.setName(category.getName()); + vo.setApp(appMap.getOrDefault(category.getId(), new ArrayList<>())); + result.add(vo); + } + return result; + } + + private AppCategoryVo convertToVo(AppCategory entity) { + AppCategoryVo vo = new AppCategoryVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } + + + +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java new file mode 100644 index 0000000..495d9a2 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java @@ -0,0 +1,277 @@ +package com.yunhai.userbase.service.impl; + +import com.bestsch.utils.DException; +import com.yunhai.userbase.entity.AppModule; +import com.yunhai.userbase.model.appmodule.AppModuleQuery; +import com.yunhai.userbase.model.appmodule.AppModuleRequest; +import com.yunhai.userbase.model.appmodule.AppModuleVo; +import com.yunhai.userbase.repository.AppModuleRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; +import com.yunhai.userbase.repository.SysGroupModulePermRepository; +import com.yunhai.userbase.service.IAppModuleService; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * @author chenx + * @date 2026/7/22 + * @description 应用模块服务:树形模块的增删改查与层级校验(逻辑外键,最深5层)。
+ */ +@Service +public class AppModuleService implements IAppModuleService { + + /** 模块最大层级(根模块=1,最深5层)。
*/ + private static final int MAX_DEPTH = 5; + + @Autowired + private AppModuleRepository appModuleRepository; + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + @Autowired + private SysGroupModulePermRepository sysGroupModulePermRepository; + + @Autowired + private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + + @Override + @Transactional + public void add(AppModuleRequest request) { + Objects.requireNonNull(request, "模块新增入参不能为空"); + if (!applicationInfoRepository.existsById(request.getAppId())) { + throw new DException("所属应用不存在"); + } + if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) { + throw new DException("自定义ID在该应用下已存在"); + } + AppModule entity = new AppModule(); + BeanUtils.copyProperties(request, entity); + entity.setId(null); + entity.setLevel(resolveLevel(request.getAppId(), request.getParentId())); + // sortNo 默认为空(由前端编辑时带入),不自动递增 + appModuleRepository.save(entity); + } + + @Override + @Transactional + public void edit(Integer id, AppModuleRequest request) { + Objects.requireNonNull(id, "模块ID不能为空"); + Objects.requireNonNull(request, "模块编辑入参不能为空"); + AppModule entity = appModuleRepository.findById(id) + .orElseThrow(() -> new RuntimeException("模块不存在: " + id)); + if (appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), request.getCustomId(), id)) { + throw new DException("自定义ID在该应用下已存在"); + } + // 可编辑字段:name/customId/path/sortNo;id、appId 不可改 + entity.setName(request.getName()); + entity.setCustomId(request.getCustomId()); + entity.setPath(request.getPath()); + entity.setSortNo(request.getSortNo()); + // parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级 + if (!Objects.equals(request.getParentId(), entity.getParentId())) { + moveSubtree(entity, request.getParentId()); + } else { + appModuleRepository.save(entity); + } + } + + @Override + @Transactional + public void delete(Integer id) { + Objects.requireNonNull(id, "模块ID不能为空"); + if (!appModuleRepository.existsById(id)) { + throw new RuntimeException("模块不存在: " + id); + } + if (appModuleRepository.countByParentId(id) > 0) { + throw new DException("该模块下存在子模块,无法删除"); + } + // AI-FIX: 删除模块时级联清理各组对该模块的权限记录(逻辑外键无物理 FK,避免残留孤儿数据) + sysGroupModulePermRepository.deleteByModuleIdInBulk(Collections.singletonList(id)); + // AI-FIX(角色授权): 删除模块时级联清理各业务角色对该模块的授权(逻辑外键无物理 FK) + sysAppRoleModuleRelRepository.deleteByModuleIdInBulk(Collections.singletonList(id)); + appModuleRepository.deleteById(id); + } + + @Override + public List list(AppModuleQuery query) { + // 载入该应用下全部模块(忽略 parentId/name,树在后处理阶段组装) + List all = appModuleRepository.findModules(query.getAppId(), null, null); + if (all.isEmpty()) { + return Collections.emptyList(); + } + // parentId -> 子节点列表 + Map> childrenMap = new HashMap>(); + for (AppModule m : all) { + Integer key = (m.getParentId() == null) ? 0 : m.getParentId(); + childrenMap.computeIfAbsent(key, k -> new ArrayList()).add(m); + } + // 每个兄弟组按 sortNo(空视为999) 升序,再按 id 升序 + Comparator siblingOrder = (a, b) -> { + int sa = a.getSortNo() == null ? 999 : a.getSortNo(); + int sb = b.getSortNo() == null ? 999 : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }; + for (List siblings : childrenMap.values()) { + siblings.sort(siblingOrder); + } + // 组装树:默认顶层为 level-1(parentId=null);传入 parentId 则以该模块为根返回其完整子树 + List tree; + if (query.getParentId() != null) { + AppModule root = all.stream() + .filter(m -> Objects.equals(m.getId(), query.getParentId())) + .findFirst() + .orElseThrow(() -> new RuntimeException("父模块不存在: " + query.getParentId())); + tree = Collections.singletonList(buildNode(root, childrenMap)); + } else { + List roots = childrenMap.getOrDefault(0, Collections.emptyList()); + tree = new ArrayList(); + for (AppModule r : roots) { + tree.add(buildNode(r, childrenMap)); + } + } + // 名称过滤:保留名称匹配或含匹配后代的分支 + String name = query.getName(); + if (name != null && !name.trim().isEmpty()) { + tree = pruneByKeyword(tree, name.trim()); + } + return tree; + } + + /** + * 递归组装节点及其子模块。
+ */ + private AppModuleVo buildNode(AppModule node, Map> childrenMap) { + AppModuleVo vo = convertToVo(node); + List children = childrenMap.get(node.getId()); + if (children != null) { + for (AppModule child : children) { + vo.getChildren().add(buildNode(child, childrenMap)); + } + } + return vo; + } + + /** + * 按关键字剪枝:仅保留自身或后代命中的分支。
+ */ + private List pruneByKeyword(List nodes, String keyword) { + List result = new ArrayList(); + for (AppModuleVo n : nodes) { + boolean selfMatch = n.getName() != null && n.getName().contains(keyword); + List keptChildren = pruneByKeyword(n.getChildren(), keyword); + if (selfMatch || !keptChildren.isEmpty()) { + n.setChildren(keptChildren); + result.add(n); + } + } + return result; + } + + /** + * 计算新建模块层级。
+ * 根模块(parentId=null)=1;子模块=父模块层级+1,超过 MAX_DEPTH 抛异常。 + */ + private Integer resolveLevel(Integer appId, Integer parentId) { + if (parentId == null) { + return 1; + } + AppModule parent = appModuleRepository.findById(parentId) + .orElseThrow(() -> new DException("父模块不存在")); + if (!Objects.equals(parent.getAppId(), appId)) { + throw new DException("父模块不属于该应用"); + } + int childLevel = parent.getLevel() + 1; + if (childLevel > MAX_DEPTH) { + throw new DException("模块层级不能超过" + MAX_DEPTH + "层"); + } + return childLevel; + } + + /** + * 移动子树:校验环与层级上限,刷新自身及后代层级后批量保存。
+ * 依赖同一事务内持久化上下文的对象同一性(all 中即 entity)。 + */ + private void moveSubtree(AppModule entity, Integer newParentId) { + List all = appModuleRepository.findModules(entity.getAppId(), null, null); + Map idMap = new HashMap(); + for (AppModule m : all) { + idMap.put(m.getId(), m); + } + // 环检测:新父节点不能是自身或自身的后代 + if (newParentId != null) { + if (Objects.equals(newParentId, entity.getId())) { + throw new DException("不能将模块移动到自身下"); + } + Integer ancestor = newParentId; + while (ancestor != null) { + AppModule a = idMap.get(ancestor); + if (a == null) { + throw new DException("父模块不存在"); + } + if (Objects.equals(a.getId(), entity.getId())) { + throw new DException("不能将模块移动到其子模块下"); + } + ancestor = a.getParentId(); + } + } + int newLevel = (newParentId == null) ? 1 : idMap.get(newParentId).getLevel() + 1; + int delta = newLevel - entity.getLevel(); + int maxRelative = maxDescendantRelativeDepth(entity.getId(), all); + if (newLevel + maxRelative > MAX_DEPTH) { + throw new DException("移动后模块层级超过" + MAX_DEPTH + "层"); + } + entity.setLevel(newLevel); + entity.setParentId(newParentId); + shiftDescendantLevels(entity.getId(), delta, all); + appModuleRepository.saveAll(all); + } + + /** + * 计算指定节点下后代的最大相对深度(直接子节点=1)。
+ */ + private int maxDescendantRelativeDepth(Integer rootId, List all) { + int max = 0; + for (AppModule m : all) { + if (Objects.equals(m.getParentId(), rootId)) { + int d = 1 + maxDescendantRelativeDepth(m.getId(), all); + if (d > max) { + max = d; + } + } + } + return max; + } + + /** + * 将指定节点所有后代的 level 增加 delta。
+ */ + private void shiftDescendantLevels(Integer rootId, int delta, List all) { + for (AppModule m : all) { + if (Objects.equals(m.getParentId(), rootId)) { + m.setLevel(m.getLevel() + delta); + shiftDescendantLevels(m.getId(), delta, all); + } + } + } + + private AppModuleVo convertToVo(AppModule entity) { + AppModuleVo vo = new AppModuleVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java new file mode 100644 index 0000000..6aad9a4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java @@ -0,0 +1,499 @@ +package com.yunhai.userbase.service.impl; + +import com.bestsch.utils.DException; +import com.yunhai.userbase.entity.AppModule; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysAppRole; +import com.yunhai.userbase.entity.SysAppRoleModuleRel; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserAppRoleRel; +import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo; +import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo; +import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest; +import com.yunhai.userbase.model.approle.AppRoleUserBindRequest; +import com.yunhai.userbase.model.approle.AppRoleUserVo; +import com.yunhai.userbase.model.approle.SysAppRoleQuery; +import com.yunhai.userbase.model.approle.SysAppRoleRequest; +import com.yunhai.userbase.model.approle.SysAppRoleVo; +import com.yunhai.userbase.model.approle.UserAppRoleBindRequest; +import com.yunhai.userbase.repository.AppModuleRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; +import com.yunhai.userbase.repository.SysAppRoleRepository; +import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.service.IAppRoleService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * @author chenx + * @date 2026/7/27 + * @description 应用业务角色服务实现。
+ * 角色创建:普通角色(allAppFlag=false,appId 必填,同应用下重名校验)/ 全应用角色(allAppFlag=true,appId 为空,全应用角色间重名校验)。
+ * 模块授权:sys_app_role_module_rel 全量覆盖式保存(bulk 先删后插);普通角色仅能授权本应用模块,全应用角色可跨应用。
+ * 用户绑定:sys_user_app_role_rel 支持角色维度 / 用户维度两个方向的全量覆盖保存。
+ */ +@Service +public class AppRoleService implements IAppRoleService { + + @Autowired + private SysAppRoleRepository sysAppRoleRepository; + + @Autowired + private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + + @Autowired + private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + @Autowired + private AppModuleRepository appModuleRepository; + + @Autowired + private SysUserRepository sysUserRepository; + + // ===================== 角色 CRUD ===================== + + @Override + public Page list(SysAppRoleQuery query, Pageable pageable) { + Objects.requireNonNull(query, "角色查询条件不能为空"); + Objects.requireNonNull(pageable, "分页参数不能为空"); + Page page = sysAppRoleRepository.findAll(select(query), pageable); + // 批量取应用名称,避免逐条查询 + Set appIds = new LinkedHashSet<>(); + for (SysAppRole role : page.getContent()) { + if (role.getAppId() != null) { + appIds.add(role.getAppId()); + } + } + Map appNameMap = buildAppNameMap(appIds); + List content = new ArrayList(); + for (SysAppRole role : page.getContent()) { + content.add(convertToVo(role, appNameMap)); + } + return new PageImpl<>(content, pageable, page.getTotalElements()); + } + + @Override + @Transactional + public void add(SysAppRoleRequest request) { + Objects.requireNonNull(request, "角色新增入参不能为空"); + boolean allAppFlag = Boolean.TRUE.equals(request.getAllAppFlag()); + checkRoleShape(allAppFlag, request.getAppId()); + // 重名校验:全应用角色之间唯一;普通角色同应用下唯一 + if (allAppFlag) { + if (sysAppRoleRepository.existsByNameAndAllAppFlagTrue(request.getName())) { + throw new DException("全应用角色名称已存在"); + } + } else { + if (sysAppRoleRepository.existsByNameAndAppId(request.getName(), request.getAppId())) { + throw new DException("该应用下角色名称已存在"); + } + } + SysAppRole entity = new SysAppRole(); + entity.setName(request.getName()); + entity.setAllAppFlag(allAppFlag); + // 全应用角色强制 appId 为空,防止脏数据 + entity.setAppId(allAppFlag ? null : request.getAppId()); + entity.setRemark(request.getRemark()); + entity.setDisabled(Boolean.TRUE.equals(request.getDisabled())); + sysAppRoleRepository.save(entity); + } + + @Override + @Transactional + public void edit(Integer id, SysAppRoleRequest request) { + Objects.requireNonNull(id, "角色ID不能为空"); + Objects.requireNonNull(request, "角色编辑入参不能为空"); + SysAppRole entity = sysAppRoleRepository.findById(id) + .orElseThrow(() -> new RuntimeException("角色不存在: " + id)); + // 角色类型与所属应用不允许修改:修改会导致已授权模块越界(普通角色授权范围与 appId 绑定) + if (!Objects.equals(entity.getAllAppFlag(), Boolean.TRUE.equals(request.getAllAppFlag())) + || !Objects.equals(entity.getAppId(), request.getAppId())) { + throw new DException("角色类型与所属应用不允许修改,请删除后重建"); + } + // 重名校验(排除自身) + if (Boolean.TRUE.equals(entity.getAllAppFlag())) { + if (sysAppRoleRepository.existsByNameAndAllAppFlagTrueAndIdNot(request.getName(), id)) { + throw new DException("全应用角色名称已存在"); + } + } else { + if (sysAppRoleRepository.existsByNameAndAppIdAndIdNot(request.getName(), entity.getAppId(), id)) { + throw new DException("该应用下角色名称已存在"); + } + } + entity.setName(request.getName()); + entity.setRemark(request.getRemark()); + if (request.getDisabled() != null) { + entity.setDisabled(request.getDisabled()); + } + sysAppRoleRepository.save(entity); + } + + @Override + @Transactional + public void delete(Integer id) { + Objects.requireNonNull(id, "角色ID不能为空"); + if (!sysAppRoleRepository.existsById(id)) { + throw new RuntimeException("角色不存在: " + id); + } + // 逻辑外键级联:先清模块授权与用户绑定,再删角色本体 + sysAppRoleModuleRelRepository.deleteByRoleIdBulk(id); + sysUserAppRoleRelRepository.deleteByRoleIdBulk(id); + sysAppRoleRepository.deleteById(id); + } + + // ===================== 角色模块授权 ===================== + + @Override + public List findRoleModuleTree(Integer roleId) { + Objects.requireNonNull(roleId, "角色ID不能为空"); + SysAppRole role = sysAppRoleRepository.findById(roleId) + .orElseThrow(() -> new RuntimeException("角色不存在: " + roleId)); + // 授权范围:普通角色仅本应用;全应用角色为系统全部应用 + List apps; + if (Boolean.TRUE.equals(role.getAllAppFlag())) { + apps = applicationInfoRepository.findAll(); + } else { + ApplicationInfo app = applicationInfoRepository.findById(role.getAppId()).orElse(null); + if (app == null) { + throw new DException("角色所属应用不存在: " + role.getAppId()); + } + apps = Collections.singletonList(app); + } + apps.sort(appOrder()); + List appIds = new ArrayList<>(); + for (ApplicationInfo app : apps) { + appIds.add(app.getId()); + } + // 应用下全部模块,按 appId 分桶 + Map> appModuleMap = new HashMap<>(); + for (AppModule m : appModuleRepository.findByAppIdIn(appIds)) { + appModuleMap.computeIfAbsent(m.getAppId(), k -> new ArrayList<>()).add(m); + } + // 已授权模块集合,供 checked 回显 + Set checkedModuleIds = new HashSet<>(); + for (SysAppRoleModuleRel rel : sysAppRoleModuleRelRepository.findByRoleId(roleId)) { + checkedModuleIds.add(rel.getModuleId()); + } + List result = new ArrayList<>(); + for (ApplicationInfo app : apps) { + AppRoleAuthzAppVo appVo = new AppRoleAuthzAppVo(); + BeanUtils.copyProperties(app, appVo); + List modules = appModuleMap.get(app.getId()); + if (modules != null) { + appVo.setModules(buildModuleTree(modules, checkedModuleIds)); + } + result.add(appVo); + } + return result; + } + + @Override + @Transactional + public void saveRoleModules(AppRoleModuleAssignRequest request) { + Objects.requireNonNull(request, "角色模块授权入参不能为空"); + Integer roleId = request.getRoleId(); + SysAppRole role = sysAppRoleRepository.findById(roleId) + .orElseThrow(() -> new RuntimeException("角色不存在: " + roleId)); + // 去重并校验模块均存在 + List moduleIds = new ArrayList<>(new LinkedHashSet<>( + request.getModuleIds() == null ? Collections.emptyList() : request.getModuleIds())); + if (!moduleIds.isEmpty()) { + Map moduleMap = new HashMap<>(); + for (AppModule m : appModuleRepository.findAllById(moduleIds)) { + moduleMap.put(m.getId(), m); + } + for (Integer moduleId : moduleIds) { + AppModule m = moduleMap.get(moduleId); + if (m == null) { + throw new DException("模块不存在: " + moduleId); + } + // 越权防护:普通角色只能授权其所属应用下的模块;全应用角色可跨应用 + if (!Boolean.TRUE.equals(role.getAllAppFlag()) && !Objects.equals(m.getAppId(), role.getAppId())) { + throw new DException("模块 " + moduleId + " 不属于该角色所属应用,普通角色不能跨应用授权"); + } + } + } + // 全量覆盖:bulk 先删旧记录(立即发 SQL,避免 flush 顺序触发唯一键冲突),再批量插入 + sysAppRoleModuleRelRepository.deleteByRoleIdBulk(roleId); + List batch = new ArrayList<>(); + for (Integer moduleId : moduleIds) { + SysAppRoleModuleRel rel = new SysAppRoleModuleRel(); + rel.setRoleId(roleId); + rel.setModuleId(moduleId); + batch.add(rel); + } + if (!batch.isEmpty()) { + sysAppRoleModuleRelRepository.saveAll(batch); + } + } + + // ===================== 用户角色绑定 ===================== + + @Override + public List findRoleUsers(Integer roleId) { + Objects.requireNonNull(roleId, "角色ID不能为空"); + if (!sysAppRoleRepository.existsById(roleId)) { + throw new DException("角色不存在: " + roleId); + } + List rels = sysUserAppRoleRelRepository.findByRoleId(roleId); + if (rels.isEmpty()) { + return Collections.emptyList(); + } + Set userIds = new LinkedHashSet<>(); + for (SysUserAppRoleRel rel : rels) { + userIds.add(rel.getUserId()); + } + Map userMap = new HashMap<>(); + for (SysUser user : sysUserRepository.findAllById(userIds)) { + userMap.put(user.getId(), user); + } + List result = new ArrayList<>(); + for (SysUserAppRoleRel rel : rels) { + SysUser user = userMap.get(rel.getUserId()); + if (user == null) { + continue; + } + AppRoleUserVo vo = new AppRoleUserVo(); + vo.setId(user.getId()); + vo.setAccount(user.getAccount()); + vo.setRealName(user.getRealName()); + result.add(vo); + } + return result; + } + + @Override + @Transactional + public void saveRoleUsers(AppRoleUserBindRequest request) { + Objects.requireNonNull(request, "角色绑定用户入参不能为空"); + Integer roleId = request.getRoleId(); + if (!sysAppRoleRepository.existsById(roleId)) { + throw new DException("角色不存在: " + roleId); + } + // 去重并校验用户均存在 + List userIds = new ArrayList<>(new LinkedHashSet<>( + request.getUserIds() == null ? Collections.emptyList() : request.getUserIds())); + for (Integer userId : userIds) { + if (!sysUserRepository.existsById(userId)) { + throw new DException("用户不存在: " + userId); + } + } + // 全量覆盖:bulk 先删旧绑定,再批量插入 + sysUserAppRoleRelRepository.deleteByRoleIdBulk(roleId); + List batch = new ArrayList<>(); + for (Integer userId : userIds) { + SysUserAppRoleRel rel = new SysUserAppRoleRel(); + rel.setUserId(userId); + rel.setRoleId(roleId); + batch.add(rel); + } + if (!batch.isEmpty()) { + sysUserAppRoleRelRepository.saveAll(batch); + } + } + + @Override + @Transactional + public void saveUserRoles(UserAppRoleBindRequest request) { + Objects.requireNonNull(request, "用户绑定角色入参不能为空"); + Integer userId = request.getUserId(); + if (!sysUserRepository.existsById(userId)) { + throw new DException("用户不存在: " + userId); + } + // 去重并校验角色均存在 + List roleIds = new ArrayList<>(new LinkedHashSet<>( + request.getRoleIds() == null ? Collections.emptyList() : request.getRoleIds())); + for (Integer roleId : roleIds) { + if (!sysAppRoleRepository.existsById(roleId)) { + throw new DException("角色不存在: " + roleId); + } + } + // 全量覆盖:bulk 先删旧绑定,再批量插入 + sysUserAppRoleRelRepository.deleteByUserIdBulk(userId); + List batch = new ArrayList<>(); + for (Integer roleId : roleIds) { + SysUserAppRoleRel rel = new SysUserAppRoleRel(); + rel.setUserId(userId); + rel.setRoleId(roleId); + batch.add(rel); + } + if (!batch.isEmpty()) { + sysUserAppRoleRelRepository.saveAll(batch); + } + } + + @Override + @Transactional + public void removeRoleUser(Integer roleId, Integer userId) { + Objects.requireNonNull(roleId, "角色ID不能为空"); + Objects.requireNonNull(userId, "用户ID不能为空"); + if (!sysAppRoleRepository.existsById(roleId)) { + throw new DException("角色不存在: " + roleId); + } + if (!sysUserRepository.existsById(userId)) { + throw new DException("用户不存在: " + userId); + } + sysUserAppRoleRelRepository.deleteByRoleIdAndUserId(roleId, userId); + } + + // ===================== 私有工具 ===================== + + /** + * 校验角色形态:普通角色 appId 必填且应用存在;全应用角色 appId 必须为空。
+ */ + private void checkRoleShape(boolean allAppFlag, Integer appId) { + if (allAppFlag) { + if (appId != null) { + throw new DException("全应用角色不能指定所属应用"); + } + } else { + if (appId == null) { + throw new DException("普通角色必须指定所属应用"); + } + if (!applicationInfoRepository.existsById(appId)) { + throw new DException("所属应用不存在: " + appId); + } + } + } + + /** + * 构建角色列表的动态查询条件(Tier 1 Specification)。
+ * name 模糊匹配;appId/allAppFlag/disabled 精确匹配。
+ */ + private Specification select(SysAppRoleQuery query) { + Specification specification = new Specification() { + @Override + public Predicate toPredicate(Root root, CriteriaQuery cq, CriteriaBuilder cb) { + List predicates = new ArrayList(); + if (query != null) { + if (StringUtils.isNotEmpty(query.getName())) { + predicates.add(cb.like(root.get("name"), "%" + query.getName() + "%")); + } + if (query.getAppId() != null) { + predicates.add(cb.equal(root.get("appId"), query.getAppId())); + } + if (query.getAllAppFlag() != null) { + predicates.add(cb.equal(root.get("allAppFlag"), query.getAllAppFlag())); + } + if (query.getDisabled() != null) { + predicates.add(cb.equal(root.get("disabled"), query.getDisabled())); + } + } + return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction(); + } + }; + return specification; + } + + /** + * 批量查询应用名称映射(角色列表回填 appName 用)。
+ */ + private Map buildAppNameMap(Set appIds) { + Map map = new HashMap<>(); + if (appIds.isEmpty()) { + return map; + } + for (ApplicationInfo app : applicationInfoRepository.findAllById(appIds)) { + map.put(app.getId(), app.getName()); + } + return map; + } + + private SysAppRoleVo convertToVo(SysAppRole entity, Map appNameMap) { + SysAppRoleVo vo = new SysAppRoleVo(); + BeanUtils.copyProperties(entity, vo); + if (entity.getAppId() != null) { + vo.setAppName(appNameMap.get(entity.getAppId())); + } + return vo; + } + + /** + * 组装单个应用下的模块树(沿用 GroupAuthzService 的组装方式:childrenMap + sortNo 排序),并回显 checked。
+ */ + private List buildModuleTree(List modules, Set checkedModuleIds) { + // parentId -> 子节点列表(null 视为 0) + Map> childrenMap = new HashMap<>(); + for (AppModule m : modules) { + Integer key = (m.getParentId() == null) ? 0 : m.getParentId(); + childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(m); + } + // 每个兄弟组按 sortNo(空视为999) 升序,再按 id 升序 + Comparator siblingOrder = (a, b) -> { + int sa = a.getSortNo() == null ? 999 : a.getSortNo(); + int sb = b.getSortNo() == null ? 999 : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }; + for (List siblings : childrenMap.values()) { + siblings.sort(siblingOrder); + } + List roots = childrenMap.getOrDefault(0, Collections.emptyList()); + List tree = new ArrayList<>(); + for (AppModule root : roots) { + tree.add(buildModuleNode(root, childrenMap, checkedModuleIds)); + } + return tree; + } + + /** + * 递归组装模块节点,并按角色授权记录回显 checked。
+ */ + private AppRoleAuthzModuleVo buildModuleNode(AppModule node, + Map> childrenMap, + Set checkedModuleIds) { + AppRoleAuthzModuleVo vo = new AppRoleAuthzModuleVo(); + BeanUtils.copyProperties(node, vo); + if (checkedModuleIds.contains(node.getId())) { + vo.setChecked(Boolean.TRUE); + } + List children = childrenMap.get(node.getId()); + if (children != null) { + for (AppModule child : children) { + vo.getChildren().add(buildModuleNode(child, childrenMap, checkedModuleIds)); + } + } + return vo; + } + + /** 应用排序:sortNo 升序(空视为999),再按 id 升序(与应用列表展示一致)。 */ + private Comparator appOrder() { + return (a, b) -> { + int sa = a.getSortNo() == null ? 999 : a.getSortNo(); + int sb = b.getSortNo() == null ? 999 : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java new file mode 100644 index 0000000..807a483 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -0,0 +1,259 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.AppModule; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysAppRole; +import com.yunhai.userbase.repository.AppModuleRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; +import com.yunhai.userbase.repository.SysAppRoleRepository; +import com.yunhai.userbase.repository.SysGroupAppRelRepository; +import com.yunhai.userbase.repository.SysGroupModulePermRepository; +import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; +import com.yunhai.userbase.service.IApplicationInfoService; +import com.yunhai.userbase.service.SignKeyCacheService; +import com.bestsch.utils.DException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import com.yunhai.userbase.model.application.ApplicationInfoQuery; +import com.yunhai.userbase.model.application.ApplicationInfoRequest; +import com.yunhai.userbase.model.application.ApplicationInfoSortVo; +import com.yunhai.userbase.model.application.ApplicationInfoVo; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + + +@Service +public class ApplicationInfoService implements IApplicationInfoService { + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + @Autowired + private AppModuleRepository appModuleRepository; + + @Autowired + private SysGroupAppRelRepository sysGroupAppRelRepository; + + @Autowired + private SysGroupModulePermRepository sysGroupModulePermRepository; + + @Autowired + private SysAppRoleRepository sysAppRoleRepository; + + @Autowired + private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + + @Autowired + private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + + @Autowired + private SignKeyCacheService signKeyCacheService; + + /** + * 查询缓存:sync = true 防击穿;底层优先 Redis,Redis 故障时自动降级 Caffeine。 + */ + @Override + @Cacheable(value = "appInfo", key = "#id", sync = true) + public ApplicationInfo getById(Integer id) { + return applicationInfoRepository.findById(id).orElse(null); + } + + /** + * 更新缓存:方法返回即写入缓存(与 @Cacheable 同一 cacheName/key,保证一致)。 + */ + @Override + @CachePut(value = "appInfo", key = "#info.id") + public ApplicationInfo save(ApplicationInfo info) { + return applicationInfoRepository.save(info); + } + + /** + * 删除缓存:删除数据时同步清除对应缓存项。 + */ + @Override + @CacheEvict(value = "appInfo", key = "#id") + public void deleteById(Integer id) { + applicationInfoRepository.deleteById(id); + } + + @Override + @Transactional + public void add(ApplicationInfoRequest request) { + Objects.requireNonNull(request, "应用新增入参不能为空"); + // 重名校验:新增应用名称不允许重复 + if (applicationInfoRepository.existsByName(request.getName())) { + throw new DException("应用名称已存在"); + } + ApplicationInfo entity = new ApplicationInfo(); + BeanUtils.copyProperties(request, entity); + entity.setId(null); + // appKey:整型,从 100000 起,取数据库现有最大 appKey + 1 + Integer maxAppKey = applicationInfoRepository.findMaxAppKey(); + Integer appKey = (maxAppKey == null) ? 100000 : maxAppKey + 1; + entity.setAppKey(appKey); + // appSecret:32 位 UUID(去除连字符) + entity.setAppSecret(UUID.randomUUID().toString().replace("-", "")); + ApplicationInfo saved = applicationInfoRepository.save(entity); + signKeyCacheService.syncOne(saved); + } + + @Override + @Transactional + public void edit(Integer id, ApplicationInfoRequest request) { + Objects.requireNonNull(id, "应用ID不能为空"); + Objects.requireNonNull(request, "应用编辑入参不能为空"); + // 重名校验:编辑时排除自身 + if (applicationInfoRepository.existsByNameAndIdNot(request.getName(), id)) { + throw new DException("应用名称已存在"); + } + ApplicationInfo entity = applicationInfoRepository.findById(id) + .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); + BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret"); + ApplicationInfo saved = applicationInfoRepository.save(entity); + signKeyCacheService.syncOne(saved); + } + + @Override + @Transactional + // AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理),并清除 appInfo 缓存 + @CacheEvict(value = "appInfo", key = "#id") + public void delete(Integer id) { + Objects.requireNonNull(id, "应用ID不能为空"); + ApplicationInfo existing = applicationInfoRepository.findById(id) + .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); + Integer appKey = existing.getAppKey(); + // AI-FIX: 删除应用前级联清理组权限关联(逻辑外键无物理 FK):先清该应用模块的组权限记录,再清组开通记录 + List modules = appModuleRepository.findByAppIdIn(java.util.Collections.singletonList(id)); + if (!modules.isEmpty()) { + List moduleIds = new ArrayList(); + for (AppModule m : modules) { + moduleIds.add(m.getId()); + } + sysGroupModulePermRepository.deleteByModuleIdInBulk(moduleIds); + } + sysGroupAppRelRepository.deleteByAppIdBulk(id); + // AI-FIX(角色授权): 删除应用前级联清理该应用下的业务角色及其授权关系(逻辑外键无物理 FK) + // 仅清理「普通角色」(appId=本应用);全应用角色(appId=null)不受应用删除影响,保留不动 + List appRoles = sysAppRoleRepository.findByAppId(id); + if (!appRoles.isEmpty()) { + List roleIds = new ArrayList(); + for (SysAppRole r : appRoles) { + roleIds.add(r.getId()); + } + sysAppRoleModuleRelRepository.deleteByRoleIdInBulk(roleIds); + sysUserAppRoleRelRepository.deleteByRoleIdInBulk(roleIds); + sysAppRoleRepository.deleteAll(appRoles); + } + // 先删模块(整棵子树随 appId 一并清除),再删应用,避免残留孤儿数据 + appModuleRepository.deleteByAppId(id); + applicationInfoRepository.deleteById(id); + signKeyCacheService.removeOne(appKey); + } + + @Override + public Page list(ApplicationInfoQuery query, Pageable pageable) { + Objects.requireNonNull(query, "应用查询条件不能为空"); + Objects.requireNonNull(pageable, "分页参数不能为空"); + Page page = applicationInfoRepository.findAll(select(query), pageable); + List content = new ArrayList(); + for (ApplicationInfo entity : page.getContent()) { + content.add(convertToVo(entity)); + } + return new PageImpl<>(content, pageable, page.getTotalElements()); + } + + /** + * 构建应用列表的动态查询条件。
+ * name 关键词:按应用名称模糊匹配,或按 appKey(appId)精确匹配;categoryId:按应用类别精确匹配。
+ * + * @param query 前端传入的查询条件 + * @return 动态查询 Specification + */ + private Specification select(ApplicationInfoQuery query) { + Specification specification = new Specification() { + @Override + public Predicate toPredicate(Root root, CriteriaQuery cq, CriteriaBuilder cb) { + List predicates = new ArrayList(); + if (query != null) { + if (StringUtils.isNotEmpty(query.getName())) { + String keyword = query.getName(); + Predicate byName = cb.like(root.get("name"), "%" + keyword + "%"); + // 关键词为纯数字时,同时按 appKey(appId)精确匹配 + if (keyword.matches("\\d+")) { + predicates.add(cb.or(byName, cb.equal(root.get("appKey"), Integer.parseInt(keyword)))); + } else { + predicates.add(byName); + } + } + if (query.getCategoryId() != null) { + predicates.add(cb.equal(root.get("categoryId"), query.getCategoryId())); + } + } + return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction(); + } + }; + return specification; + } + + @Override + @Transactional + // AI-FIX: 重置密钥后必须清除 appInfo 缓存,否则控制器随后 getById 命中旧缓存、返回旧密钥 + @CacheEvict(value = "appInfo", key = "#id") + public void resetAppSecret(Integer id) { + Objects.requireNonNull(id, "应用ID不能为空"); + ApplicationInfo entity = applicationInfoRepository.findById(id) + .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); + entity.setAppSecret(UUID.randomUUID().toString().replace("-", "")); + ApplicationInfo saved = applicationInfoRepository.save(entity); + signKeyCacheService.syncOne(saved); + } + + @Override + @Transactional + public void sort(List ids) { + if (ids == null || ids.isEmpty()) { + throw new DException("排序应用ID列表不能为空"); + } + Integer categoryId = null; + for (int i = 0; i < ids.size(); i++) { + Integer id = ids.get(i); + ApplicationInfo entity = applicationInfoRepository.findById(id) + .orElseThrow(() -> new DException("应用不存在: " + id)); + // 额外校验:排序必须限定在同一个应用类型内,避免跨类型串号导致排序错乱 + if (categoryId == null) { + categoryId = entity.getCategoryId(); + } else if (!Objects.equals(categoryId, entity.getCategoryId())) { + throw new DException("排序应用不属于同一应用类型,请检查入参"); + } + applicationInfoRepository.updateSortNoById(id, i + 1); + } + } + + private ApplicationInfoVo convertToVo(ApplicationInfo entity) { + ApplicationInfoVo vo = new ApplicationInfoVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } + + @Override + public List findListByCategoryId(Integer categoryId) { + return applicationInfoRepository.findSortVoByCategoryId(categoryId); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java new file mode 100644 index 0000000..0a27eac --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -0,0 +1,1180 @@ +package com.yunhai.userbase.service.impl; + +import com.bestsch.utils.DException; +import com.yunhai.userbase.entity.*; +import com.yunhai.userbase.enums.IEnum; +import com.yunhai.userbase.enums.SysModeEnum; +import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest; +import com.yunhai.userbase.model.groupauthz.GroupAppVo; +import com.yunhai.userbase.model.groupauthz.GroupAuthzAppVo; +import com.yunhai.userbase.model.groupauthz.GroupAuthzCategoryVo; +import com.yunhai.userbase.model.groupauthz.GroupAuthzModuleVo; +import com.yunhai.userbase.model.groupauthz.GroupModuleAuthzItem; +import com.yunhai.userbase.model.groupauthz.GroupModulePermRequest; +import com.yunhai.userbase.model.groupauthz.SchoolAppAuthzRequest; +import com.yunhai.userbase.repository.AppCategoryRepository; +import com.yunhai.userbase.repository.AppModuleRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysGroupAppRelRepository; +import com.yunhai.userbase.repository.SysGroupModulePermRepository; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.model.groupauthz.UserGroupNodeVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionAppVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionModuleVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionUserVo; +import com.yunhai.userbase.model.groupauthz.UserRoleVo; +import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; +import com.yunhai.userbase.repository.SysAppRoleRepository; +import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.service.IGroupAuthzService; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import javax.persistence.criteria.Predicate; +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * @author chenx + * @date 2026/7/23 + * @description 组应用权限服务实现。
+ * 功能1「应用开通」:sys_group_app_rel 全量覆盖式保存,开通不继承(子组须自己开通)。
+ * 功能2「组授权」:sys_group_module_perm 全量覆盖式保存,inheritFlag=true 时子孙组自动继承。
+ * 权限判定:沿 sys_group 祖先链内存回溯(树深度有限,MySQL 5.7 无递归 CTE,批量 IN 查询)。
+ * 角色授权并集:最终模块权限 = 组授权模块 ∪ 启用角色授权模块;角色模块不要求组先开通应用(见 AI-FIX 标记)。
+ */ +@Service +public class GroupAuthzService implements IGroupAuthzService { + + /** 虚拟父节点:根节点的 parentId,表示「无上级」(与 SysGroupService 保持一致)。 */ + private static final int ROOT_PARENT_ID = 0; + + @Autowired + private SysGroupAppRelRepository sysGroupAppRelRepository; + + @Autowired + private SysGroupModulePermRepository sysGroupModulePermRepository; + + @Autowired + private SysGroupRepository sysGroupRepository; + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + @Autowired + private AppCategoryRepository appCategoryRepository; + + @Autowired + private AppModuleRepository appModuleRepository; + + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + + @Autowired + private SysUserRepository sysUserRepository; + + @Autowired + private SysAppRoleRepository sysAppRoleRepository; + + @Autowired + private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + + @Autowired + private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + + // ===================== 功能1:应用开通 ===================== + + @Override + public List listGroupApps(Integer groupId) { + Objects.requireNonNull(groupId, "组ID不能为空"); + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("系统组不存在"); + } + // 系统全量应用 + 该组已开通集合,标记 checked 回显 + List apps = applicationInfoRepository.findAll(); + apps.sort(appOrder()); + Set openedAppIds = new HashSet<>(); + for (SysGroupAppRel rel : sysGroupAppRelRepository.findByGroupId(groupId)) { + openedAppIds.add(rel.getAppId()); + } + List result = new ArrayList<>(); + for (ApplicationInfo app : apps) { + GroupAppVo vo = new GroupAppVo(); + BeanUtils.copyProperties(app, vo); + vo.setChecked(openedAppIds.contains(app.getId())); + result.add(vo); + } + return result; + } + + @Override + @Transactional + public void assignAppsToGroup(GroupAppAssignRequest request) { + Objects.requireNonNull(request, "应用开通入参不能为空"); + Integer groupId = request.getGroupId(); + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("系统组不存在"); + } + // 去重并校验应用均存在 + List appIds = new ArrayList<>(new LinkedHashSet<>( + request.getAppIds() == null ? Collections.emptyList() : request.getAppIds())); + for (Integer appId : appIds) { + if (!applicationInfoRepository.existsById(appId)) { + throw new DException("应用不存在: " + appId); + } + } + // 计算本次被取消开通的应用,先清理该组在其模块上的权限记录,避免残留脏数据 + List oldRels = sysGroupAppRelRepository.findByGroupId(groupId); + List removedAppIds = new ArrayList<>(); + for (SysGroupAppRel rel : oldRels) { + if (!appIds.contains(rel.getAppId())) { + removedAppIds.add(rel.getAppId()); + } + } + if (!removedAppIds.isEmpty()) { + List removedModuleIds = new ArrayList<>(); + for (AppModule m : appModuleRepository.findByAppIdIn(removedAppIds)) { + removedModuleIds.add(m.getId()); + } + if (!removedModuleIds.isEmpty()) { + sysGroupModulePermRepository.deleteByGroupIdAndModuleIdInBulk(groupId, removedModuleIds); + } + } + // 全量覆盖:bulk 先删旧记录(立即发 SQL,避免 flush 顺序触发唯一键冲突),再批量插入 + sysGroupAppRelRepository.deleteByGroupIdBulk(groupId); + List batch = new ArrayList<>(); + for (Integer appId : appIds) { + SysGroupAppRel rel = new SysGroupAppRel(); + rel.setGroupId(groupId); + rel.setAppId(appId); + batch.add(rel); + } + if (!batch.isEmpty()) { + sysGroupAppRelRepository.saveAll(batch); + } + } + + // ===================== 功能2:组授权 ===================== + + @Override + public List findGroupAppsWithModules(Integer groupId) { + Objects.requireNonNull(groupId, "组ID不能为空"); + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("系统组不存在"); + } + // 1. 该组已开通的应用;无记录则右侧不显示任何应用 + List rels = sysGroupAppRelRepository.findByGroupId(groupId); + if (rels.isEmpty()) { + return Collections.emptyList(); + } + List appIds = new ArrayList<>(); + for (SysGroupAppRel rel : rels) { + appIds.add(rel.getAppId()); + } + List apps = applicationInfoRepository.findAllById(appIds); + apps.sort(appOrder()); + // 2. 已开通应用下的全部模块,按 appId 分桶 + List allModules = appModuleRepository.findByAppIdIn(appIds); + Map> appModuleMap = new HashMap<>(); + for (AppModule m : allModules) { + appModuleMap.computeIfAbsent(m.getAppId(), k -> new ArrayList<>()).add(m); + } + // 3. 权限回显:该组已勾选的模块权限记录 + Map permMap = new HashMap<>(); + for (SysGroupModulePerm perm : sysGroupModulePermRepository.findByGroupId(groupId)) { + permMap.put(perm.getModuleId(), perm); + } + // 4. 逐应用组装模块树 + List result = new ArrayList<>(); + for (ApplicationInfo app : apps) { + GroupAuthzAppVo appVo = new GroupAuthzAppVo(); + BeanUtils.copyProperties(app, appVo); + List modules = appModuleMap.get(app.getId()); + if (modules != null) { + appVo.setModules(buildModuleTree(modules, permMap)); + } + result.add(appVo); + } + return result; + } + + @Override + public List findGroupAppsWithModulesByCategory(Integer groupId) { + // 1. 先拿到已开通应用 + 模块权限树(categoryId 已由 BeanUtils 复制) + List appVos = findGroupAppsWithModules(groupId); + if (appVos.isEmpty()) { + // 无开通应用时仍返回全部分类(空应用列表),方便前端下拉筛选完整 + return buildEmptyCategories(); + } + + // 2. 按 categoryId 分组 + Map> categoryAppMap = new HashMap<>(); + for (GroupAuthzAppVo appVo : appVos) { + Integer categoryId = appVo.getCategoryId() == null ? 0 : appVo.getCategoryId(); + categoryAppMap.computeIfAbsent(categoryId, k -> new ArrayList<>()).add(appVo); + } + + // 3. 按分类排序号组装结果,未命中分类的放到“未分类” + List result = new ArrayList<>(); + List categories = appCategoryRepository.findAllByOrderBySortNoAsc(); + for (AppCategory category : categories) { + GroupAuthzCategoryVo vo = new GroupAuthzCategoryVo(); + vo.setId(category.getId()); + vo.setName(category.getName()); + List apps = categoryAppMap.remove(category.getId()); + if (apps != null) { + // 同一分类内应用保持排序 + apps.sort(appVoOrder()); + vo.getApps().addAll(apps); + } + result.add(vo); + } + // 未分类(categoryId 为 null 或不存在)兜底 + List uncategorized = categoryAppMap.remove(0); + if (uncategorized != null && !uncategorized.isEmpty()) { + GroupAuthzCategoryVo vo = new GroupAuthzCategoryVo(); + vo.setId(0); + vo.setName("未分类"); + uncategorized.sort(appVoOrder()); + vo.getApps().addAll(uncategorized); + result.add(vo); + } + return result; + } + + private List buildEmptyCategories() { + List result = new ArrayList<>(); + for (AppCategory category : appCategoryRepository.findAllByOrderBySortNoAsc()) { + GroupAuthzCategoryVo vo = new GroupAuthzCategoryVo(); + vo.setId(category.getId()); + vo.setName(category.getName()); + result.add(vo); + } + return result; + } + + /** 应用VO排序:sortNo 升序(空视为999),再按 id 升序。 */ + private Comparator appVoOrder() { + return (a, b) -> { + int sa = a.getSortNo() == null ? 999 : a.getSortNo(); + int sb = b.getSortNo() == null ? 999 : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId() == null ? 0 : a.getId(), b.getId() == null ? 0 : b.getId()); + }; + } + + @Override + @Transactional + public void saveModulePermissions(GroupModulePermRequest request) { + Objects.requireNonNull(request, "组授权入参不能为空"); + Integer groupId = request.getGroupId(); + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("系统组不存在"); + } + List perms = + request.getPerms() == null ? Collections.emptyList() : request.getPerms(); + // 勾选的模块必须属于该组已开通应用(边界情况:未开通应用的模块权限无效,不允许写入) + Set validModuleIds = new HashSet<>(); + List rels = sysGroupAppRelRepository.findByGroupId(groupId); + if (!rels.isEmpty()) { + List appIds = new ArrayList<>(); + for (SysGroupAppRel rel : rels) { + appIds.add(rel.getAppId()); + } + for (AppModule m : appModuleRepository.findByAppIdIn(appIds)) { + validModuleIds.add(m.getId()); + } + } + // 现有记录按 moduleId 索引,做差异比对 + Map existingMap = new HashMap<>(); + for (SysGroupModulePerm perm : sysGroupModulePermRepository.findByGroupId(groupId)) { + existingMap.put(perm.getModuleId(), perm); + } + List toSave = new ArrayList<>(); + Set requestedModuleIds = new HashSet<>(); + for (GroupModulePermRequest.ModulePermItem item : perms) { + Integer moduleId = item.getModuleId(); + if (!validModuleIds.contains(moduleId)) { + throw new DException("模块不属于该组已开通的应用: " + moduleId); + } + if (!requestedModuleIds.add(moduleId)) { + throw new DException("模块权限列表存在重复模块: " + moduleId); + } + boolean inheritFlag = Boolean.TRUE.equals(item.getInheritFlag()); + SysGroupModulePerm existing = existingMap.get(moduleId); + if (existing != null) { + // 有旧记录:仅当继承开关变化时更新 + if (!Objects.equals(existing.getInheritFlag(), inheritFlag)) { + existing.setInheritFlag(inheritFlag); + toSave.add(existing); + } + } else { + // 无旧记录:新增 + SysGroupModulePerm perm = new SysGroupModulePerm(); + perm.setGroupId(groupId); + perm.setModuleId(moduleId); + perm.setInheritFlag(inheritFlag); + toSave.add(perm); + } + } + // 取消勾选:旧记录不在本次全量列表中的,物理删除 + List toDelete = new ArrayList<>(); + for (Map.Entry entry : existingMap.entrySet()) { + if (!requestedModuleIds.contains(entry.getKey())) { + toDelete.add(entry.getValue()); + } + } + if (!toSave.isEmpty()) { + sysGroupModulePermRepository.saveAll(toSave); + } + if (!toDelete.isEmpty()) { + sysGroupModulePermRepository.deleteAll(toDelete); + } + } + + // ===================== 新增:树节点式增量授权 ===================== + + @Override + public List findAuthorizedAppIds(Integer nodeId) { + Objects.requireNonNull(nodeId, "节点组ID不能为空"); + if (!sysGroupRepository.existsById(nodeId)) { + throw new DException("系统组不存在: " + nodeId); + } + // 应用开通「不继承」:直接查本组在 sys_group_app_rel 的开通记录,返回 app_id 列表。 + // 不沿祖先链回填父组开通的应用(父组开通 != 子组拥有),与「开通不继承」原则一致。 + List appIds = new ArrayList<>(); + for (SysGroupAppRel rel : sysGroupAppRelRepository.findByGroupId(nodeId)) { + appIds.add(rel.getAppId()); + } + return appIds; + } + + @Override + @Transactional + public void setSchoolApps(SchoolAppAuthzRequest request) { + Integer schId = request.getSchid(); + Integer appId = request.getAppid(); + int mode = request.getMode(); + Objects.requireNonNull(schId, "组ID不能为空"); + Objects.requireNonNull(appId, "应用ID不能为空"); + if (!sysGroupRepository.existsById(schId)) { + throw new DException("系统组不存在: " + schId); + } + // 操作模式:仅 0=追加 / 2=删除 合法;1=覆盖 不适用于单条增量,拒绝。 + // 注:IEnum 的静态方法不被实现类继承,必须用 IEnum.getByKey 调用;未知值返回 null,便于抛清晰错误。 + SysModeEnum modeEnum = IEnum.getByKey(SysModeEnum.class, mode); + if (modeEnum == null || (modeEnum != SysModeEnum.APPEND && modeEnum != SysModeEnum.DELETE)) { + throw new DException("不支持的操作模式: " + mode + ",应用授权仅支持 0(追加)/2(删除)"); + } + // 校验应用存在(单值,无需去重/批量) + if (!applicationInfoRepository.existsById(appId)) { + throw new DException("应用不存在: " + appId); + } + // 应用开通「不继承」:仅操作 schId 自身,不级联子组(与现有 AssignApps 一致)。 + if (modeEnum == SysModeEnum.APPEND) { + if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(schId, appId)) { + SysGroupAppRel rel = new SysGroupAppRel(); + rel.setGroupId(schId); + rel.setAppId(appId); + sysGroupAppRelRepository.save(rel); + } + } else { // DELETE 取消开通 + sysGroupAppRelRepository.deleteByGroupIdAndAppIdBulk(schId, appId); + // 同步清理该组在该应用下模块的权限残留,避免「应用已关但模块权限还在」的脏数据 + List moduleIds = new ArrayList<>(); + for (AppModule m : appModuleRepository.findByAppIdIn(Collections.singletonList(appId))) { + moduleIds.add(m.getId()); + } + if (!moduleIds.isEmpty()) { + sysGroupModulePermRepository.deleteByGroupIdAndModuleIdInBulk(schId, moduleIds); + } + } + } + + @Override + @Transactional + public void setGroupModulePerms(List items) { + Objects.requireNonNull(items, "组模块授权入参不能为空"); + for (GroupModuleAuthzItem item : items) { + Integer groupId = item.getGroupId(); + Integer appId = item.getAppId(); + Integer moduleId = item.getModuleId(); + int mode = item.getMode(); + boolean isAll = item.getIsAll() != null && item.getIsAll() == 1; + // 基础校验 + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("系统组不存在: " + groupId); + } + if (!applicationInfoRepository.existsById(appId)) { + throw new DException("应用不存在: " + appId); + } + // 操作模式:仅 0=追加 / 2=删除 合法(同 SetSchoolApp,经 IEnum.getByKey 解析) + SysModeEnum modeEnum = IEnum.getByKey(SysModeEnum.class, mode); + if (modeEnum == null || (modeEnum != SysModeEnum.APPEND && modeEnum != SysModeEnum.DELETE)) { + throw new DException("不支持的操作模式: " + mode + ",模块授权仅支持 0(追加)/2(删除)"); + } + // 解析本次要操作的模块集合:moduleId=0 → 该应用下全部模块(展开,不写 module_id=0 伪记录) + List targetModuleIds = resolveModuleIds(appId, moduleId); + // isAll 语义(重点):映射 sys_group_module_perm.inherit_flag。 + // isAll=1 → inheritFlag=true:子孙组在 hasModulePermission 判定时沿祖先链自动继承(逻辑继承,不物理写子组)。 + // isAll=0 → inheritFlag=false:仅本组生效。 + // —— 与「应用不继承 / 模块可继承」对称,复用现有权限判定,不破坏其他功能。 + // —— 若需「物理写子组」:将下方 upsert/delete 作用范围由 [groupId] 改为 [groupId + 其全部子孙组] 即可 + // (子孙组集合可由 sys_group 全量构建 parentId→children 映射后 DFS 得到,参考本类 ancestorChain 的上行写法)。 + for (Integer mid : targetModuleIds) { + if (modeEnum == SysModeEnum.APPEND) { + // 边界一致:该组须已开通该应用,未开通应用的模块权限无意义 + if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(groupId, appId)) { + throw new DException("组 " + groupId + " 未开通应用 " + appId + ",无法授权其模块 " + mid); + } + SysGroupModulePerm existing = sysGroupModulePermRepository.findByGroupIdAndModuleId(groupId, mid); + if (existing != null) { + if (!Objects.equals(existing.getInheritFlag(), isAll)) { + existing.setInheritFlag(isAll); + sysGroupModulePermRepository.save(existing); + } + } else { + SysGroupModulePerm perm = new SysGroupModulePerm(); + perm.setGroupId(groupId); + perm.setModuleId(mid); + perm.setInheritFlag(isAll); + sysGroupModulePermRepository.save(perm); + } + } else { // DELETE 取消模块授权 + sysGroupModulePermRepository.deleteByGroupIdAndModuleIdBulk(groupId, mid); + } + } + } + } + + /** + * 解析某条授权项实际要操作的模块ID集合。
+ * - moduleId 为 null 或 0:返回该应用下全部真实模块的 ID(「整应用」语义)。
+ * - 具体 moduleId:校验其确实存在且属于该应用,越权写别的应用的模块会被拒绝。
+ */ + private List resolveModuleIds(Integer appId, Integer moduleId) { + if (moduleId == null || moduleId == 0) { + List ids = new ArrayList<>(); + for (AppModule m : appModuleRepository.findByAppIdIn(Collections.singletonList(appId))) { + ids.add(m.getId()); + } + return ids; + } + AppModule m = appModuleRepository.findById(moduleId).orElse(null); + if (m == null) { + throw new DException("模块不存在: " + moduleId); + } + if (!Objects.equals(m.getAppId(), appId)) { + throw new DException("模块 " + moduleId + " 不属于应用 " + appId); + } + return Collections.singletonList(moduleId); + } + + // ===================== 权限一览 ===================== + + @Override + public List findGroupUsers(Integer groupId) { + Objects.requireNonNull(groupId, "组ID不能为空"); + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("系统组不存在"); + } + List rels = sysUserGroupRelRepository.findByGroupId(groupId); + if (rels.isEmpty()) { + return Collections.emptyList(); + } + Set userIds = new LinkedHashSet<>(); + for (SysUserGroupRel rel : rels) { + if (rel.getUserId() != null) { + userIds.add(rel.getUserId()); + } + } + Map userMap = new HashMap<>(); + for (SysUser user : sysUserRepository.findAllById(userIds)) { + userMap.put(user.getId(), user); + } + String groupPath = buildGroupPath(groupId, buildAllGroupMap()); + + List result = new ArrayList<>(); + for (SysUserGroupRel rel : rels) { + SysUser user = userMap.get(rel.getUserId()); + if (user == null) { + continue; + } + UserPermissionUserVo vo = new UserPermissionUserVo(); + vo.setId(user.getId()); + vo.setAccount(user.getAccount()); + vo.setRealName(user.getRealName()); + vo.setIsMain(rel.getIsMain()); + vo.setGroupPath(groupPath); + result.add(vo); + } + return result; + } + + @Override + public List searchUsers(String keyword) { + if (keyword == null) { + keyword = ""; + } + String kw = keyword.trim(); + if (kw.isEmpty()) { + return Collections.emptyList(); + } + Specification spec = buildUserSearchSpec(kw); + List users = sysUserRepository.findAll(spec); + if (users.isEmpty()) { + return Collections.emptyList(); + } + + // 加载所有组,用于构建主归属路径 + Map allGroupMap = buildAllGroupMap(); + // 加载用户与组关联,找到每个用户的主归属组 + Set userIds = new LinkedHashSet<>(); + for (SysUser user : users) { + userIds.add(user.getId()); + } + Map mainRelMap = new HashMap<>(); + for (SysUserGroupRel rel : sysUserGroupRelRepository.findAll()) { + if (rel.getUserId() == null || !userIds.contains(rel.getUserId())) { + continue; + } + SysUserGroupRel existing = mainRelMap.get(rel.getUserId()); + // 优先取主归属(isMain=1),否则保留第一个 + if (existing == null || (existing.getIsMain() == null || existing.getIsMain() != 1) && rel.getIsMain() != null && rel.getIsMain() == 1) { + mainRelMap.put(rel.getUserId(), rel); + } + } + + List result = new ArrayList<>(); + for (SysUser user : users) { + SysUserGroupRel mainRel = mainRelMap.get(user.getId()); + String path = ""; + if (mainRel != null && mainRel.getGroupId() != null) { + path = buildGroupPath(mainRel.getGroupId(), allGroupMap); + } + UserPermissionUserVo vo = new UserPermissionUserVo(); + vo.setId(user.getId()); + vo.setAccount(user.getAccount()); + vo.setRealName(user.getRealName()); + vo.setIsMain(mainRel != null ? mainRel.getIsMain() : null); + vo.setGroupPath(path); + result.add(vo); + } + return result; + } + + @Override + public List findUserPermissionTree(Integer groupId, Integer userId) { + Objects.requireNonNull(userId, "用户ID不能为空"); + SysUser user = sysUserRepository.findById(userId).orElse(null); + if (user == null) { + throw new DException("用户不存在"); + } + // AI-FIX(角色授权): 先取用户经「启用」角色获得的模块ID集合,与组授权做并集; + // 角色授权不依赖组归属与组开通应用,故下方不再因无组归属而提前返回 + Set roleModuleIds = collectEnabledRoleModuleIds(userId); + + // 1. 用户所属的全部组 + List userRels = sysUserGroupRelRepository.findByUserId(userId); + Set selfGroupIds = new LinkedHashSet<>(); + for (SysUserGroupRel rel : userRels) { + if (rel.getGroupId() != null) { + selfGroupIds.add(rel.getGroupId()); + } + } + + // 2. 全部组,构建祖先链 + Map allGroupMap = buildAllGroupMap(); + Map> chainMap = new HashMap<>(); + Set allChainIds = new LinkedHashSet<>(); + for (Integer groupIdItem : selfGroupIds) { + List chain = ancestorChain(groupIdItem, allGroupMap); + chainMap.put(groupIdItem, chain); + allChainIds.addAll(chain); + } + + // 3. 收集这些组已开通的应用 + Map> groupToApps = new HashMap<>(); + Set allAppIds = new LinkedHashSet<>(); + for (Integer groupIdItem : selfGroupIds) { + List appRels = sysGroupAppRelRepository.findByGroupId(groupIdItem); + Set apps = new LinkedHashSet<>(); + for (SysGroupAppRel rel : appRels) { + if (rel.getAppId() != null) { + apps.add(rel.getAppId()); + } + } + groupToApps.put(groupIdItem, apps); + allAppIds.addAll(apps); + } + // AI-FIX(角色授权): 最终应用范围 = 组开通应用 ∪ 角色模块所属应用(角色模块不要求组先开通应用) + if (!roleModuleIds.isEmpty()) { + for (AppModule m : appModuleRepository.findAllById(roleModuleIds)) { + if (m.getAppId() != null) { + allAppIds.add(m.getAppId()); + } + } + } + if (allAppIds.isEmpty()) { + return Collections.emptyList(); + } + + // 4. 查询应用与分类 + Map appMap = new HashMap<>(); + List apps = applicationInfoRepository.findAllById(allAppIds); + for (ApplicationInfo app : apps) { + appMap.put(app.getId(), app); + } + + // 5. 查询这些应用下的所有模块 + List allModules = appModuleRepository.findByAppIdIn(new ArrayList<>(allAppIds)); + if (allModules.isEmpty()) { + return Collections.emptyList(); + } + Set allModuleIds = new LinkedHashSet<>(); + Map> appModulesMap = new HashMap<>(); + for (AppModule m : allModules) { + allModuleIds.add(m.getId()); + appModulesMap.computeIfAbsent(m.getAppId(), k -> new ArrayList<>()).add(m); + } + + // 6. 批量查询所有相关模块在所有相关组/祖先上的权限记录 + Map> modulePermMap = new HashMap<>(); + // AI-FIX(角色授权): 用户可能无任何组归属(纯角色授权),祖先链为空时跳过 IN 空集合查询 + if (!allChainIds.isEmpty()) { + List perms = sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn( + new ArrayList<>(allModuleIds), new ArrayList<>(allChainIds)); + for (SysGroupModulePerm perm : perms) { + if (perm.getModuleId() == null || perm.getGroupId() == null) { + continue; + } + modulePermMap.computeIfAbsent(perm.getModuleId(), k -> new HashMap<>()).put(perm.getGroupId(), perm); + } + } + + // 7. 判定每个模块是否有权限 + // AI-FIX(角色授权): 最终模块权限 = 组授权模块 ∪ 角色授权模块 + Set permittedModuleIds = new LinkedHashSet<>(); + for (AppModule m : allModules) { + if (roleModuleIds.contains(m.getId()) + || hasUserModulePermission(m.getId(), m.getAppId(), selfGroupIds, chainMap, groupToApps, modulePermMap)) { + permittedModuleIds.add(m.getId()); + } + } + if (permittedModuleIds.isEmpty()) { + return Collections.emptyList(); + } + + // 8. 按应用过滤并构建模块树(仅保留有权限的模块) + Map> categoryAppMap = new HashMap<>(); + for (Map.Entry> entry : appModulesMap.entrySet()) { + Integer appId = entry.getKey(); + ApplicationInfo app = appMap.get(appId); + if (app == null) { + continue; + } + List moduleTree = buildUserPermissionModuleTree( + entry.getValue(), permittedModuleIds); + if (moduleTree.isEmpty()) { + continue; // 无授权模块的应用不显示 + } + UserPermissionAppVo appVo = new UserPermissionAppVo(); + appVo.setId(app.getId()); + appVo.setName(app.getName()); + appVo.setAppKey(app.getAppKey()); + appVo.setPcIcon(app.getPcIcon()); + appVo.setSortNo(app.getSortNo()); + appVo.setModules(moduleTree); + Integer categoryId = app.getCategoryId() == null ? 0 : app.getCategoryId(); + categoryAppMap.computeIfAbsent(categoryId, k -> new ArrayList<>()).add(appVo); + } + if (categoryAppMap.isEmpty()) { + return Collections.emptyList(); + } + + // 9. 按分类排序号组装结果,只返回有应用的分组 + List result = new ArrayList<>(); + List categories = appCategoryRepository.findAllByOrderBySortNoAsc(); + for (AppCategory category : categories) { + List appVos = categoryAppMap.remove(category.getId()); + if (appVos == null || appVos.isEmpty()) { + continue; + } + appVos.sort(userPermissionAppOrder()); + UserPermissionCategoryVo vo = new UserPermissionCategoryVo(); + vo.setId(category.getId()); + vo.setName(category.getName()); + vo.setSortNo(category.getSortNo()); + vo.getApps().addAll(appVos); + result.add(vo); + } + // 未分类兜底 + List uncategorized = categoryAppMap.remove(0); + if (uncategorized != null && !uncategorized.isEmpty()) { + uncategorized.sort(userPermissionAppOrder()); + UserPermissionCategoryVo vo = new UserPermissionCategoryVo(); + vo.setId(0); + vo.setName("未分类"); + vo.setSortNo(999); + vo.getApps().addAll(uncategorized); + result.add(vo); + } + return result; + } + + @Override + public List findUserRoles(Integer userId) { + Objects.requireNonNull(userId, "用户ID不能为空"); + if (!sysUserRepository.existsById(userId)) { + throw new DException("用户不存在"); + } + List rels = sysUserAppRoleRelRepository.findByUserId(userId); + if (rels.isEmpty()) { + return Collections.emptyList(); + } + Set roleIds = new LinkedHashSet<>(); + for (SysUserAppRoleRel rel : rels) { + if (rel.getRoleId() != null) { + roleIds.add(rel.getRoleId()); + } + } + Map roleMap = new HashMap<>(); + Set appIds = new LinkedHashSet<>(); + for (SysAppRole role : sysAppRoleRepository.findAllById(roleIds)) { + roleMap.put(role.getId(), role); + if (role.getAppId() != null) { + appIds.add(role.getAppId()); + } + } + // 批量取应用名称,普通角色回填 appName + Map appNameMap = new HashMap<>(); + if (!appIds.isEmpty()) { + for (ApplicationInfo app : applicationInfoRepository.findAllById(appIds)) { + appNameMap.put(app.getId(), app.getName()); + } + } + List result = new ArrayList<>(); + for (Integer roleId : roleIds) { + SysAppRole role = roleMap.get(roleId); + if (role == null) { + continue; + } + UserRoleVo vo = new UserRoleVo(); + vo.setId(role.getId()); + vo.setName(role.getName()); + vo.setAllAppFlag(role.getAllAppFlag()); + vo.setAppId(role.getAppId()); + if (role.getAppId() != null) { + vo.setAppName(appNameMap.get(role.getAppId())); + } + vo.setDisabled(role.getDisabled()); + result.add(vo); + } + return result; + } + + @Override + public List findUserGroupTree(Integer userId) { + Objects.requireNonNull(userId, "用户ID不能为空"); + if (!sysUserRepository.existsById(userId)) { + throw new DException("用户不存在"); + } + // 1. 用户直接归属的组 + List rels = sysUserGroupRelRepository.findByUserId(userId); + Set selfGroupIds = new LinkedHashSet<>(); + for (SysUserGroupRel rel : rels) { + if (rel.getGroupId() != null) { + selfGroupIds.add(rel.getGroupId()); + } + } + if (selfGroupIds.isEmpty()) { + return Collections.emptyList(); + } + // 2. 祖先链并集(含自身);多个归属组共享祖先时只出现一次 + Map allGroupMap = buildAllGroupMap(); + Set nodeIds = new LinkedHashSet<>(); + for (Integer gid : selfGroupIds) { + nodeIds.addAll(ancestorChain(gid, allGroupMap)); + } + if (nodeIds.isEmpty()) { + return Collections.emptyList(); + } + // 3. 仅用链上节点组装树:父节点在链上则挂到父下,否则视为根 + Map voMap = new LinkedHashMap<>(); + for (Integer id : nodeIds) { + SysGroup g = allGroupMap.get(id); + UserGroupNodeVo vo = new UserGroupNodeVo(); + vo.setId(id); + vo.setName(g == null || g.getName() == null ? String.valueOf(id) : g.getName()); + vo.setOwn(selfGroupIds.contains(id)); + voMap.put(id, vo); + } + List roots = new ArrayList<>(); + for (Integer id : nodeIds) { + SysGroup g = allGroupMap.get(id); + Integer parentId = (g == null) ? null : g.getParentId(); + UserGroupNodeVo parent = (parentId == null) ? null : voMap.get(parentId); + if (parent != null) { + parent.getChildren().add(voMap.get(id)); + } else { + roots.add(voMap.get(id)); + } + } + return roots; + } + + /** + * 用户维度权限判定:用户对指定模块是否有权限。
+ * 复用 hasModulePermission 的祖先链继承逻辑,但基于已预热的批量查询结果。 + */ + private boolean hasUserModulePermission(Integer moduleId, Integer appId, + Set selfGroupIds, + Map> chainMap, + Map> groupToApps, + Map> modulePermMap) { + Map permMap = modulePermMap.get(moduleId); + if (permMap == null || permMap.isEmpty()) { + return false; + } + for (Integer selfGroupId : selfGroupIds) { + Set openedApps = groupToApps.get(selfGroupId); + if (openedApps == null || !openedApps.contains(appId)) { + continue; + } + List chain = chainMap.get(selfGroupId); + if (chain == null || chain.isEmpty()) { + continue; + } + for (Integer ancestorId : chain) { + SysGroupModulePerm perm = permMap.get(ancestorId); + if (perm == null) { + continue; + } + if (Objects.equals(ancestorId, selfGroupId)) { + return true; + } + if (Boolean.TRUE.equals(perm.getInheritFlag())) { + return true; + } + } + } + return false; + } + + /** + * 构建用户权限一览的模块树:仅保留有权限的模块,父节点无权限时子节点挂到根级。 + */ + private List buildUserPermissionModuleTree(List modules, + Set permittedModuleIds) { + // 先过滤 + List permitted = new ArrayList<>(); + for (AppModule m : modules) { + if (permittedModuleIds.contains(m.getId())) { + permitted.add(m); + } + } + if (permitted.isEmpty()) { + return Collections.emptyList(); + } + // parentId -> 子节点列表;若父节点不在 permitted 集合中,则视为根节点 + Map> childrenMap = new HashMap<>(); + for (AppModule m : permitted) { + Integer parentId = m.getParentId(); + Integer key = (parentId == null || !permittedModuleIds.contains(parentId)) ? 0 : parentId; + childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(m); + } + Comparator siblingOrder = (a, b) -> { + int sa = a.getSortNo() == null ? 999 : a.getSortNo(); + int sb = b.getSortNo() == null ? 999 : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }; + for (List siblings : childrenMap.values()) { + siblings.sort(siblingOrder); + } + List roots = childrenMap.getOrDefault(0, Collections.emptyList()); + List tree = new ArrayList<>(); + for (AppModule root : roots) { + tree.add(buildUserPermissionModuleNode(root, childrenMap)); + } + return tree; + } + + private UserPermissionModuleVo buildUserPermissionModuleNode(AppModule node, + Map> childrenMap) { + UserPermissionModuleVo vo = new UserPermissionModuleVo(); + BeanUtils.copyProperties(node, vo); + List children = childrenMap.get(node.getId()); + if (children != null) { + for (AppModule child : children) { + vo.getChildren().add(buildUserPermissionModuleNode(child, childrenMap)); + } + } + return vo; + } + + private Comparator userPermissionAppOrder() { + return (a, b) -> { + int sa = a.getSortNo() == null ? 999 : a.getSortNo(); + int sb = b.getSortNo() == null ? 999 : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId() == null ? 0 : a.getId(), b.getId() == null ? 0 : b.getId()); + }; + } + + private Map buildAllGroupMap() { + Map map = new HashMap<>(); + for (SysGroup g : sysGroupRepository.findAll()) { + map.put(g.getId(), g); + } + return map; + } + + private String buildGroupPath(Integer groupId, Map allGroupMap) { + if (groupId == null) { + return ""; + } + List names = new ArrayList<>(); + Set visited = new HashSet<>(); + Integer cursor = groupId; + while (cursor != null && cursor != ROOT_PARENT_ID && visited.add(cursor)) { + SysGroup g = allGroupMap.get(cursor); + if (g == null) { + break; + } + names.add(g.getName() == null ? "" : g.getName()); + cursor = g.getParentId(); + } + Collections.reverse(names); + return names.isEmpty() ? "" : "/" + String.join("/", names); + } + + private Specification buildUserSearchSpec(String keyword) { + return (root, cbQuery, cb) -> { + List predicates = new ArrayList<>(); + String like = "%" + keyword + "%"; + predicates.add(cb.like(root.get("account"), like)); + predicates.add(cb.like(root.get("realName"), like)); + predicates.add(cb.like(root.get("idcard"), like)); + try { + predicates.add(cb.equal(root.get("phone"), keyword)); + } catch (Exception ignored) { + // phone 字段可能不存在或类型不匹配时忽略精确匹配 + } + return cb.or(predicates.toArray(new Predicate[0])); + }; + } + + // ===================== 权限判定 ===================== + + @Override + public boolean hasModulePermission(Integer userId, Integer moduleId) { + Objects.requireNonNull(userId, "用户ID不能为空"); + Objects.requireNonNull(moduleId, "模块ID不能为空"); + AppModule module = appModuleRepository.findById(moduleId).orElse(null); + if (module == null) { + return false; + } + // AI-FIX(角色授权): 用户经「启用」角色获得该模块授权即通过,不要求组开通模块所属应用;禁用角色已在收集时过滤 + if (collectEnabledRoleModuleIds(userId).contains(moduleId)) { + return true; + } + // 1. 用户所属的全部组 + List userRels = sysUserGroupRelRepository.findByUserId(userId); + if (userRels.isEmpty()) { + return false; + } + // 2. 全部组载入内存(树深度有限),供祖先链回溯 + Map allGroupMap = new HashMap<>(); + for (SysGroup g : sysGroupRepository.findAll()) { + allGroupMap.put(g.getId(), g); + } + // 3. 前置校验:所属组必须已开通模块所属应用(最终权限 = 继承的模块权限 ∩ 已开通应用); + // 通过校验的组构建祖先链(含自身),并收集祖先链并集用于一次批量 IN 查询 + Map> chainMap = new HashMap<>(); + Set allChainIds = new LinkedHashSet<>(); + for (SysUserGroupRel rel : userRels) { + Integer groupId = rel.getGroupId(); + if (groupId == null || chainMap.containsKey(groupId)) { + continue; + } + if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(groupId, module.getAppId())) { + continue; + } + List chain = ancestorChain(groupId, allGroupMap); + chainMap.put(groupId, chain); + allChainIds.addAll(chain); + } + if (chainMap.isEmpty()) { + return false; + } + // 4. 一次批量查询目标模块在所有祖先链节点上的权限记录 + Map permMap = new HashMap<>(); + List perms = + sysGroupModulePermRepository.findByModuleIdAndGroupIdIn(moduleId, new ArrayList<>(allChainIds)); + for (SysGroupModulePerm perm : perms) { + permMap.put(perm.getGroupId(), perm); + } + if (permMap.isEmpty()) { + return false; + } + // 5. 判定:自身显式勾选直接通过;祖先勾选且开启继承则子孙生效 + for (Map.Entry> entry : chainMap.entrySet()) { + Integer selfGroupId = entry.getKey(); + for (Integer ancestorId : entry.getValue()) { + SysGroupModulePerm perm = permMap.get(ancestorId); + if (perm == null) { + continue; + } + if (Objects.equals(ancestorId, selfGroupId)) { + return true; + } + if (Boolean.TRUE.equals(perm.getInheritFlag())) { + return true; + } + } + } + return false; + } + + // ===================== 私有工具 ===================== + + /** + * 构建组的祖先链(从自身到根节点,含自身),沿 parentId 向上回溯。
+ * 带 visited 防环保护,脏数据成环时中断回溯。
+ */ + private List ancestorChain(Integer groupId, Map allGroupMap) { + List chain = new ArrayList<>(); + Set visited = new HashSet<>(); + Integer cursor = groupId; + while (cursor != null && cursor != ROOT_PARENT_ID && visited.add(cursor)) { + SysGroup g = allGroupMap.get(cursor); + if (g == null) { + break; + } + chain.add(cursor); + cursor = g.getParentId(); + } + return chain; + } + + /** + * 组装单个应用下的模块权限树(沿用 AppModuleService 的组装方式:childrenMap + sortNo 排序)。
+ */ + private List buildModuleTree(List modules, + Map permMap) { + // parentId -> 子节点列表(null 视为 0) + Map> childrenMap = new HashMap<>(); + for (AppModule m : modules) { + Integer key = (m.getParentId() == null) ? 0 : m.getParentId(); + childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(m); + } + // 每个兄弟组按 sortNo(空视为999) 升序,再按 id 升序 + Comparator siblingOrder = (a, b) -> { + int sa = a.getSortNo() == null ? 999 : a.getSortNo(); + int sb = b.getSortNo() == null ? 999 : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }; + for (List siblings : childrenMap.values()) { + siblings.sort(siblingOrder); + } + List roots = childrenMap.getOrDefault(0, Collections.emptyList()); + List tree = new ArrayList<>(); + for (AppModule root : roots) { + tree.add(buildModuleNode(root, childrenMap, permMap)); + } + return tree; + } + + /** + * 递归组装模块节点,并按权限记录回显 checked / inheritFlag。
+ */ + private GroupAuthzModuleVo buildModuleNode(AppModule node, + Map> childrenMap, + Map permMap) { + GroupAuthzModuleVo vo = new GroupAuthzModuleVo(); + BeanUtils.copyProperties(node, vo); + SysGroupModulePerm perm = permMap.get(node.getId()); + if (perm != null) { + vo.setChecked(Boolean.TRUE); + vo.setInheritFlag(Boolean.TRUE.equals(perm.getInheritFlag())); + } + List children = childrenMap.get(node.getId()); + if (children != null) { + for (AppModule child : children) { + vo.getChildren().add(buildModuleNode(child, childrenMap, permMap)); + } + } + return vo; + } + + /** 应用排序:sortNo 升序(空视为999),再按 id 升序(与应用列表展示一致)。 */ + private Comparator appOrder() { + return (a, b) -> { + int sa = a.getSortNo() == null ? 999 : a.getSortNo(); + int sb = b.getSortNo() == null ? 999 : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }; + } + + /** + * 收集用户经「启用状态」业务角色获得授权的模块ID集合(角色授权并集的角色侧输入)。
+ * 禁用角色(disabled=true)不参与权限计算;用户无绑定或角色全被禁用时返回空集合。
+ */ + private Set collectEnabledRoleModuleIds(Integer userId) { + List rels = sysUserAppRoleRelRepository.findByUserId(userId); + if (rels.isEmpty()) { + return Collections.emptySet(); + } + Set roleIds = new LinkedHashSet<>(); + for (SysUserAppRoleRel rel : rels) { + if (rel.getRoleId() != null) { + roleIds.add(rel.getRoleId()); + } + } + // 仅保留启用角色 + List enabledRoleIds = new ArrayList<>(); + for (SysAppRole role : sysAppRoleRepository.findAllById(roleIds)) { + if (!Boolean.TRUE.equals(role.getDisabled())) { + enabledRoleIds.add(role.getId()); + } + } + if (enabledRoleIds.isEmpty()) { + return Collections.emptySet(); + } + Set moduleIds = new LinkedHashSet<>(); + for (SysAppRoleModuleRel rel : sysAppRoleModuleRelRepository.findByRoleIdIn(enabledRoleIds)) { + if (rel.getModuleId() != null) { + moduleIds.add(rel.getModuleId()); + } + } + return moduleIds; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java new file mode 100644 index 0000000..9741db5 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -0,0 +1,403 @@ +package com.yunhai.userbase.service.impl; + +import com.bestsch.utils.DException; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.enums.IEnum; +import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupQuery; +import com.yunhai.userbase.model.sysgroup.SysGroupRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupVo; +import com.yunhai.userbase.repository.SysGroupAppRelRepository; +import com.yunhai.userbase.repository.SysGroupModulePermRepository; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.service.ISysGroupService; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * @author chenx + * @date 2026/7/20 16:00 + * @description 系统组服务实现:组织架构树(仅 parentId 逻辑外键,不使用 path;后续可用 MySQL8 WITH 优化递归查询)。
+ */ +@Service +public class SysGroupService implements ISysGroupService { + + /** 虚拟父节点:根节点(教育云中心)的 parentId,表示「无上级」。 */ + private static final int ROOT_PARENT_ID = 0; + + @Autowired + private SysGroupRepository sysGroupRepository; + + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + + @Autowired + private SysGroupAppRelRepository sysGroupAppRelRepository; + + @Autowired + private SysGroupModulePermRepository sysGroupModulePermRepository; + + // ===================== 基础增删改(含根节点保护) ===================== + + @Override + @Transactional + public void add(SysGroupRequest request) { + Integer parentId = request.getParentId(); + // 根节点由系统自动维护,不允许手动新增一个 pid=0 的组 + if (parentId == null || parentId == ROOT_PARENT_ID) { + throw new DException("根节点由系统自动创建,不可手动添加"); + } + if (!sysGroupRepository.existsById(parentId)) { + throw new DException("父组不存在"); + } + GroupTypeEnum typeEnum = resolveType(request.getType()); + + SysGroup entity = new SysGroup(); + entity.setName(request.getName()); + entity.setParentId(parentId); + entity.setType(typeEnum); + entity.setSort(nextSort(parentId)); + sysGroupRepository.save(entity); + } + + @Override + @Transactional + public void edit(Integer id, SysGroupRequest request) { + SysGroup entity = sysGroupRepository.findById(id) + .orElseThrow(() -> new DException("系统组不存在")); + // 根节点不可编辑 + if (isRoot(entity)) { + throw new DException("根节点不可编辑"); + } + entity.setName(request.getName()); + if (request.getType() != null) { + entity.setType(resolveType(request.getType())); + } + if (request.getSort() != null) { + entity.setSort(request.getSort()); + } + // 注:移动请走 move 接口,edit 不改 parentId,避免与移动逻辑混淆 + sysGroupRepository.save(entity); + } + + @Override + @Transactional + public void delete(Integer id) { + SysGroup entity = sysGroupRepository.findById(id) + .orElseThrow(() -> new DException("系统组不存在")); + // 根节点不可删除 + if (isRoot(entity)) { + throw new DException("根节点不可删除"); + } + if (sysGroupRepository.countByParentId(id) > 0) { + throw new DException("该组下存在子组,无法删除"); + } + if (sysUserGroupRelRepository.countByGroupId(id) > 0) { + throw new DException("有用户,不能删除组"); + } + // AI-FIX: 删除组时级联清理其应用开通记录与模块权限记录(逻辑外键无物理 FK,避免残留孤儿数据) + sysGroupAppRelRepository.deleteByGroupIdBulk(id); + sysGroupModulePermRepository.deleteByGroupIdBulk(id); + sysGroupRepository.deleteById(id); + } + + @Override + public List list(SysGroupQuery query) { + List list; + if (StringUtils.hasText(query.getName())) { + list = sysGroupRepository.findByNameContainingOrderBySortAsc(query.getName()); + } else if (query.getParentId() != null) { + list = sysGroupRepository.findByParentIdOrderBySortAsc(query.getParentId()); + } else { + list = sysGroupRepository.findAllByOrderBySortAsc(); + } + return list.stream().map(this::convertToVo).collect(Collectors.toList()); + } + + // ===================== 组织架构树 ===================== + + @Override + public List getTree() { + // 根节点由 SysGroupRootInitializer 在启动时幂等创建,此处直接读取组装 + List all = sysGroupRepository.findAll(); + if (all.isEmpty()) { + return new ArrayList<>(); + } + Map> childrenMap = buildSortedChildrenMap(all); + // 从根(pid=0)开始组装树 + List roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); + return roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList()); + } + + @Override + public List getSchoolTree() { + List all = sysGroupRepository.findAll(); + if (all.isEmpty()) { + return new ArrayList<>(); + } + Map> childrenMap = buildSortedChildrenMap(all); + List roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); + List result = new ArrayList<>(); + for (SysGroup root : roots) { + SysGroupVo vo = buildSchoolNode(root, childrenMap); + if (vo == null) { + // 根节点始终保留(即使其下没有任何学校组),children 为空 + vo = convertToVo(root); + } + result.add(vo); + } + return result; + } + + /** + * 递归组装「学校组」剪枝节点。
+ * 学校组:直接作为叶子返回(不再向下递归,其下所有组不显示);
+ * 非学校组:递归剪枝后仍有子节点才保留,否则返回 null(整枝剪掉)。
+ */ + private SysGroupVo buildSchoolNode(SysGroup node, Map> childrenMap) { + if (node.getType() == GroupTypeEnum.SCHOOL_GROUP) { + return convertToVo(node); + } + List children = childrenMap.get(node.getId()); + if (children == null) { + return null; + } + SysGroupVo vo = convertToVo(node); + for (SysGroup child : children) { + SysGroupVo childVo = buildSchoolNode(child, childrenMap); + if (childVo != null) { + vo.getChildren().add(childVo); + } + } + return vo.getChildren().isEmpty() ? null : vo; + } + + /** + * 构建 parentId -> 直接子节点列表 的映射(null 视为 0),同层级按 sort 升序、再按 id 升序。
+ */ + private Map> buildSortedChildrenMap(List all) { + Map> childrenMap = new HashMap<>(); + for (SysGroup g : all) { + Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId(); + childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g); + } + Comparator siblingOrder = (a, b) -> { + int sa = a.getSort() == null ? 999 : a.getSort(); + int sb = b.getSort() == null ? 999 : b.getSort(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }; + for (List siblings : childrenMap.values()) { + siblings.sort(siblingOrder); + } + return childrenMap; + } + + private SysGroupVo buildNode(SysGroup node, Map> childrenMap) { + SysGroupVo vo = convertToVo(node); + List children = childrenMap.get(node.getId()); + if (children != null) { + for (SysGroup child : children) { + vo.getChildren().add(buildNode(child, childrenMap)); + } + } + return vo; + } + + // ===================== 批量新增 ===================== + + @Override + @Transactional + public void batchAdd(SysGroupBatchRequest request) { + Integer pid = request.getPid(); + if (pid == null || !sysGroupRepository.existsById(pid)) { + throw new DException("父组不存在"); + } + GroupTypeEnum typeEnum = resolveType(request.getType()); + List items = request.getItems(); + if (items == null || items.isEmpty()) { + throw new DException("批量新增的子组列表不能为空"); + } + // 追加到当前父组子节点之后 + int base = nextSort(pid); + + List batch = new ArrayList<>(); + for (int i = 0; i < items.size(); i++) { + String name = items.get(i).getName(); + if (name == null || name.trim().isEmpty()) { + throw new DException("第 " + (i + 1) + " 个子组名称为空"); + } + SysGroup g = new SysGroup(); + g.setName(name.trim()); + g.setParentId(pid); + g.setType(typeEnum); + g.setSort(base + i); + batch.add(g); + } + sysGroupRepository.saveAll(batch); + } + + // ===================== 子组排序 ===================== + + @Override + @Transactional + public void sortChildren(SysGroupSortRequest request) { + Integer pid = request.getPid(); + List ids = request.getIds(); + if (pid == null || !sysGroupRepository.existsById(pid)) { + throw new DException("父组不存在"); + } + if (ids == null || ids.isEmpty()) { + throw new DException("子节点ID列表不能为空"); + } + if (new HashSet<>(ids).size() != ids.size()) { + throw new DException("子节点ID列表存在重复"); + } + // 取出 pid 的直接子节点,校验 ids 全是其直接子节点 + List children = sysGroupRepository.findByParentIdOrderBySortAsc(pid); + Set childIdSet = children.stream().map(SysGroup::getId).collect(Collectors.toSet()); + for (Integer id : ids) { + if (!childIdSet.contains(id)) { + throw new DException("ids 中存在不属于该父组的直接子节点"); + } + } + // 按传入顺序落库排序(1 起) + Map childMap = children.stream() + .collect(Collectors.toMap(SysGroup::getId, c -> c, (a, b) -> a)); + for (int i = 0; i < ids.size(); i++) { + childMap.get(ids.get(i)).setSort(i + 1); + } + sysGroupRepository.saveAll(children); + } + + // ===================== 移动组 ===================== + + @Override + @Transactional + public void move(SysGroupMoveRequest request) { + Integer moveId = request.getMoveId(); + Integer pid = request.getPid(); + if (moveId == null || !sysGroupRepository.existsById(moveId)) { + throw new DException("待移动节点不存在"); + } + if (pid == null || pid == ROOT_PARENT_ID || !sysGroupRepository.existsById(pid)) { + throw new DException("目标父节点不存在"); + } + SysGroup moveEntity = sysGroupRepository.findById(moveId).get(); + // 根节点不可移动 + if (isRoot(moveEntity)) { + throw new DException("根节点不可移动"); + } + if (Objects.equals(pid, moveId)) { + throw new DException("不能移动到自身下"); + } + // 环检测:目标父节点不能是待移动节点的后代(含自身) + Map allMap = sysGroupRepository.findAll().stream() + .collect(Collectors.toMap(SysGroup::getId, g -> g, (a, b) -> a)); + if (isDescendantOf(pid, moveId, allMap)) { + throw new DException("不能移动到自身子节点下"); + } + // 移动后追加到目标父节点的子节点末尾 + moveEntity.setParentId(pid); + moveEntity.setSort(nextSort(pid)); + sysGroupRepository.save(moveEntity); + } + + /** + * 判断 ancestorCandidate 是否为 nodeId 的后代(沿 parentId 向上回溯)。
+ */ + private boolean isDescendantOf(Integer ancestorCandidate, Integer nodeId, Map allMap) { + Integer cursor = ancestorCandidate; + while (cursor != null && cursor != ROOT_PARENT_ID) { + if (Objects.equals(cursor, nodeId)) { + return true; + } + SysGroup g = allMap.get(cursor); + if (g == null) { + return false; + } + cursor = g.getParentId(); + } + return false; + } + + // ===================== 子树聚合 ===================== + + @Override + public List listSelfAndDescendantIds(Integer groupId) { + List result = new ArrayList<>(); + if (groupId == null) { + return result; + } + result.add(groupId); + // parentId -> 直接子节点ID列表 + Map> childrenMap = new HashMap<>(); + for (SysGroup g : sysGroupRepository.findAll()) { + Integer parent = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId(); + childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(g.getId()); + } + // 广度优先向下收集所有子孙 + List frontier = new ArrayList<>(); + frontier.add(groupId); + while (!frontier.isEmpty()) { + List next = new ArrayList<>(); + for (Integer id : frontier) { + List children = childrenMap.get(id); + if (children != null) { + result.addAll(children); + next.addAll(children); + } + } + frontier = next; + } + return result; + } + + // ===================== 私有工具 ===================== + + /** pid 下已有子节点的最大 sort +1;无子节点返回 1。 */ + private int nextSort(Integer pid) { + Integer max = sysGroupRepository.findMaxSortByParentId(pid); + return (max == null ? 0 : max) + 1; + } + + /** 校验并返回组类型枚举;非法类型抛异常。 */ + private GroupTypeEnum resolveType(Integer type) { + GroupTypeEnum typeEnum = IEnum.getByKey(GroupTypeEnum.class, type); + if (typeEnum == null) { + throw new DException("组类型不存在"); + } + return typeEnum; + } + + /** 是否为系统根节点(唯一的 pid=0 节点)。 */ + private boolean isRoot(SysGroup entity) { + return entity.getParentId() != null && entity.getParentId() == ROOT_PARENT_ID; + } + + private SysGroupVo convertToVo(SysGroup entity) { + SysGroupVo vo = new SysGroupVo(); + BeanUtils.copyProperties(entity, vo); + vo.setType(entity.getType() == null ? null : entity.getType().getKey()); + return vo; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java b/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java new file mode 100644 index 0000000..4e458a4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java @@ -0,0 +1,181 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.SysOperationLog; +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import com.yunhai.userbase.model.oplog.SysOperationLogListVo; +import com.yunhai.userbase.model.oplog.SysOperationLogQuery; +import com.yunhai.userbase.repository.SysOperationLogRepository; +import com.yunhai.userbase.service.ISysOperationLogService; +import com.yunhai.userbase.utils.GzipUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.TypedQuery; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统操作日志业务实现。
+ * + * @author chenx + * @date 2026/7/28 + */ +@Slf4j +@Service +public class SysOperationLogServiceImpl implements ISysOperationLogService { + + /** 一般日志(level=3)保留时长:一年(12 个月)。 */ + private static final int GENERAL_RETENTION_MONTHS = 12; + /** 重要(level=1)/次要(level=2)日志保留时长:三年(36 个月)。 */ + private static final int IMPORTANT_RETENTION_MONTHS = 36; + + @Autowired + private SysOperationLogRepository sysOperationLogRepository; + + @PersistenceContext + private EntityManager em; + + @Override + @Async + public void saveAsync(SysOperationLog entity) { + try { + sysOperationLogRepository.save(entity); + } catch (Exception e) { + // 日志落库失败绝不抛回主流程,仅记录 + log.error("操作日志异步落库失败, uri={}, method={}", + entity.getRequestUri(), entity.getRequestMethod(), e); + } + } + + @Override + public BasePageResult pageQuery(SysOperationLogQuery query, + BasePageQuery pageQuery) { + int pageIndex = Math.max(pageQuery.getPageIndex(), 1); + int pageSize = Math.max(pageQuery.getPageSize(), 1); + + CriteriaBuilder cb = em.getCriteriaBuilder(); + // 列表投影:显式选择字段,不包含 response_data,避免加载 MEDIUMBLOB + CriteriaQuery cq = cb.createQuery(SysOperationLogListVo.class); + Root root = cq.from(SysOperationLog.class); + cq.select(cb.construct(SysOperationLogListVo.class, + root.get("id"), root.get("logLevel"), root.get("userId"), root.get("username"), + root.get("requestIp"), root.get("requestUri"), root.get("requestMethod"), + root.get("operationType"), root.get("classMethod"), root.get("requestParams"), + root.get("statusCode"), root.get("costTime"), root.get("userAgent"), + root.get("errorMsg"), root.get("createTime"))); + List predicates = buildPredicates(cb, root, query); + cq.where(predicates.toArray(new Predicate[0])); + cq.orderBy(cb.desc(root.get("createTime"))); + + // 总数查询 + CriteriaQuery countCq = cb.createQuery(Long.class); + Root countRoot = countCq.from(SysOperationLog.class); + countCq.select(cb.count(countRoot)); + countCq.where(buildPredicates(cb, countRoot, query).toArray(new Predicate[0])); + long total = em.createQuery(countCq).getSingleResult(); + + TypedQuery typed = em.createQuery(cq); + typed.setFirstResult((pageIndex - 1) * pageSize); + typed.setMaxResults(pageSize); + List content = typed.getResultList(); + + int totalPages = (int) Math.ceil((double) total / pageSize); + boolean hasNext = pageIndex < totalPages; + + return new BasePageResult<>(content, (int) total, pageIndex, pageSize, totalPages, hasNext); + } + + @Override + public String getResponseData(Long id) { + SysOperationLog entity = sysOperationLogRepository.findById(id).orElse(null); + if (entity == null || entity.getResponseData() == null) { + return null; + } + return GzipUtil.decompress(entity.getResponseData()); + } + + @Override + @Transactional + public long cleanupExpired() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime generalDeadline = now.minusMonths(GENERAL_RETENTION_MONTHS); + LocalDateTime importantDeadline = now.minusMonths(IMPORTANT_RETENTION_MONTHS); + // 一般日志(level=3)保留一年(12个月);重要(level=1)/次要(level=2)保留三年(36个月) + long deleted = sysOperationLogRepository.deleteByLogLevelAndCreateTimeBefore(3, generalDeadline) + + sysOperationLogRepository.deleteByLogLevelAndCreateTimeBefore(2, importantDeadline) + + sysOperationLogRepository.deleteByLogLevelAndCreateTimeBefore(1, importantDeadline); + log.info("操作日志清理完成, 删除{}条(一般截止={}, 重要/次要截止={})", + deleted, generalDeadline, importantDeadline); + return deleted; + } + + /** + * 构建动态查询条件。
+ * 模糊字段使用 LIKE,时间使用范围,其余等值匹配。
+ * + * @param cb 条件构建器 + * @param root 根路径 + * @param query 查询条件 + * @return 谓词列表 + */ + private List buildPredicates(CriteriaBuilder cb, Root root, + SysOperationLogQuery query) { + List predicates = new ArrayList(); + if (query == null) { + return predicates; + } + if (query.getLogLevel() != null) { + predicates.add(cb.equal(root.get("logLevel"), query.getLogLevel())); + } + if (query.getUserId() != null) { + predicates.add(cb.equal(root.get("userId"), query.getUserId())); + } + if (StringUtils.isNotBlank(query.getUsername())) { + predicates.add(cb.like(root.get("username"), "%" + query.getUsername() + "%")); + } + if (StringUtils.isNotBlank(query.getRequestUri())) { + predicates.add(cb.like(root.get("requestUri"), "%" + query.getRequestUri() + "%")); + } + if (StringUtils.isNotBlank(query.getRequestIp())) { + predicates.add(cb.like(root.get("requestIp"), "%" + query.getRequestIp() + "%")); + } + if (StringUtils.isNotBlank(query.getOperationType())) { + predicates.add(cb.equal(root.get("operationType"), query.getOperationType())); + } + if (query.getStatusCode() != null) { + predicates.add(cb.equal(root.get("statusCode"), query.getStatusCode())); + } + if (query.getSuccess() != null) { + if (Boolean.TRUE.equals(query.getSuccess())) { + predicates.add(cb.equal(root.get("statusCode"), 200)); + } else { + predicates.add(cb.notEqual(root.get("statusCode"), 200)); + } + } + if (StringUtils.isNotBlank(query.getKeyword())) { + String kw = "%" + query.getKeyword().trim() + "%"; + predicates.add(cb.or( + cb.like(root.get("requestUri"), kw), + cb.like(root.get("username"), kw))); + } + if (query.getStartTime() != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime())); + } + if (query.getEndTime() != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime())); + } + return predicates; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java new file mode 100644 index 0000000..e53838e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -0,0 +1,279 @@ +package com.yunhai.userbase.service.impl; + +import com.bestsch.utils.DException; +import com.yunhai.userbase.enums.IEnum; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.enums.SysRoleEnum; +import com.yunhai.userbase.enums.UserTypeEnum; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.model.sysuser.SysUserQuery; +import com.yunhai.userbase.model.sysuser.SysUserRequest; +import com.yunhai.userbase.model.sysuser.SysUserVo; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.service.ISysGroupService; +import com.yunhai.userbase.service.ISysUserService; +import com.yunhai.userbase.utils.ValidatorUtil; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Subquery; +import javax.transaction.Transactional; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * @author chenx + * @date 2026/7/20 16:14 + * @description 系统用户服务实现 + */ +@Service +public class SysUserService implements ISysUserService { + + /** 主归属标识:1=本职主学校/部门 */ + private static final int IS_MAIN = 1; + + /** 系统内置超级管理员账号(与 sys_user_init.sql 种子账号保持一致) */ + private static final Set SUPER_ADMIN_ACCOUNTS = + new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3")); + + @Autowired + private SysUserRepository sysUserRepository; + + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + + @Autowired + private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + + @Autowired + private SysGroupRepository sysGroupRepository; + + @Autowired + private ISysGroupService sysGroupService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Override + @Transactional + public void add(SysUserRequest request) { + if (sysUserRepository.countByAccount(request.getAccount()) > 0) { + throw new DException("账号已存在"); + } + if (!sysGroupRepository.existsById(request.getGroupId())) { + throw new DException("所属分组不存在"); + } + validateProfile(request); + String encodedPassword = validateAndEncodePassword(request.getPassword(), request.getConfirmPassword()); + + SysUser entity = new SysUser(); + entity.setAccount(request.getAccount()); + entity.setPassword(encodedPassword); + entity.setRealName(request.getRealName()); + entity.setPhone(request.getPhone()); + entity.setBirthday(request.getBirthday()); + entity.setIdcard(request.getIdcard()); + entity.setProfilePicture(request.getProfilePicture()); + entity.setRoles(request.getRoles()); + // 用户类型推导:超级管理员账号固定为 1;其余含 ADMIN(7) 角色 -> 2 学校管理员;默认 0 普通用户 + entity.setType(resolveUserType(entity.getAccount(), entity.getRoles())); + // disabled 用枚举存储:不传时默认 NORMAL(正常/未禁用) + entity.setDisabled(request.getDisabled() != null ? resolveStatus(request.getDisabled()) : StatusEnum.NORMAL); + entity.setExpired(request.getExpired() != null ? request.getExpired() : false); + sysUserRepository.save(entity); + + // 写入组用户关联 + SysUserGroupRel rel = new SysUserGroupRel(); + rel.setUserId(entity.getId()); + rel.setGroupId(request.getGroupId()); + rel.setIsMain(IS_MAIN); + rel.setCreateTime(LocalDateTime.now()); + sysUserGroupRelRepository.save(rel); + } + + @Override + @Transactional + public void edit(Integer id, SysUserRequest request) { + if (sysUserRepository.countByAccountAndIdNot(request.getAccount(), id) > 0) { + throw new DException("账号已被其他用户使用"); + } + SysUser entity = sysUserRepository.findById(id) + .orElseThrow(() -> new DException("系统用户不存在")); + validateProfile(request); + + entity.setAccount(request.getAccount()); + // 密码非空才修改:需两次一致 + 弱密码校验 + BCrypt 加密 + if (StringUtils.isNotBlank(request.getPassword())) { + entity.setPassword(validateAndEncodePassword(request.getPassword(), request.getConfirmPassword())); + } + entity.setRealName(request.getRealName()); + entity.setPhone(request.getPhone()); + entity.setBirthday(request.getBirthday()); + entity.setIdcard(request.getIdcard()); + entity.setProfilePicture(request.getProfilePicture()); + entity.setRoles(request.getRoles()); + // 用户类型推导:仅当本次传入了角色时才重算,避免编辑时未传角色把 type 误重置为 0 + if (request.getRoles() != null) { + entity.setType(resolveUserType(entity.getAccount(), entity.getRoles())); + } + if (request.getDisabled() != null) { + entity.setDisabled(resolveStatus(request.getDisabled())); + } + if (request.getExpired() != null) { + entity.setExpired(request.getExpired()); + } + sysUserRepository.save(entity); + } + + @Override + @Transactional + public void delete(Integer id) { + if (!sysUserRepository.existsById(id)) { + throw new DException("系统用户不存在"); + } + // 先清理组用户关联、用户-角色绑定,再删除用户 + sysUserGroupRelRepository.deleteByUserId(id); + // AI-FIX(角色授权): 删除用户时级联清理用户-业务角色绑定(逻辑外键无物理 FK,避免残留孤儿数据) + sysUserAppRoleRelRepository.deleteByUserIdBulk(id); + sysUserRepository.deleteById(id); + } + + @Override + @Transactional + public void changeStatus(Integer id, boolean disabled) { + SysUser entity = sysUserRepository.findById(id) + .orElseThrow(() -> new DException("系统用户不存在")); + // 实体 disabled 字段为 StatusEnum(0=正常/未禁用,1=禁用):boolean 直接映射枚举 + entity.setDisabled(disabled ? StatusEnum.DISABLED : StatusEnum.NORMAL); + sysUserRepository.save(entity); + } + + @Override + public Page list(SysUserQuery query, Pageable pageable) { + Specification spec = buildSpecification(query); + return sysUserRepository.findAll(spec, pageable).map(this::convertToVo); + } + + // ===================== 私有工具 ===================== + + /** + * 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。
+ */ + private Specification buildSpecification(SysUserQuery query) { + return (root, cbQuery, cb) -> { + List predicates = new ArrayList<>(); + + // 组范围过滤:用户ID 属于目标组集合下的关联记录 + if (query.getGroupId() != null) { + List groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub()); + Subquery sub = cbQuery.subquery(Integer.class); + Root relRoot = sub.from(SysUserGroupRel.class); + sub.select(relRoot.get("userId")); + sub.where(relRoot.get("groupId").in(groupIds)); + predicates.add(root.get("id").in(sub)); + } + + // 关键字:账号/姓名/身份证模糊,手机号精确 + if (StringUtils.isNotBlank(query.getName())) { + String keyword = query.getName().trim(); + String like = "%" + keyword + "%"; + Predicate byAccount = cb.like(root.get("account"), like); + Predicate byRealName = cb.like(root.get("realName"), like); + Predicate byIdcard = cb.like(root.get("idcard"), like); + Predicate byPhone = cb.equal(root.get("phone"), keyword); + predicates.add(cb.or(byAccount, byRealName, byIdcard, byPhone)); + } + + if (query.getDisabled() != null) { + // 实体 disabled 为 StatusEnum,查询条件需用枚举值比较(按 key 解析) + predicates.add(cb.equal(root.get("disabled"), IEnum.getByKey(StatusEnum.class, query.getDisabled()))); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + + /** 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */ + private List resolveGroupIds(Integer groupId, Integer withSub) { + if (withSub != null && withSub == 1) { + return sysGroupService.listSelfAndDescendantIds(groupId); + } + List single = new ArrayList<>(); + single.add(groupId); + return single; + } + + /** 校验手机号与身份证(有值才校验)。 */ + private void validateProfile(SysUserRequest request) { + if (StringUtils.isNotBlank(request.getPhone()) && !ValidatorUtil.isPhone(request.getPhone())) { + throw new DException("手机号格式不正确"); + } + if (StringUtils.isNotBlank(request.getIdcard()) && !ValidatorUtil.isIdCard(request.getIdcard())) { + throw new DException("身份证号不合法"); + } + } + + /** + * 校验两次密码一致性与弱密码规则,通过后返回 BCrypt 密文。
+ */ + private String validateAndEncodePassword(String password, String confirmPassword) { + if (!StringUtils.equals(password, confirmPassword)) { + throw new DException("两次输入的密码不一致"); + } + String weakReason = ValidatorUtil.checkPasswordStrength(password); + if (weakReason != null) { + throw new DException(weakReason); + } + return passwordEncoder.encode(password); + } + + private SysUserVo convertToVo(SysUser entity) { + SysUserVo vo = new SysUserVo(); + BeanUtils.copyProperties(entity, vo); + // disabled 为枚举,需要单独映射为 key(与 GroupTypeEnum 的 type 处理一致) + vo.setDisabled(entity.getDisabled() == null ? null : entity.getDisabled().getKey()); + // type 为枚举,同样映射为 key + vo.setType(entity.getType() == null ? null : entity.getType().getKey()); + return vo; + } + + /** + * 推导用户类型(见 {@link UserTypeEnum}): + * - 内置超级管理员账号 -> 1 超级管理员; + * - 其余账号若拥有 ADMIN(7) 角色 -> 2 学校管理员; + * - 其他 -> 0 普通用户。 + */ + private UserTypeEnum resolveUserType(String account, List roles) { + if (account != null && SUPER_ADMIN_ACCOUNTS.contains(account)) { + return UserTypeEnum.SUPER_ADMIN; + } + if (roles != null && roles.contains(SysRoleEnum.ADMIN.getKey())) { + return UserTypeEnum.SCHOOL_ADMIN; + } + return UserTypeEnum.NORMAL; + } + + /** 校验并返回用户状态枚举;非法状态抛异常。 */ + private StatusEnum resolveStatus(Integer status) { + StatusEnum statusEnum = IEnum.getByKey(StatusEnum.class, status); + if (statusEnum == null) { + throw new DException("用户状态不存在"); + } + return statusEnum; + } +} diff --git a/src/main/java/com/yunhai/userbase/task/OperationLogCleanupTask.java b/src/main/java/com/yunhai/userbase/task/OperationLogCleanupTask.java new file mode 100644 index 0000000..311822a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/task/OperationLogCleanupTask.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.task; + +import com.yunhai.userbase.service.ISysOperationLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 操作日志定时清理任务。
+ * 每日凌晨 3 点执行:一般日志(level=3)保留一年、重要(level=1)/次要(level=2)日志保留三年。
+ * 如需「删除前归档」,可在 {@code ISysOperationLogService.cleanupExpired} 中扩展归档逻辑。
+ * + * @author chenx + * @date 2026/7/28 + */ +@Slf4j +@Component +public class OperationLogCleanupTask { + + @Autowired + private ISysOperationLogService sysOperationLogService; + + @Scheduled(cron = "0 0 3 * * ?") + public void run() { + try { + long deleted = sysOperationLogService.cleanupExpired(); + log.info("操作日志定时清理完成, 删除{}条", deleted); + } catch (Exception e) { + log.error("操作日志定时清理失败", e); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/BaseUtil.java b/src/main/java/com/yunhai/userbase/utils/BaseUtil.java new file mode 100644 index 0000000..559cea0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/BaseUtil.java @@ -0,0 +1,155 @@ +package com.yunhai.userbase.utils; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @ Description : 基础工具类,不知道怎么分类放这里就对了 + * @ Author : cls + * @ CreateDate : 2020/2/8 16:38 + * @ Company : bestsch + */ +@Component +public class BaseUtil { + + /** + * 为数字前面自动补充0 + * @param number 待补充的数字 + * @param length 结果总长度 + * 若number为2,length为3,则返回的结果为002 + * @return + */ + public static String frontCompWithZore(int number,int length){ + String newString = String.format("%0"+length+"d", number); + return newString; + } + + /** + * 年级名称转换为年级编号 + * @param grade + * @return + */ + public static int gradeConverter(String grade){ + if(StringUtils.isNotBlank(grade)){ + if(grade.indexOf("一") != -1 + && grade.indexOf("初一") == -1 + && grade.indexOf("高一") == -1){ + return 1; + } + if(grade.indexOf("二") != -1 + && grade.indexOf("初二") == -1 + && grade.indexOf("高二") == -1){ + return 2; + } + if(grade.indexOf("三") != -1 + && grade.indexOf("初三") == -1 + && grade.indexOf("高三") == -1){ + return 3; + } + if(grade.indexOf("四") != -1){ + return 4; + } + if(grade.indexOf("五") != -1){ + return 5; + } + if(grade.indexOf("六") != -1){ + return 6; + } + if(grade.indexOf("七") != -1 + || grade.indexOf("初一") != -1){ + return 7; + } + if(grade.indexOf("八") != -1 + || grade.indexOf("初二") != -1){ + return 8; + } + if(grade.indexOf("九") != -1 + || grade.indexOf("初三") != -1){ + return 9; + } + if(grade.indexOf("十") != -1 + || grade.indexOf("高一") != -1){ + return 10; + } + if(grade.indexOf("十一") != -1 + || grade.indexOf("高二") != -1){ + return 11; + } + if(grade.indexOf("十二") != -1 + || grade.indexOf("高三") != -1){ + return 12; + } + } + return -1; + } + + /** + * 学期名称转学期编号 + * @param semester + * @return + */ + public static int semesterConverter(String semester){ + if(StringUtils.isNotBlank(semester)){ + if(semester.indexOf("上") != -1 + || semester.indexOf("一") != -1 + || semester.indexOf("1") != -1){ + return 0; + } + if(semester.indexOf("下") != -1 + || semester.indexOf("二") != -1 + || semester.indexOf("2") != -1){ + return 1; + } + } + return -1; + } + + public static String collectionToString(Collection collection){ + String string = ""; + if(collection != null && !collection.isEmpty()){ + Iterator it = collection.iterator(); + while (it.hasNext()){ + Object obj = it.next(); + if(obj != null && StringUtils.isNotBlank(obj.toString())){ + string += obj.toString() + ","; + } + } + if(string.lastIndexOf(",") == string.length()-1){ + string = string.substring(0,string.length()-1); + } + } + return string; + } + + /** 功能描述: List 转 List + * @param list + * @return: + * @Author: cls + * @Date: 2021/10/12 10:06 + */ + public static List stringListToIntegerList(List list){ + return list.stream().map(Integer :: valueOf).collect(Collectors.toList()); + } + + + /** + * 十六进制转字符串 + * @param hex + * @return + */ + public final static String hexToString(String hex) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < hex.length(); i += 2) { + String hexSubstring = hex.substring(i, i + 2); + int decimalValue = Integer.parseInt(hexSubstring, 16); + sb.append((char) decimalValue); + } + return sb.toString(); + } + +} diff --git a/src/main/java/com/yunhai/userbase/utils/CacheKitUtils.java b/src/main/java/com/yunhai/userbase/utils/CacheKitUtils.java new file mode 100644 index 0000000..e357906 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/CacheKitUtils.java @@ -0,0 +1,57 @@ +package com.yunhai.userbase.utils; + +import java.util.function.Supplier; + +/** + * @author chenx + * @date 2026/1/6 20:54 + * @description 通用缓存封装 + */ + +public final class CacheKitUtils { + private CacheKitUtils() {} + + /** + * 泛型模板:先读缓存 → 没有则调用 loader → 回写缓存 → 返回结果 + * + * @param redisUtil 你的 RedisUtil + * @param key 缓存键 + * @param expireSec 过期秒数 + * @param loader 实际业务加载器 + * @param 返回值类型 + * @return 缓存值或加载值 + */ + public static T getOrLoad(RedisUtil redisUtil, + String key, + int expireSec, + Supplier loader) { + // 1. 读缓存 + if (redisUtil.hasKey(key)) { + try { + @SuppressWarnings("unchecked") + T v = (T) redisUtil.get(key); + if (v != null) { + return v; + } + } catch (Exception e) { + // 仅打印,继续走 loader + e.printStackTrace(); + } + } + + // 2. 加载 + T value = loader.get(); + if (value == null) { + return null; + } + + // 3. 回写缓存 + try { + redisUtil.set(key, value, expireSec); + } catch (Exception e) { + // 缓存失败不影响主流程 + e.printStackTrace(); + } + return value; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/utils/ConstantUtil.java b/src/main/java/com/yunhai/userbase/utils/ConstantUtil.java new file mode 100644 index 0000000..b48c78a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/ConstantUtil.java @@ -0,0 +1,44 @@ +package com.yunhai.userbase.utils; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +public class ConstantUtil { + + /** + * redis过期时间 + */ + public static final long EXPIRE_TIME=60*60*8; + + /** + * 全局缓存时间:5分钟 + */ + public static final Duration CACHE_TTL = Duration.ofMinutes(5); + + /** + * redis前缀 + */ + public static final String PREFIX_KEY="bsch.bschlearn.";//redis前缀 + + /** + * @Description: 提供AI参数常量 + * @Author: chenx + * @Date: 2025/4/28 + */ + public static final Map PARAMS_CONSTANT; + + static { + PARAMS_CONSTANT = new HashMap<>(); + /** 默认前两个数据为系统常量 */ + PARAMS_CONSTANT.put("score", "分数"); + PARAMS_CONSTANT.put("rating", "评级"); + + PARAMS_CONSTANT.put("polishLevel", "润色幅度"); + PARAMS_CONSTANT.put("commentFont", "旁批字体"); + PARAMS_CONSTANT.put("correctionStrictness", "批改严格程度"); + PARAMS_CONSTANT.put("lineStyle", "句子划线样式"); + + } + +} diff --git a/src/main/java/com/yunhai/userbase/utils/DicUtil.java b/src/main/java/com/yunhai/userbase/utils/DicUtil.java new file mode 100644 index 0000000..6db0f23 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/DicUtil.java @@ -0,0 +1,179 @@ +package com.yunhai.userbase.utils; + + +/** + * @ Description : 存储魔法值 + * @ Author : cls + * @ CreateDate : 2020/2/1 9:24 + * @ Company : bestsch + */ +public class DicUtil { + + /** + * 超管id + */ + public static final Integer SUPER_ADMIN_ID = 100000; + + public static final String UNKNOWN = "未知"; + + /** + * 一般hashMap初始化大小 + */ + public static final int initialCapacity = 6; + + /** + * 缓存时间,单位毫秒 + */ + public static final long ONE_DAY = 1000 * 60 * 60 * 24; + public static final long TEN_MINUTES = 1000 * 60 * 10; + public static final long FIVE_MINUTES = 1000 * 60 * 5; + public static final long ONE_MINUTE = 1000 * 60; + public static final long HALF_MINUTE = 1000 * 30; + public static final long THREE_SECONDS = 1000 * 3; + + + public static final int SORT_STEP = 10; + /** + * 节点最大层级,索引是9,代表是第10层 + */ + public static final int MAX_LEVEL = 9; + + /** + * 默认的学科类型 6 代表其他 + */ + public static final String DEFAULT_RESOURCE_TYPE = "6"; + + /** + * 数据状态 1 有效 + */ + public static final Integer STATUS_VALID = 1; + + /** + * 数据状态 0 无效 + */ + public static final Integer STATUS_INVALID = 0; + + /** + * 审核状态 0 待审 + */ + public static final Integer REVIEW_STATUS_TODO = 0; + + /** + * 审核状态 1 通过 + */ + public static final Integer REVIEW_STATUS_PASS = 1; + + /** + * 审核状态 2 未通过 + */ + public static final Integer REVIEW_STATUS_NOPASS = 2; + /** + * 区间参数 不包括 + */ + public static final int SECTION_NO_INCLUDE = 0; + + /** + * 区间参数 包括 + */ + public static final int SECTION_INCLUDE = 1; + + + /** + * 用户类别 用户中心数据,1 超级管理员 + */ + public static final Integer USER_TYPE_SUPER_MANAGER = 1; + + /** + * 默认头像 + */ + public static final String DEFAULT_PIC = "http://base.bestsch.com/img/defprofilepic.png"; + + /** + * 排序 降序 0 + */ + public static final Integer ORDER_DESC = 0; + + /** + * 排序 升序 1 + */ + public static final Integer ORDER_ASC = 1; + + /** + * 错误信息,上传文件失败 + */ + public static final String ERROR_MSG_IMPORT_WRONG = "上传失败,请稍后再试"; + + /** + * 错误消息,数据不存在 + */ + public static final String ERROR_MSG_NOT_FOUND = "数据不存在,可能已被删除"; + + /** + * 错误消息,无操作权限 + */ + public static final String ERROR_MSG_NO_POWER = "您无操作权限"; + + /** + * 错误消息,无权删除资源 + */ + public static final String NO_POWER_TO_DEL_RESOURCE = "您无法删除此文件"; + + /** + * 错误消息,无权编辑资源 + */ + public static final String NO_POWER_TO_MODIFY_RESOURCE = "您无法编辑/移动此文件"; + + /** + * 错误消息,无权移动到根节点 + */ + public static final String NO_MOVE_TO_ROOT = "您无法把文件移至根节点"; + + /** + * 错误消息,无法删除此节点 + */ + public static final String NOT_DEL_NODE = "无法删除,请先移动或删除此节点下的文件"; + + /** + * 错误消息,没有可查看的节点 + */ + public static final String NO_NODE_BY_WATCH = "您暂无可查看的节点"; + + /** + * 错误消息,未知的教材来源 + */ + public static final String UNKNOWN_TEXTBOOK_SOURCE = "未知的教材来源"; + + /** + * 错误消息,无法对根节点进行操作 + */ + public static final String CANT_OPERATE_FOR_ROOT = "无法对根节点进行操作"; + + + public static final String OVER_MAX_LEVEL = "层级超过上限,最多支持" + (MAX_LEVEL + 1) + "层"; + + /** + * 错误消息,未知的权限类型 + */ + public static final String UNKNOWN_AUTHORITY_TYPE = "未知的权限类型"; + + /** + * 错误消息,未知的根节点类型类型 + */ + public static final String UNKNOWN_ROOT_TYPE_TYPE = "未知的根节点类型"; + + /** + * 错误消息,节点不存在 + */ + public static final String NO_NODE = "节点不存在"; + + /** + * 错误消息,节点不存在 + */ + public static final String NO_TEXTBOOK = "教材不存在"; + + /** + * 错误消息,节点不存在 + */ + public static final String NO_AUTHORITY_NODE = "无权限操作该节点"; + +} diff --git a/src/main/java/com/yunhai/userbase/utils/DownloadFileUtil.java b/src/main/java/com/yunhai/userbase/utils/DownloadFileUtil.java new file mode 100644 index 0000000..e0d3aa6 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/DownloadFileUtil.java @@ -0,0 +1,56 @@ +package com.yunhai.userbase.utils; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.io.*; + +@Slf4j +public class DownloadFileUtil { + + public static final String separator = File.separator; + + /** + * 下载样表 + * @param filePath 文件上级目录 + * @param fileName 文件名 + * @param newName 下载的展示文件名 + * @return 响应 + */ + public static ResponseEntity download(String filePath, String fileName, String newName) { +// String route = "static" + separator + "templates" + separator; +// String path = null; + ResponseEntity response = null; + try { +// path = route + filePath + separator + fileName; + + File file = new File(filePath); + + System.out.println(file.exists()); + + +// ClassPathResource classPathResource = new ClassPathResource(filePath); + InputStream inputStream = new FileInputStream(file); + + HttpHeaders headers = new HttpHeaders(); + headers.add("Cache-Control", "no-cache, no-store, must-revalidate"); + headers.add("Content-Disposition", + "attachment; filename=" + + new String(newName.getBytes("gbk"), "iso8859-1")); + headers.add("Pragma", "no-cache"); + headers.add("Expires", "0"); + response = ResponseEntity.ok().headers(headers) + .contentType(MediaType.parseMediaType("application/octet-stream")) + .body(new InputStreamResource(inputStream)); + } catch (FileNotFoundException e1) { + log.error("找不到指定的文件", e1); + } catch (IOException e) { + log.error("获取不到文件流", e); + } + return response; + } +} + diff --git a/src/main/java/com/yunhai/userbase/utils/EasyPoiUtils.java b/src/main/java/com/yunhai/userbase/utils/EasyPoiUtils.java new file mode 100644 index 0000000..12f57fb --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/EasyPoiUtils.java @@ -0,0 +1,105 @@ +package com.yunhai.userbase.utils; + +import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.word.WordExportUtil; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xwpf.usermodel.XWPFDocument; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.util.List; +import java.util.Map; + +public final class EasyPoiUtils { + + private EasyPoiUtils() {} + + /** + * 只能xls + * @param fileName + * @param response + * @param workbook + */ + public static void downLoadExcel(String fileName, HttpServletResponse response, Workbook workbook) { + try { + response.setCharacterEncoding("UTF-8"); + response.setHeader("content-Type", "application/vnd.ms-excel"); + fileName = new String(fileName.getBytes("utf-8"), "iso-8859-1") + ".xls"; + response.setHeader("Content-Disposition", "attachment;filename=" + fileName); + workbook.write(response.getOutputStream()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static void defaultExport(List dataList, Class clz, String fileName, HttpServletResponse response, ExportParams exportParams) { + Workbook workbook = ExcelExportUtil.exportExcel(exportParams, clz, dataList); +// workbook.getSheetAt(0).getRow(0).setHeight((short) 0x25622); // 设置行高 + if (workbook != null) { + downLoadExcel(fileName, response, workbook); + } + } + + public static void exportExcel(List dataList, String title, String sheetName, Class clz, String fileName, boolean isCreateHeader, HttpServletResponse response) { + ExportParams exportParams = new ExportParams(title, sheetName); + exportParams.setCreateHeadRows(isCreateHeader); + defaultExport(dataList, clz, fileName, response, exportParams); + } + + public static void exportExcel(List dataList, String title, String sheetName, Class clz, String fileName, HttpServletResponse response) { + defaultExport(dataList, clz, fileName, response, new ExportParams(title, sheetName)); + } + private static void changeHeightExport(List dataList, Class clz, String fileName, HttpServletResponse response, ExportParams exportParams, Short height) { + Workbook workbook = ExcelExportUtil.exportExcel(exportParams, clz, dataList); + if (height != null) { + workbook.getSheetAt(0).getRow(0).setHeight(height); // 设置行高 + } + if (workbook != null) { + downLoadExcel(fileName, response, workbook); + } + } + + public static StringBuffer changeStringTable(String spl,String string) { + StringBuffer buffer = new StringBuffer(); + String[] split = string.split(spl); + for (int i = 0; i < split.length; i++) { + if (i < split.length - 1) { + buffer.append(split[i]+(char)11); + } else { + buffer.append(split[i]); + } + } + return buffer; + } + + /** + * 导出word + */ + public static void exportWord(String fileName, Map map, HttpServletRequest request, HttpServletResponse response) { + + try { + String userAgent = request.getHeader("user-agent").toLowerCase(); + if (userAgent.contains("msie") || userAgent.contains("like gecko")) { + fileName = URLEncoder.encode(fileName, "UTF-8"); + } else { + fileName = new String(fileName.getBytes("utf-8"), "ISO-8859-1"); + } + XWPFDocument doc = WordExportUtil.exportWord07( + "/Users/luona/Downloads/模版.docx", map); + // 设置强制下载不打开 + response.setContentType("application/force-download"); + // 设置文件名 + response.addHeader("Content-Disposition", "attachment;fileName=" + fileName); + OutputStream out = response.getOutputStream(); + doc.write(out); + out.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + +} diff --git a/src/main/java/com/yunhai/userbase/utils/EnumUtils.java b/src/main/java/com/yunhai/userbase/utils/EnumUtils.java new file mode 100644 index 0000000..63fbf11 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/EnumUtils.java @@ -0,0 +1,134 @@ +package com.yunhai.userbase.utils; + +import cn.hutool.core.util.StrUtil; +import com.yunhai.userbase.enums.IEnum; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 枚举底层工具实现,适配 Hutool 5.8.36 + * 对外统一通过 IEnum.xxx() 静态调用,不直接使用本类 + */ +public final class EnumUtils { + + private EnumUtils() { + throw new AssertionError("工具类禁止实例化"); + } + + /** + * 获取全部枚举实例(依赖Hutool5.8.36支持的EnumUtil.toList) + */ + public static & IEnum> List toList(Class targetEnumClazz) { + return new ArrayList<>(Arrays.asList(targetEnumClazz.getEnumConstants())); + } + + /** + * 枚举转完整Map列表(包含enum、name、key、value) + */ + public static & IEnum> List> toMapList(Class targetEnumClazz) { + List enumList = toList(targetEnumClazz); + List> resultList = new ArrayList<>(enumList.size()); + + for (T enumItem : enumList) { + Map map = new HashMap<>(4); + map.put("enum", enumItem); + map.put("name", enumItem.name()); + map.put("key", enumItem.getKey()); + map.put("value", enumItem.getValue()); + resultList.add(map); + } + return resultList; + } + + /** + * 通过key获取对应value文本 + */ + public static & IEnum> String getValue(Class targetEnumClazz, Object key) { + if (key == null || StrUtil.isBlank(String.valueOf(key))) { + return StrUtil.EMPTY; + } + Optional matchEnum = findByKey(targetEnumClazz, key); + return matchEnum.map(IEnum::getValue).orElse(StrUtil.EMPTY); + } + + /** + * 通过value文本获取对应key字符串 + */ + public static & IEnum> String getKey(Class targetEnumClazz, String value) { + if (StrUtil.isBlank(value)) { + return StrUtil.EMPTY; + } + Optional matchEnum = findByValue(targetEnumClazz, value); + return matchEnum.map(item -> String.valueOf(item.getKey())).orElse(StrUtil.EMPTY); + } + + /** + * 判断key是否存在 + */ + public static & IEnum> boolean isExistKey(Class targetEnumClazz, Object key) { + return findByKey(targetEnumClazz, key).isPresent(); + } + + /** + * 判断value是否存在 + */ + public static & IEnum> boolean isExistValue(Class targetEnumClazz, String value) { + return findByValue(targetEnumClazz, value).isPresent(); + } + + /** + * 根据key获取枚举实例,无匹配返回null + */ + public static & IEnum> T getEnumByKey(Class targetEnumClazz, Object key) { + return findByKey(targetEnumClazz, key).orElse(null); + } + + /** + * 根据key严格获取枚举,不存在抛出异常(fromKey) + */ + public static & IEnum> T fromKey(Class targetEnumClazz, Object key) { + T enumItem = getEnumByKey(targetEnumClazz, key); + if (enumItem == null) { + throw new IllegalArgumentException( + String.format("枚举不存在,key=%s,枚举类=%s", key, targetEnumClazz.getName()) + ); + } + return enumItem; + } + + /** + * 自定义返回字段名,生成下拉框简易列表 + */ + public static & IEnum> List> toMapList( + Class targetEnumClazz, String keyMapName, String valueMapName + ) { + List enumList = toList(targetEnumClazz); + List> result = new ArrayList<>(enumList.size()); + for (T enumItem : enumList) { + Map map = new HashMap<>(2); + map.put(keyMapName, enumItem.getKey()); + map.put(valueMapName, enumItem.getValue()); + result.add(map); + } + return result; + } + + // ====================== 私有底层查询方法(基于接口方法,无反射硬编码) ====================== + private static & IEnum> Optional findByKey(Class clazz, Object key) { + String targetKey = String.valueOf(key); + return toList(clazz).stream() + .filter(item -> targetKey.equals(String.valueOf(item.getKey()))) + .findFirst(); + } + + private static & IEnum> Optional findByValue(Class clazz, String value) { + return toList(clazz).stream() + .filter(item -> value.equals(item.getValue())) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/utils/FileUtil.java b/src/main/java/com/yunhai/userbase/utils/FileUtil.java new file mode 100644 index 0000000..151c089 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/FileUtil.java @@ -0,0 +1,411 @@ +package com.yunhai.userbase.utils; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.IdUtil; +import com.bestsch.utils.DException; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * @author min + * @description File工具类,扩展 hutool 工具包 + * @date 2019-07-05 + */ +public class FileUtil { + + /** + * 定义GB的计算常量(改为long避免int溢出) + */ + private static final long GB = 1024L * 1024 * 1024; + /** + * 定义MB的计算常量 + */ + private static final long MB = 1024L * 1024; + /** + * 定义KB的计算常量 + */ + private static final long KB = 1024L; + + /** + * 格式化小数 + */ + private static final DecimalFormat DF = new DecimalFormat("0.00"); + + /** + * 上传文件存储根目录(由 FileStorageConfig 在启动时注入)。
+ */ + private static String BASE_STORE_PATH; + + /** + * 上传文件对外访问的 Web 根地址(由 FileStorageConfig 在启动时注入)。
+ */ + private static String BASE_WEB_PATH; + + /** + * 危险后缀黑名单:命中则禁止上传,防止恶意文件被执行/访问。
+ */ + private static final Set DANGEROUS_EXT = new HashSet<>(Arrays.asList( + "jsp", "jspx", "html", "htm", "js", "exe", "sh", "bat", + "php", "asp", "aspx", "jar", "war", "class", "htaccess" + )); + + /** + * 由 Spring 启动时注入存储根目录与 Web 访问前缀。
+ * + * @param storePath 磁盘存储根目录 + * @param webPath 对外访问的 Web 根地址 + */ + public static void init(String storePath, String webPath) { + BASE_STORE_PATH = storePath; + BASE_WEB_PATH = webPath; + } + + /** + * 校验文件后缀是否在危险黑名单中。
+ * + * @param ext 文件后缀(可带或不带点) + * @throws DException 命中黑名单时抛出 + */ + private static void validateExt(String ext) { + if (ext == null || ext.isEmpty()) { + return; + } + String lower = ext.toLowerCase().replaceFirst("^\\.", ""); + if (DANGEROUS_EXT.contains(lower)) { + throw new DException("不支持的文件类型: " + ext); + } + } + + /** + * 净化文件名,防止路径穿越(../ 等)。
+ * + * @param name 原始文件名 + * @return 净化后的安全文件名 + */ + private static String sanitizeFileName(String name) { + if (name == null || name.trim().isEmpty()) { + return "file"; + } + return name.replaceAll("[\\\\/]", "_") + .replaceAll("\\.{2,}", "_") + .replaceAll("\\s+", "_"); + } + + /** + * 拼接完整可访问的 Web URL(根地址 + 子目录 + 文件名)。
+ * + * @param subDir 子目录(如 image/file/icon) + * @param fileName 文件名 + * @return 完整访问 URL + */ + private static String buildUrl(String subDir, String fileName) { + String web = (BASE_WEB_PATH == null) ? "" : BASE_WEB_PATH; + web = web.endsWith("/") ? web : web + "/"; + return web + subDir + "/" + fileName; + } + + /** + * 校验存储配置已注入。
+ * + * @throws DException 未注入时抛出 + */ + private static void ensureStoreBase() { + if (BASE_STORE_PATH == null || BASE_WEB_PATH == null) { + throw new DException("文件存储路径未初始化,请检查 FILE_PATH / FILE_WEB_PATH 配置"); + } + } + + /** + * MultipartFile转File(临时文件) + * @param multipartFile 上传文件 + * @return 临时文件对象,失败返回null + */ + public static File toFile(MultipartFile multipartFile) { + if (multipartFile == null || multipartFile.isEmpty()) { + return null; + } + // 获取文件后缀 + String ext = getExtensionName(multipartFile.getOriginalFilename()); + String prefix = "." + ext; + File file = null; + try { + // 用uuid作为文件名,防止生成的临时文件重复 + file = File.createTempFile(IdUtil.simpleUUID(), prefix); + multipartFile.transferTo(file); + } catch (IOException e) { + e.printStackTrace(); + } + return file; + } + + /** + * 删除文件/目录 + * @param files 待删除文件数组 + */ + public static void deleteFile(File... files) { + if (files == null || files.length == 0) { + return; + } + for (File file : files) { + if (file != null && file.exists()) { + cn.hutool.core.io.FileUtil.del(file); + } + } + } + + /** + * 获取文件扩展名(不带点) + * @param filename 文件名 + * @return 扩展名,无后缀返回原文件名 + */ + public static String getExtensionName(String filename) { + return cn.hutool.core.io.FileUtil.extName(filename); + } + + /** + * 获取不带扩展名的文件名 + * @param filename 文件名 + * @return 主文件名 + */ + public static String getFileNameNoEx(String filename) { + return cn.hutool.core.io.FileUtil.mainName(filename); + } + + /** + * 文件大小转换(修复int溢出问题) + * @param size 文件字节数 + * @return 格式化后的大小字符串 + */ + public static String getSize(int size) { + long sizeLong = size; + String resultSize; + if (sizeLong / GB >= 1) { + resultSize = DF.format(sizeLong / (double) GB) + "GB "; + } else if (sizeLong / MB >= 1) { + resultSize = DF.format(sizeLong / (double) MB) + "MB "; + } else if (sizeLong / KB >= 1) { + resultSize = DF.format(sizeLong / (double) KB) + "KB "; + } else { + resultSize = sizeLong + "B "; + } + return resultSize; + } + + /** + * 字节数组上传图片(固定.png后缀,存 image 子目录)。
+ * + * @param imgData 图片字节数组 + * @return 完整可访问的 Web URL + */ + public static String uploadImg(byte[] imgData) { + return uploadBytes(imgData, "image", ".png"); + } + + /** + * MultipartFile上传图片(按原后缀,存 image 子目录)。
+ * + * @param file 上传文件 + * @return 完整可访问的 Web URL + */ + public static String uploadImg(MultipartFile file) { + return uploadMultipart(file, "image"); + } + + /** + * 字节数组上传文件(固定.png后缀,存 file 子目录)。
+ * + * @param imgData 文件字节数组 + * @return 完整可访问的 Web URL + */ + public static String uploadFile(byte[] imgData) { + return uploadBytes(imgData, "file", ".png"); + } + + /** + * MultipartFile上传文件(按原后缀,存 file 子目录)。
+ * + * @param file 上传文件 + * @return 完整可访问的 Web URL + */ + public static String uploadFile(MultipartFile file) { + return uploadMultipart(file, "file"); + } + + /** + * 通用上传:按原后缀保存到指定子目录,返回完整可访问的 Web URL。
+ * + * @param file 上传文件 + * @param subDir 子目录(如 image/file/icon/media/word/html) + * @return 完整可访问的 Web URL + */ + public static String upload(MultipartFile file, String subDir) { + return uploadMultipart(file, subDir); + } + + /** + * URL文件代理中转下载 + * @param response 响应对象 + * @param address 远程文件地址 + * @param contentType 响应Content-Type,传null自动识别 + * @param fileName 下载文件名,传null取远程响应头 + * @throws IOException 网络/IO异常 + */ + public static void proxyUrlFile(HttpServletResponse response, String address, String contentType, String fileName) throws IOException { + // 原有https转http逻辑保留 + if (address != null && address.toLowerCase().startsWith("https:")) { + address = address.replaceFirst("(?i)^https:", "http:") + .replaceAll(":443(/|$)", ":80$1"); + } + + HttpURLConnection httpURLConnection = null; + InputStream inputStream = null; + ServletOutputStream outputStream = null; + try { + URL url = new URL(address); + httpURLConnection = (HttpURLConnection) url.openConnection(); + httpURLConnection.setConnectTimeout(5000); + httpURLConnection.setReadTimeout(30000); + httpURLConnection.connect(); + inputStream = httpURLConnection.getInputStream(); + outputStream = response.getOutputStream(); + + // 设置Content-Type + if (contentType != null) { + response.setContentType(contentType); + } else { + response.setContentType("multipart/form-data"); + } + + // 设置下载文件名 + if (fileName != null) { + response.setHeader("Content-disposition", "attachment; filename=\"" + + new String(fileName.getBytes("utf-8"), "ISO8859-1") + "\""); + } else { + String headerFileName = ""; + String disposition = httpURLConnection.getHeaderField("Content-Disposition"); + if (disposition != null) { + headerFileName = new String(disposition.getBytes("ISO-8859-1"), "UTF-8"); + } + if (headerFileName.contains("filename")) { + headerFileName = headerFileName.substring(headerFileName.lastIndexOf("filename") + "filename".length()); + // 去除可能的=号和引号 + headerFileName = headerFileName.replaceFirst("=", "").replace("\"", "").trim(); + } + response.setHeader("Content-disposition", "attachment; filename=" + headerFileName); + } + + // 流式拷贝,8KB缓冲区提升性能 + IoUtil.copy(inputStream, outputStream, IoUtil.DEFAULT_BUFFER_SIZE); + + } catch (Exception e) { + throw e; + } finally { + IoUtil.close(inputStream); + if (outputStream != null) { + outputStream.flush(); + IoUtil.close(outputStream); + } + if (httpURLConnection != null) { + httpURLConnection.disconnect(); + } + } + } + + /** + * 保存上传文件到指定完整路径 + * @param mf 上传文件 + * @param path 完整保存路径(含文件名) + */ + public static void saveResource(MultipartFile mf, String path) { + if (mf == null || path == null || path.trim().length() == 0) { + throw new DException(DicUtil.ERROR_MSG_IMPORT_WRONG); + } + // 净化文件名,防止路径穿越(如 ../../evil) + File target = new File(path); + String safeName = sanitizeFileName(target.getName()); + File safeFile = new File(target.getParentFile(), safeName); + // 自动创建父目录 + cn.hutool.core.io.FileUtil.mkParentDirs(safeFile.getPath()); + try (InputStream is = mf.getInputStream(); + FileOutputStream fos = new FileOutputStream(safeFile)) { + IoUtil.copy(is, fos); + } catch (Exception e) { + throw new DException(DicUtil.ERROR_MSG_IMPORT_WRONG); + } + } + + // ==================== 私有通用方法,消除重复代码 ==================== + + /** + * 字节数组通用上传(流式写入,返回完整 Web URL)。
+ * + * @param data 字节数组 + * @param subDir 子目录 + * @param ext 固定后缀(带点) + * @return 完整可访问的 Web URL + */ + private static String uploadBytes(byte[] data, String subDir, String ext) { + if (data == null || data.length == 0 || subDir == null || subDir.trim().length() == 0) { + throw new DException("上传内容为空"); + } + // 后缀黑名单校验(固定后缀也走同一规则) + validateExt(ext); + ensureStoreBase(); + try { + // 自动创建多级目录 + String dirPath = BASE_STORE_PATH + File.separator + subDir; + cn.hutool.core.io.FileUtil.mkdir(dirPath); + String fileName = IdUtil.fastSimpleUUID() + ext; + File targetFile = new File(dirPath, fileName); + // Hutool写入,自动关流 + cn.hutool.core.io.FileUtil.writeBytes(data, targetFile); + return buildUrl(subDir, fileName); + } catch (DException e) { + throw e; + } catch (Exception e) { + throw new DException("文件上传失败: " + e.getMessage()); + } + } + + /** + * MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。
+ * + * @param file 上传文件 + * @param subDir 子目录 + * @return 完整可访问的 Web URL + */ + private static String uploadMultipart(MultipartFile file, String subDir) { + if (file == null || file.isEmpty() || subDir == null || subDir.trim().length() == 0) { + throw new DException("上传文件不能为空"); + } + // 提取后缀 + String originalFilename = file.getOriginalFilename(); + String ext = cn.hutool.core.io.FileUtil.extName(originalFilename); + ext = (ext == null || ext.trim().length() == 0) ? "" : "." + ext.toLowerCase(); + // 后缀黑名单校验 + validateExt(ext); + ensureStoreBase(); + try { + String dirPath = BASE_STORE_PATH + File.separator + subDir; + cn.hutool.core.io.FileUtil.mkdir(dirPath); + String fileName = IdUtil.fastSimpleUUID() + ext; + File targetFile = new File(dirPath, fileName); + // 流式写入,不加载全文件到内存 + cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile); + return buildUrl(subDir, fileName); + } catch (DException e) { + throw e; + } catch (Exception e) { + throw new DException("文件上传失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/GzipUtil.java b/src/main/java/com/yunhai/userbase/utils/GzipUtil.java new file mode 100644 index 0000000..0581d95 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/GzipUtil.java @@ -0,0 +1,64 @@ +package com.yunhai.userbase.utils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * GZIP 压缩/解压工具。
+ * 用于操作日志响应结果(response_data)的压缩存储与下载解压。
+ * + * @author chenx + * @date 2026/7/28 + */ +public class GzipUtil { + + private static final int BUFFER_SIZE = 4096; + + /** + * 将字符串按 UTF-8 压缩为 GZIP 字节数组。 + * + * @param data 原始字符串,可为 null + * @return 压缩后字节数组,入参为空返回 null + */ + public static byte[] compress(String data) { + if (data == null) { + return null; + } + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write(data.getBytes(StandardCharsets.UTF_8)); + gzip.finish(); + return out.toByteArray(); + } catch (IOException e) { + throw new RuntimeException("GZIP 压缩失败", e); + } + } + + /** + * 将 GZIP 字节数组解压为原始字符串。 + * + * @param data 压缩字节数组,可为 null 或空 + * @return 解压后字符串,入参为空返回 null + */ + public static String decompress(byte[] data) { + if (data == null || data.length == 0) { + return null; + } + try (ByteArrayInputStream in = new ByteArrayInputStream(data); + GZIPInputStream gzip = new GZIPInputStream(in); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buf = new byte[BUFFER_SIZE]; + int n; + while ((n = gzip.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toString(StandardCharsets.UTF_8.name()); + } catch (IOException e) { + throw new RuntimeException("GZIP 解压失败", e); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/HttpUtils.java b/src/main/java/com/yunhai/userbase/utils/HttpUtils.java new file mode 100644 index 0000000..9841624 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/HttpUtils.java @@ -0,0 +1,143 @@ +package com.yunhai.userbase.utils; + +import com.bestsch.utils.DException; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.ByteBuffer; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; + +public class HttpUtils { + static { + try { + trustAllHttpsCertificates(); + HttpsURLConnection.setDefaultHostnameVerifier + ( + (urlHostName, session) -> true + ); + } catch (Exception e) { + } + } + /** + * 跳过ssl证书 + * + * @throws NoSuchAlgorithmException + * @throws KeyManagementException + */ + private static void trustAllHttpsCertificates() + throws NoSuchAlgorithmException, KeyManagementException { + TrustManager[] trustAllCerts = new TrustManager[1]; + trustAllCerts[0] = (TrustManager) new TrustAllManager(); + SSLContext sc = SSLContext.getInstance("SSL"); + sc.init(null, trustAllCerts, null); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + } + + private static class TrustAllManager implements X509TrustManager { + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + public void checkServerTrusted(X509Certificate[] certs, String authType) { + } + + public void checkClientTrusted(X509Certificate[] certs, String authType) { + } + } + + public static String get(String aUrl,Integer timeout){ + int respCode = 0; + HttpURLConnection conn = null; + String var3; + try { + conn = getHttpURLConnection(aUrl, "GET",timeout); + conn.setDoInput(true); + respCode = conn.getResponseCode(); + if (respCode != 301) { + InputStream is = conn.getInputStream(); + int contentLen = conn.getContentLength(); + byte[] resultData = read(is, contentLen); + is.close(); + String var6 = new String(resultData, "utf-8"); + return var6; + } + var3 = get(conn.getHeaderField("Location"),timeout); + } catch (Exception var10) { + throw new DException("网络连接失败"); + } finally { + close(conn); + } + return var3; + } + + + private static HttpURLConnection getHttpURLConnection(String uri, String method,int timeout) throws IOException { + URL url = new URL(uri); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(timeout); + conn.setReadTimeout(timeout); + return conn; + } + + private static byte[] read(InputStream is, int contentLen) throws IOException { + if (contentLen <= 0) { + int BUF_SIZE = 102400; + byte[] buf = new byte[102400]; + ByteBuffer byteBuffer = ByteBuffer.allocate(102400); + + int readed; + do { + if (byteBuffer.position() + 102400 > byteBuffer.capacity()) { + byteBuffer = reallocByteBuffer(byteBuffer, byteBuffer.capacity() + 102400); + } + + readed = is.read(buf, 0, 102400); + if (readed > 0) { + byteBuffer.put(buf, 0, readed); + } + } while(readed != -1); + + byte[] data = new byte[byteBuffer.position()]; + System.arraycopy(byteBuffer.array(), 0, data, 0, byteBuffer.position()); + return data; + } else { + byte[] data = new byte[contentLen]; + + int readed; + for(int remain = contentLen; remain > 0; remain -= readed) { + readed = is.read(data, contentLen - remain, remain); + if (readed == -1) { + break; + } + } + + return data; + } + } + private static ByteBuffer reallocByteBuffer(ByteBuffer buffer, int size) { + ByteBuffer newBuf = ByteBuffer.allocate(size); + newBuf.put(buffer.array(), 0, buffer.position()); + return newBuf; + } + private static void close(HttpURLConnection conn) { + try { + if (conn == null) { + return; + } + + conn.disconnect(); + } catch (Exception var2) { + var2.printStackTrace(); + } + + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/IpUtils.java b/src/main/java/com/yunhai/userbase/utils/IpUtils.java new file mode 100644 index 0000000..0235b0f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/IpUtils.java @@ -0,0 +1,53 @@ +package com.yunhai.userbase.utils; + +import org.apache.commons.lang3.StringUtils; + +import javax.servlet.http.HttpServletRequest; + +/** + * 客户端 IP 获取工具。
+ * 兼容反向代理场景:优先取 X-Forwarded-For 等代理头中的第一个真实 IP。
+ * + * @author chenx + * @date 2026/7/28 + */ +public class IpUtils { + + /** 常见的真实 IP 代理头。 */ + private static final String[] PROXY_HEADERS = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR", + "X-Real-IP" + }; + + private static final String UNKNOWN = "unknown"; + + /** + * 获取客户端真实 IP。
+ * X-Forwarded-For 可能为逗号分隔的多级代理,取第一段。
+ * + * @param request HTTP 请求 + * @return 客户端 IP,获取失败返回 "unknown" + */ + public static String getIpAddr(HttpServletRequest request) { + if (request == null) { + return UNKNOWN; + } + String ip = null; + for (String header : PROXY_HEADERS) { + ip = request.getHeader(header); + if (StringUtils.isNotBlank(ip) && !UNKNOWN.equalsIgnoreCase(ip)) { + int comma = ip.indexOf(','); + if (comma > 0) { + ip = ip.substring(0, comma).trim(); + } + return ip; + } + } + ip = request.getRemoteAddr(); + return StringUtils.isBlank(ip) ? UNKNOWN : ip; + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/NodeIdUtil.java b/src/main/java/com/yunhai/userbase/utils/NodeIdUtil.java new file mode 100644 index 0000000..f8bd723 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/NodeIdUtil.java @@ -0,0 +1,86 @@ +package com.yunhai.userbase.utils; + +import java.util.HashMap; +import java.util.Map; + +/** + * 课程节点 64 bit 复合 ID 工具类(无实体、纯静态) + * rootType 根节点类别【资源,辅导,题库,试卷,专项】 + * nodeType 虚拟节点类型【根节点,教材(辅)目录节点,年级节点,教材知识树节点,真实节点】 + * stage 学段【2小学 3初中 4高中】 + * grade 年级 1-12 + */ + +public final class NodeIdUtil { + + /* ===================== 位宽 ===================== */ + private static final int ROOT_BITS = 4; + private static final int TYPE_BITS = 4; // 原 biz + private static final int SUBJ_BITS = 20; + private static final int STAGE_BITS = 4; + private static final int GRADE_BITS = 4; + private static final int NODE_BITS = 28; + + private static final long ROOT_MASK = mask(ROOT_BITS); + private static final long TYPE_MASK = mask(TYPE_BITS); + private static final long SUBJ_MASK = mask(SUBJ_BITS); + private static final long STAGE_MASK = mask(STAGE_BITS); + private static final long GRADE_MASK = mask(GRADE_BITS); + private static final long NODE_MASK = mask(NODE_BITS); + + /* ===================== 编码 ===================== */ + public static long encode(Integer rootType, + String subject, + Integer stage, + Integer nodeType, + Integer grade, + Integer nodeId) { + int subNum = subjectToInt(subject); + long root = (rootType == null ? 0 : rootType) & ROOT_MASK; + long type = (nodeType == null ? 0 : nodeType) & TYPE_MASK; + long stageVal = (stage == null ? 0 : stage) & STAGE_MASK; + long gradeVal = (grade == null ? 0 : grade) & GRADE_MASK; + long nodeVal = (nodeId == null ? 0 : nodeId) & NODE_MASK; + + return root << (TYPE_BITS + SUBJ_BITS + STAGE_BITS + GRADE_BITS + NODE_BITS) + | type << (SUBJ_BITS + STAGE_BITS + GRADE_BITS + NODE_BITS) + | (long) subNum << (STAGE_BITS + GRADE_BITS + NODE_BITS) + | stageVal << (GRADE_BITS + NODE_BITS) + | gradeVal << NODE_BITS + | nodeVal; + } + + /* ===================== 解码 ===================== */ + public static Map decode(long compositeId) { + Map map = new HashMap<>(6); + int root = (int) ((compositeId >>> (TYPE_BITS + SUBJ_BITS + STAGE_BITS + GRADE_BITS + NODE_BITS)) & ROOT_MASK); + int type = (int) ((compositeId >>> (SUBJ_BITS + STAGE_BITS + GRADE_BITS + NODE_BITS)) & TYPE_MASK); + int subNum = (int) ((compositeId >>> (STAGE_BITS + GRADE_BITS + NODE_BITS)) & SUBJ_MASK); + int stage = (int) ((compositeId >>> (GRADE_BITS + NODE_BITS)) & STAGE_MASK); + int grade = (int) ((compositeId >>> (NODE_BITS)) & GRADE_MASK); + int nodeId = (int) (compositeId & NODE_MASK); + + map.put("rootType", root == 0 ? null : root); + map.put("nodeType", type == 0 ? null : type); + map.put("subject", intToSubject(subNum)); + map.put("stage", stage == 0 ? null : stage); + map.put("grade", grade == 0 ? null : grade); + map.put("nodeId", nodeId == 0 ? null : nodeId); + return map; + } + + /* ===================== subject 工具 ===================== */ + private static int subjectToInt(String subject) { + if (subject == null || !subject.startsWith("SUB")) return 0; + return Integer.parseUnsignedInt(subject.substring(3)); + } + + private static String intToSubject(int n) { + if (n == 0) return null; + return (n <= 6) ? "SUB0" + n : "SUB" + n; + } + + private static long mask(int bits) { return (1L << bits) - 1; } + + private NodeIdUtil() {} +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/utils/OperationLogResolver.java b/src/main/java/com/yunhai/userbase/utils/OperationLogResolver.java new file mode 100644 index 0000000..7f493fd --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/OperationLogResolver.java @@ -0,0 +1,85 @@ +package com.yunhai.userbase.utils; + +import com.yunhai.userbase.enums.LogLevel; + +import org.apache.commons.lang3.StringUtils; + +/** + * 操作日志级别与操作类型的自动判定。
+ * 规则(贴合本项目的接口风格):
+ * - GET 一律记为「一般(查询)」、操作类型 QUERY;
+ * - HTTP DELETE 方法或路径/方法名含 delete/del/remove 记为「重要(删除)」、操作类型 DELETE;
+ * - 含 add/create/insert 记为操作类型 CREATE;
+ * - 其余 POST/PUT 记为「次要(新增/编辑)」、操作类型 UPDATE。
+ * + * @author chenx + * @date 2026/7/28 + */ +public class OperationLogResolver { + + private static final String[] DELETE_KEYWORDS = {"delete", "del", "remove"}; + private static final String[] CREATE_KEYWORDS = {"add", "create", "insert"}; + + /** + * 判定日志级别。 + * + * @param httpMethod HTTP 方法(GET/POST/PUT/DELETE...) + * @param uri 请求路径 + * @param methodName 被拦截的方法名 + * @return 日志级别 + */ + public static LogLevel resolveLevel(String httpMethod, String uri, String methodName) { + if (StringUtils.isBlank(httpMethod)) { + return LogLevel.SECONDARY; + } + String method = httpMethod.toUpperCase(); + if ("GET".equals(method)) { + return LogLevel.GENERAL; + } + if ("DELETE".equals(method)) { + return LogLevel.IMPORTANT; + } + if (containsAny(uri, methodName, DELETE_KEYWORDS)) { + return LogLevel.IMPORTANT; + } + return LogLevel.SECONDARY; + } + + /** + * 判定操作类型(DELETE/CREATE/UPDATE/QUERY)。 + * + * @param httpMethod HTTP 方法 + * @param uri 请求路径 + * @param methodName 被拦截的方法名 + * @return 操作类型字符串 + */ + public static String resolveOperationType(String httpMethod, String uri, String methodName) { + if (StringUtils.isBlank(httpMethod)) { + return "UPDATE"; + } + String method = httpMethod.toUpperCase(); + if ("GET".equals(method)) { + return "QUERY"; + } + if ("DELETE".equals(method)) { + return "DELETE"; + } + if (containsAny(uri, methodName, DELETE_KEYWORDS)) { + return "DELETE"; + } + if (containsAny(uri, methodName, CREATE_KEYWORDS)) { + return "CREATE"; + } + return "UPDATE"; + } + + private static boolean containsAny(String uri, String methodName, String[] keywords) { + String text = (StringUtils.lowerCase(uri) + " " + StringUtils.lowerCase(methodName)); + for (String keyword : keywords) { + if (StringUtils.contains(text, keyword)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/OtherUtil.java b/src/main/java/com/yunhai/userbase/utils/OtherUtil.java new file mode 100644 index 0000000..1e0faa0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/OtherUtil.java @@ -0,0 +1,139 @@ +package com.yunhai.userbase.utils; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONException; +import com.alibaba.fastjson.JSONObject; +import com.bestsch.utils.DException; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/* +* 杂项公用方法集合 +* */ +public class OtherUtil { + + /** + * 十六进制转字符串 + * @param hex + * @return + */ + public final static String hexToString(String hex) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < hex.length(); i += 2) { + String hexSubstring = hex.substring(i, i + 2); + int decimalValue = Integer.parseInt(hexSubstring, 16); + sb.append((char) decimalValue); + } + return sb.toString(); + } + + /** + * @Description: 判断字符串能否序列化 + * @Author: chenx + * @Date: 2023/4/20 + */ + public final static boolean isJSONValid(String str) { + try { + JSONObject.parse(str); + } catch (JSONException ex) { + return false; + } + return true; + } + + /** + * @Description: 判断字符串能否序列化为json数组 + * @Author: chenx + * @Date: 2023/4/20 + */ + public final static boolean isJsonArray(String str) { + if (!isJSONValid(str)) return false; + try { + Object object = JSON.parse(str); + return object instanceof JSONArray; + } catch (JSONException ex) { + return false; + } + } + + /** + * @Description: 数组深拷贝 + * @Author: chenx + * @Date: 2023/4/20 + */ + public final static List deepCopy(List src, Class clazz) { + String jsonStr=JSON.toJSONString(src); + if (!isJsonArray(jsonStr)) return src; + return JSONArray.parseArray(jsonStr,clazz); + } + + /** + * @Description: 逗号分隔字符串转List数组 + * @Author: chenx + * @Date: 2023/4/20 + */ + public final static List SplitToList(String idsStr) { + return Arrays.stream(idsStr.split(",")).map(s -> Integer.parseInt(s.trim())).collect(Collectors.toList()); + } + + /** + * 判断是否为校领导 + */ + public final static Boolean isAdminById(Integer roleId){ + return Arrays.asList(2,5,7).contains(roleId); + } + + + public static JSONObject getDataOrThrow(String result) { + JSONObject root = JSON.parseObject(result); + if (root.getIntValue("code") != 0) { + throw new DException("接口异常: " + root.getString("msg")); + } + return root.getJSONObject("data"); + } + + /** + * result 里 data 直接就是数组 [],返回 List + */ + public static List getDataArray(String result) { + JSONObject root = JSON.parseObject(result); + if (root.getIntValue("code") != 0) { + throw new DException("接口异常: " + root.getString("msg")); + } + // data 本身就是 JSONArray + return root.getJSONArray("data").toJavaList(JSONObject.class); + } + + + /** + * 根据 code 最大值返回学段 + * 1~6 → 2 + * 7~9 → 3 + * 10~12→ 4 + */ + public static int calcStage(List codes) { + if (codes == null || codes.isEmpty()) { + throw new IllegalArgumentException("codes 不能为空"); + } + int max = codes.stream().max(Integer::compareTo).get(); + if (max <= 6) return 2; + if (max <= 9) return 3; + return 4; + } + + + public static String getUUID(){ + return UUID.randomUUID().toString().replace("-",""); + } + + + public static Integer parseIntNullable(Object val) { + return val == null ? null : Integer.parseInt(val.toString()); + } + + +} diff --git a/src/main/java/com/yunhai/userbase/utils/PageUtils.java b/src/main/java/com/yunhai/userbase/utils/PageUtils.java new file mode 100644 index 0000000..00b0c2f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/PageUtils.java @@ -0,0 +1,53 @@ +package com.yunhai.userbase.utils; + + +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * @author chenx + * @date 2026/7/21 21:54 + * @description 分页辅助类 + */ +public class PageUtils { + + private PageUtils() { + } + + /** + * 将 BasePageQuery 转换为 Spring Data JPA 的 Pageable。
+ * + * @param query 前端分页参数(pageIndex 从 1 开始) + * @param sort 排序规则 + * @return Pageable + */ + public static Pageable toPageable(BasePageQuery query, Sort sort) { + int page = Math.max(query.getPageIndex() - 1, 0); + int size = Math.max(query.getPageSize(), 1); + if(sort==null)sort=Sort.unsorted(); + return PageRequest.of(page, size, sort); + } + + /** + * 将 Spring Data 的 Page 装配为统一分页响应外壳 BasePageResult。
+ * 页码约定:Spring Data 的 page 从 0 起,对外暴露的 pageIndex 从 1 起。
+ * + * @param page 仓储/Service 返回的分页结果 + * @param 数据元素类型 + * @return 统一分页响应 + */ + public static BasePageResult toResult(Page page) { + return new BasePageResult( + page.getContent(), + (int) page.getTotalElements(), + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.hasNext() + ); + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/PermissionBits.java b/src/main/java/com/yunhai/userbase/utils/PermissionBits.java new file mode 100644 index 0000000..9f97727 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/PermissionBits.java @@ -0,0 +1,43 @@ +package com.yunhai.userbase.utils; + +/** + * @author chenx + * @date 2025/12/29 9:47 + * @description 权限bit类型许可帮助类 + */ +public final class PermissionBits { + + // 位掩码 + public static final int CREATE = 1; // 0001 + public static final int UPDATE = 2; // 0010 + public static final int MOVE = 4; // 0100 + public static final int DELETE = 8; // 1000 + + /** 布尔数组→二进制 */ + public static byte pack(boolean canCreate, boolean canUpdate, + boolean canMove, boolean canDelete) { + int value = 0; + if (canCreate) value |= CREATE; + if (canUpdate) value |= UPDATE; + if (canMove) value |= MOVE; + if (canDelete) value |= DELETE; + return (byte) value; // 范围 0~15,够用 + } + + /** 二进制→布尔数组,返回顺序与上面一致 */ + public static boolean[] unpack(byte packed) { + boolean[] arr = new boolean[4]; + arr[0] = (packed & CREATE) != 0; // 新增 + arr[1] = (packed & UPDATE) != 0; // 编辑 + arr[2] = (packed & MOVE) != 0; // 移动 + arr[3] = (packed & DELETE) != 0; // 删除 + return arr; + } + + /* 可选:校验是否拥有某权限 */ + public static boolean has(int packed, int permission) { + return (packed & permission) == permission; + } + + private PermissionBits() {} +} diff --git a/src/main/java/com/yunhai/userbase/utils/RedisConstants.java b/src/main/java/com/yunhai/userbase/utils/RedisConstants.java new file mode 100644 index 0000000..1e86452 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/RedisConstants.java @@ -0,0 +1,10 @@ +package com.yunhai.userbase.utils; + +public class RedisConstants { + + private static final String CachePrefix = "yunhai.userbase."; + + //学校目录树 + public static final String SCH_ROOT_TREE =CachePrefix + "sch_root_tree"; + +} diff --git a/src/main/java/com/yunhai/userbase/utils/RedisUtil.java b/src/main/java/com/yunhai/userbase/utils/RedisUtil.java new file mode 100644 index 0000000..b1abccc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/RedisUtil.java @@ -0,0 +1,868 @@ +package com.yunhai.userbase.utils; + + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + + +/** + * Redis工具类 + */ + +@Component +public final class RedisUtil { + + + @Autowired + private RedisTemplate redisTemplate; + + + /** + * 指定缓存失效时间 + * + * @param key 键 + * @param time 时间(秒) + * @return + */ + + public boolean expire(String key, long time) { + + try { + + if (time > 0) { + + redisTemplate.expire(key, time, TimeUnit.SECONDS); + + } + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 根据key 获取过期时间 + * + * @param key 键 不能为null + * @return 时间(秒) 返回0代表为永久有效 + */ + + public long getExpire(String key) { + + return redisTemplate.getExpire(key, TimeUnit.SECONDS); + + } + + + /** + * 判断key是否存在 + * + * @param key 键 + * @return true 存在 false不存在 + */ + + public boolean hasKey(String key) { + + try { + + return redisTemplate.hasKey(key); + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 删除缓存 + * + * @param key 可以传一个值 或多个 + */ + + @SuppressWarnings("unchecked") + + public void del(String... key) { + + if (key != null && key.length > 0) { + + if (key.length == 1) { + + redisTemplate.delete(key[0]); + + } else { + + redisTemplate.delete(CollectionUtils.arrayToList(key)); + + } + + } + + } + + + // ============================String============================= + + /** + * 普通缓存获取 + * + * @param key 键 + * @return 值 + */ + + public Object get(String key) { + + return key == null ? null : redisTemplate.opsForValue().get(key); + + } + + + /** + * 普通缓存放入 + * + * @param key 键 + * @param value 值 + * @return true成功 false失败 + */ + + public boolean set(String key, Object value) { + + try { + + redisTemplate.opsForValue().set(key, value); + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + + } + + + /** + * 普通缓存放入并设置时间 + * + * @param key 键 + * @param value 值 + * @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期 + * @return true成功 false 失败 + */ + + public boolean set(String key, Object value, long time) { + + try { + + if (time > 0) { + + redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS); + + } else { + + set(key, value); + + } + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 递增 + * + * @param key 键 + * @param delta 要增加几(大于0) + * @return + */ + + public long incr(String key, long delta) { + + if (delta < 0) { + + throw new RuntimeException("递增因子必须大于"); + + } + + return redisTemplate.opsForValue().increment(key, delta); + + } + + + /** + * 递减 + * + * @param key 键 + * @param delta 要减少几(小于0) + * @return + */ + + public long decr(String key, long delta) { + + if (delta < 0) { + + throw new RuntimeException("递减因子必须大于"); + + } + + return redisTemplate.opsForValue().increment(key, -delta); + + } + + + // ================================Map================================= + + /** + * HashGet + * + * @param key 键 不能为null + * @param item 项 不能为null + * @return 值 + */ + + public Object hget(String key, String item) { + + return redisTemplate.opsForHash().get(key, item); + + } + + + /** + * 获取hashKey对应的所有键值 + * + * @param key 键 + * @return 对应的多个键值 + */ + + public Map hmget(String key) { + + return redisTemplate.opsForHash().entries(key); + + } + + + /** + * HashSet + * + * @param key 键 + * @param map 对应多个键值 + * @return true 成功 false 失败 + */ + + public boolean hmset(String key, Map map) { + + try { + + redisTemplate.opsForHash().putAll(key, map); + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + /** + * HashSet 并设置时间 + * + * @param key 键 + * @param map 对应多个键值 + * @param time 时间(秒) + * @return true成功 false失败 + */ + + public boolean hmset(String key, Map map, long time) { + + try { + + redisTemplate.opsForHash().putAll(key, map); + + if (time > 0) { + + expire(key, time); + + } + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 向一张hash表中放入数据,如果不存在将创建 + * + * @param key 键 + * @param item 项 + * @param value 值 + * @return true 成功 false失败 + */ + + public boolean hset(String key, String item, Object value) { + + try { + + redisTemplate.opsForHash().put(key, item, value); + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 向一张hash表中放入数据,如果不存在将创建 + * + * @param key 键 + * @param item 项 + * @param value 值 + * @param time 时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间 + * @return true 成功 false失败 + */ + + public boolean hset(String key, String item, Object value, long time) { + + try { + + redisTemplate.opsForHash().put(key, item, value); + + if (time > 0) { + + expire(key, time); + + } + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 删除hash表中的值 + * + * @param key 键 不能为null + * @param item 项 可以使多个 不能为null + */ + + public void hdel(String key, Object... item) { + + redisTemplate.opsForHash().delete(key, item); + + } + + /** + * 判断hash表中是否有该项的值 + * + * @param key 键 不能为null + * @param item 项 不能为null + * @return true 存在 false不存在 + */ + + public boolean hHasKey(String key, String item) { + + return redisTemplate.opsForHash().hasKey(key, item); + + } + + + /** + * hash递增 如果不存在,就会创建一个 并把新增后的值返回 + * + * @param key 键 + * @param item 项 + * @param by 要增加几(大于) + * @return + */ + + public double hincr(String key, String item, double by) { + + return redisTemplate.opsForHash().increment(key, item, by); + + } + + + /** + * hash递减 + * + * @param key 键 + * @param item 项 + * @param by 要减少记(小于) + * @return + */ + + public double hdecr(String key, String item, double by) { + + return redisTemplate.opsForHash().increment(key, item, -by); + + } + + + // ============================set============================= + + /** + * 根据key获取Set中的所有值 + * + * @param key 键 + * @return + */ + + public Set sGet(String key) { + + try { + + return redisTemplate.opsForSet().members(key); + + } catch (Exception e) { + + e.printStackTrace(); + + return null; + + } + + } + + + /** + * 根据value从一个set中查询,是否存在 + * + * @param key 键 + * @param value 值 + * @return true 存在 false不存在 + */ + + public boolean sHasKey(String key, Object value) { + + try { + + return redisTemplate.opsForSet().isMember(key, value); + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 将数据放入set缓存 + * + * @param key 键 + * @param values 值 可以是多个 + * @return 成功个数 + */ + + public long sSet(String key, Object... values) { + + try { + + return redisTemplate.opsForSet().add(key, values); + + } catch (Exception e) { + + e.printStackTrace(); + + return 0; + + } + + } + + + /** + * 将set数据放入缓存 + * + * @param key 键 + * @param time 时间(秒) + * @param values 值 可以是多个 + * @return 成功个数 + */ + + public long sSetAndTime(String key, long time, Object... values) { + + try { + + Long count = redisTemplate.opsForSet().add(key, values); + + if (time > 0) + + expire(key, time); + + return count; + + } catch (Exception e) { + + e.printStackTrace(); + + return 0; + + } + + } + + + /** + * 获取set缓存的长度 + * + * @param key 键 + * @return + */ + + public long sGetSetSize(String key) { + + try { + + return redisTemplate.opsForSet().size(key); + + } catch (Exception e) { + + e.printStackTrace(); + + return 0; + + } + + } + + + /** + * 移除值为value的 + * + * @param key 键 + * @param values 值 可以是多个 + * @return 移除的个数 + */ + + public long setRemove(String key, Object... values) { + + try { + + Long count = redisTemplate.opsForSet().remove(key, values); + + return count; + + } catch (Exception e) { + + e.printStackTrace(); + + return 0; + + } + + } + + // ===============================list================================= + + + /** + * 获取list缓存的内容 + * + * @param key 键 + * @param start 开始 + * @param end 结束 0 到 -1代表所有值 + * @return + */ + + public List lGet(String key, long start, long end) { + + try { + + return redisTemplate.opsForList().range(key, start, end); + + } catch (Exception e) { + + e.printStackTrace(); + + return null; + + } + + } + + + /** + * 获取list缓存的长度 + * + * @param key 键 + * @return + */ + + public long lGetListSize(String key) { + + try { + + return redisTemplate.opsForList().size(key); + + } catch (Exception e) { + + e.printStackTrace(); + + return 0; + + } + + } + + + /** + * 通过索引 获取list中的值 + * + * @param key 键 + * @param index 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推 + * * @return + */ + + public Object lGetIndex(String key, long index) { + + try { + + return redisTemplate.opsForList().index(key, index); + + } catch (Exception e) { + + e.printStackTrace(); + + return null; + + } + + } + + + /** + * 将list放入缓存 + * + * @param key 键 + * @param value 值 + * @return + */ + + public boolean lSet(String key, Object value) { + + try { + + redisTemplate.opsForList().rightPush(key, value); + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 将list放入缓存 + * + * @param key 键 + * @param value 值 + * @param time 时间(秒) + * @return + */ + + public boolean lSet(String key, Object value, long time) { + + try { + + redisTemplate.opsForList().rightPush(key, value); + + if (time > 0) + + expire(key, time); + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 将list放入缓存 + * + * @param key 键 + * @param value 值 + * @return + */ + + public boolean lSet(String key, List value) { + + try { + + redisTemplate.opsForList().rightPushAll(key, value); + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 将list放入缓存 + * + * @param key 键 + * @param value 值 + * @param time 时间(秒) + * @return + */ + + public boolean lSet(String key, List value, long time) { + + try { + + redisTemplate.opsForList().rightPushAll(key, value); + + if (time > 0) + + expire(key, time); + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 根据索引修改list中的某条数据 + * + * @param key 键 + * @param index 索引 + * @param value 值 + * @return + */ + + public boolean lUpdateIndex(String key, long index, Object value) { + + try { + + redisTemplate.opsForList().set(key, index, value); + + return true; + + } catch (Exception e) { + + e.printStackTrace(); + + return false; + + } + + } + + + /** + * 移除N个值为value + * + * @param key 键 + * @param count 移除多少个 + * @param value 值 + * @return 移除的个数 + */ + + public long lRemove(String key, long count, Object value) { + + try { + + Long remove = redisTemplate.opsForList().remove(key, count, value); + + return remove; + + } catch (Exception e) { + + e.printStackTrace(); + + return 0; + + } + + } + +} diff --git a/src/main/java/com/yunhai/userbase/utils/SubjectDict.java b/src/main/java/com/yunhai/userbase/utils/SubjectDict.java new file mode 100644 index 0000000..56ff016 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/SubjectDict.java @@ -0,0 +1,78 @@ +package com.yunhai.userbase.utils; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @author chenx + * @date 2025/12/23 9:26 + * @description 学科Code映射表 静态字典 + */ + +public final class SubjectDict { + + /* ---------- 只读缓存 ---------- */ + private static final Map NAME_TO_CODE; + private static final Map CODE_TO_NAME; + + /* ---------- 初始化 ---------- */ + static { + Map tmp =new LinkedHashMap<>(32); + tmp.put("语文", "SUB01"); + tmp.put("数学", "SUB02"); + tmp.put("英语", "SUB03"); + tmp.put("物理", "SUB04"); + tmp.put("化学", "SUB05"); + tmp.put("生物", "SUB06"); + tmp.put("信息", "SUB7"); + tmp.put("品德", "SUB8"); + tmp.put("体育", "SUB9"); + tmp.put("美术", "SUB10"); + tmp.put("科学", "SUB11"); + tmp.put("劳技", "SUB12"); + tmp.put("历史", "SUB13"); + tmp.put("地理", "SUB14"); + tmp.put("政治", "SUB15"); + tmp.put("音乐", "SUB604741"); + tmp.put("心理", "SUB605021"); + tmp.put("艺术", "SUB605101"); + tmp.put("道法", "SUB605341"); + tmp.put("社会", "SUB605371"); + + NAME_TO_CODE = Collections.unmodifiableMap(tmp); + CODE_TO_NAME = Collections.unmodifiableMap(invert(tmp)); + } + + private static Map invert(Map source) { + Map target = new HashMap<>(source.size()); + source.forEach((k, v) -> target.put(v, k)); + return target; + } + + /* ---------- 工具方法 ---------- */ + + /** 中文名称 -> code */ + public static String toCode(String name) { + return NAME_TO_CODE.get(name); + } + + /** code -> 中文名称 */ + public static String toName(String code) { + return CODE_TO_NAME.get(code); + } + + public static boolean containsName(String name) { + return NAME_TO_CODE.containsKey(name); + } + + /** 只读视图 */ + public static Map allName2Code() { + return NAME_TO_CODE; + } + + private SubjectDict() { + // 禁止实例化 + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/utils/TimeUtils.java b/src/main/java/com/yunhai/userbase/utils/TimeUtils.java new file mode 100644 index 0000000..88f6c61 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/TimeUtils.java @@ -0,0 +1,273 @@ +package com.yunhai.userbase.utils; + + +import cn.hutool.core.date.DateTime; +import cn.hutool.core.date.DateUtil; +import com.yunhai.userbase.model.StartEndDate; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +public class TimeUtils { + private static SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); + private static SimpleDateFormat format1 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private static SimpleDateFormat format2 = new SimpleDateFormat("yyyy/MM/dd"); + + + /* + * 毫秒转化时分秒毫秒 + */ + public static String formatTime(Long ms) { + Integer ss = 1000; + Integer mi = ss * 60; + Integer hh = mi * 60; + Integer dd = hh * 24; + + Long day = ms / dd; + Long hour = (ms - day * dd) / hh; + Long minute = (ms - day * dd - hour * hh) / mi; + Long second = (ms - day * dd - hour * hh - minute * mi) / ss; + Long milliSecond = ms - day * dd - hour * hh - minute * mi - second * ss; + + StringBuffer sb = new StringBuffer(); + if(day > 0) { + sb.append(day+"天"); + } + if(hour > 0) { + sb.append(hour+"小时"); + } + if(minute > 0) { + sb.append(minute+"分"); + } + if(second > 0) { + sb.append(second+"秒"); + } + /*if(milliSecond > 0) { + sb.append(milliSecond+"毫秒"); + }*/ + return sb.toString(); + } + /** + * 获取过去几天内的日期数组 + * @param intervals intervals天内 + * @return 日期数组 + */ + public static ArrayList getDays(int intervals) { + ArrayList pastDaysList = new ArrayList<>(); + for (int i = intervals -1; i >= 0; i--) { + pastDaysList.add(getPastDate(i)); + } + return pastDaysList; + } + /** + * 获取过去第几天的日期 + * @param past + * @return + */ + public static String getPastDate(int past) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.DAY_OF_YEAR, calendar.get(Calendar.DAY_OF_YEAR) - past); + Date today = calendar.getTime(); + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); + String result = format.format(today); + return result; + } + + /** + * 获取两个日期之间的天数 + * @param start + * @param end + * @return + */ + public static List getBetweenDates(Date start, Date end) { + List days = new ArrayList(); + + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + try { + + Calendar tempStart = Calendar.getInstance(); + tempStart.setTime(start); + + Calendar tempEnd = Calendar.getInstance(); + tempEnd.setTime(end); + tempEnd.add(Calendar.DATE, 0);// 日期加1(包含结束) + while (tempStart.before(tempEnd)) { + days.add(dateFormat.format(tempStart.getTime())); + tempStart.add(Calendar.DAY_OF_YEAR, 1); + } + + } catch (Exception e) { + e.printStackTrace(); + } + return days; + } + + + /** + * 根据俩日期,获取中间存在某周的周一、周日 + */ + public static List getMonAndSunWeek(Date startDate, Date endDate) { + List list = new ArrayList<>(); + Date tmp = startDate; + int ii = 0; + try { + while (tmp.before(endDate)) { + StartEndDate pram = new StartEndDate(); + + Calendar calendar = Calendar.getInstance(); + if (ii == 0) { + String dateStr = format.format(startDate); + Date date = format.parse(dateStr); + calendar.setTime(date); + + if (calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) + calendar.add(Calendar.DAY_OF_YEAR, -1); + } else { + calendar.setTime(tmp); + calendar.add(Calendar.DATE, 1); + } + int i = calendar.get(Calendar.DAY_OF_WEEK) - 1; + calendar.add(Calendar.DATE, -i + 1); + pram.setStartDate(format1.parse(format.format(calendar.getTime()) + " 00:00:00"));//周一 + + calendar.add(Calendar.DATE, 6); + Date dd = format1.parse(format.format(calendar.getTime()) + " 23:59:59"); + pram.setEndDate(dd);//周日 + tmp = dd; + +// System.out.println("ii===========" + ii); + ii++; + list.add(pram); + } + } catch (Exception e) { + } + return list; + } + + /** + * 根据俩日期,获取中间存在某月的月初、月末 + */ + public static List getBeginAndLastMonth(Date startDate,Date endDate) { + List list = new ArrayList<>(); + Date tmp = startDate; + int ii = 0; + try { + while (tmp.before(endDate)) { + StartEndDate pram = new StartEndDate(); + + Calendar calendar = Calendar.getInstance(); + if (ii == 0) { + String dateStr = format.format(startDate); + Date date = format.parse(dateStr); + calendar.setTime(date); + } else { + calendar.setTime(tmp); + calendar.add(Calendar.DATE, 1); + } + + calendar.add(Calendar.MONTH, 0); + calendar.set(Calendar.DAY_OF_MONTH,1);//设置为1号,当前日期既为本月第一天 + pram.setStartDate(format1.parse(format.format(calendar.getTime()) + " 00:00:00"));//周一 + + + //获取当前月最后一天 + calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH)); + Date dd = format1.parse(format.format(calendar.getTime()) + " 23:59:59"); + pram.setEndDate(dd);//月末 + tmp = dd; + + ii++; + list.add(pram); + } + } catch (Exception e) { + } + return list; + } + /** + * 根据俩日期,获取中间存 + */ + public static List getBeiginAndLastTerm(Date startDate,Date endDate) { + List list = new ArrayList<>(); + StartEndDate _pram = new StartEndDate(); + + _pram.setStartDate(startDate); + _pram.setEndDate(endDate); + + list.add(_pram); + + return list; + } + + + + /** + * 指定某日期,在某段时间内是第几周 + */ + public static Integer getWeek(String sDate1,String eDate1,String date1) { + DateTime sDate = DateUtil.parseDate(sDate1); + DateTime eDate = DateUtil.parseDate(eDate1); + DateTime date = DateUtil.parseDate(date1); + int week = 1; + if (DateUtil.isIn(date, sDate, eDate)) { + //获取某周的结束时间(周日) + Date endDateOfWeek = DateUtil.endOfWeek(sDate); + while (!DateUtil.isIn(date, sDate, endDateOfWeek)) { + ++week; + //获取某周的结束时间(周日) + endDateOfWeek = DateUtil.endOfWeek(DateUtil.offsetDay(endDateOfWeek, 1)); + } + return week; + } + return null; + } + + + /** + * 指定某日期,在某段时间内是第几月 + */ + public static Integer getMonth(String sDate1,String eDate1,String date1) { + DateTime sDate = DateUtil.parseDate(sDate1); + DateTime eDate = DateUtil.parseDate(eDate1); + DateTime date = DateUtil.parseDate(date1); + int month = 1; + if (DateUtil.isIn(date, sDate, eDate)) { + //获取某月的结束时间(月末) + Date endDateOfMonth = DateUtil.endOfMonth(sDate); + while (!DateUtil.isIn(date, sDate, endDateOfMonth)) { + ++month; + endDateOfMonth = DateUtil.endOfMonth(DateUtil.offsetDay(endDateOfMonth, 1)); + } + return month; + } + return null; + } + + /** + * 获取学年日期区间 + */ + public static String getSchoolYear(Integer year) { + Calendar tempStart = Calendar.getInstance(); + tempStart.set(year, 8, 1, 0, 0, 0); + Calendar tempStart2 =(Calendar)tempStart.clone(); + tempStart2.add(Calendar.YEAR,1); + tempStart2.add(Calendar.DAY_OF_MONTH,-1); + return format2.format(tempStart.getTime()) + "-" + format2.format(tempStart2.getTime()); + } + /** + * 获取自然年日期区间 + */ + public static String getNatureYear(Integer year) { + Calendar tempStart = Calendar.getInstance(); + tempStart.set(year, 0, 1, 0, 0, 0); + Calendar tempStart2 =(Calendar)tempStart.clone(); + tempStart2.add(Calendar.YEAR,1); + tempStart2.add(Calendar.DATE,-1); + return format2.format(tempStart.getTime()) + "-" + format2.format(tempStart2.getTime()); + } + + +} diff --git a/src/main/java/com/yunhai/userbase/utils/ValidatorUtil.java b/src/main/java/com/yunhai/userbase/utils/ValidatorUtil.java new file mode 100644 index 0000000..cb42b09 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/ValidatorUtil.java @@ -0,0 +1,134 @@ +package com.yunhai.userbase.utils; + +import org.apache.commons.lang3.StringUtils; + +import java.util.regex.Pattern; + +/** + * 统一校验工具:手机号、身份证、密码强度等基于正则的规则集中在此类维护。
+ * 所有正则常量私有,只暴露语义化的校验方法,便于复用与统一调整。
+ * + * @author chenx + * @date 2026/7/23 + * @description 统一正则校验工具 + */ +public final class ValidatorUtil { + + /** 中国大陆手机号:1 开头,第二位 3-9,共 11 位。 */ + private static final Pattern PHONE = Pattern.compile("^1[3-9]\\d{9}$"); + + /** 18 位居民身份证结构:6 位地址码 + 8 位出生日期(19/20xx) + 3 位顺序码 + 1 位校验位(数字或 X)。 */ + private static final Pattern ID_CARD_18 = Pattern.compile( + "^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$"); + + /** 是否包含字母。 */ + private static final Pattern HAS_LETTER = Pattern.compile("[a-zA-Z]"); + + /** 是否包含数字。 */ + private static final Pattern HAS_DIGIT = Pattern.compile("\\d"); + + /** 是否包含符号(非字母、非数字、非空白的可见字符)。 */ + private static final Pattern HAS_SYMBOL = Pattern.compile("[^a-zA-Z0-9\\s]"); + + /** 身份证 18 位加权因子。 */ + private static final int[] ID_CARD_WEIGHT = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}; + + /** 身份证校验位对照表(余数 0-10 对应的校验码)。 */ + private static final char[] ID_CARD_CHECK = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'}; + + /** 密码最小长度。 */ + private static final int PASSWORD_MIN_LENGTH = 8; + + /** 密码最大长度。 */ + private static final int PASSWORD_MAX_LENGTH = 32; + + private ValidatorUtil() { + } + + /** + * 校验手机号是否合规。
+ * + * @param phone 手机号 + * @return true=合规 + */ + public static boolean isPhone(String phone) { + return StringUtils.isNotBlank(phone) && PHONE.matcher(phone).matches(); + } + + /** + * 基础校验身份证是否合法:18 位结构正则 + 末位校验码。
+ * + * @param idCard 身份证号 + * @return true=合法 + */ + public static boolean isIdCard(String idCard) { + if (StringUtils.isBlank(idCard) || !ID_CARD_18.matcher(idCard).matches()) { + return false; + } + int sum = 0; + for (int i = 0; i < ID_CARD_WEIGHT.length; i++) { + sum += (idCard.charAt(i) - '0') * ID_CARD_WEIGHT[i]; + } + char expected = ID_CARD_CHECK[sum % 11]; + char actual = Character.toUpperCase(idCard.charAt(17)); + return expected == actual; + } + + /** + * 校验密码强度,不满足时返回具体原因;满足时返回 null。
+ * 规则:长度 8-32;必须同时包含字母、数字、符号;不允许出现连续自然数序列(如 1234、6789)。
+ * + * @param password 明文密码 + * @return 不合规原因,合规返回 null + */ + public static String checkPasswordStrength(String password) { + if (StringUtils.isBlank(password)) { + return "密码不能为空"; + } + int length = password.length(); + if (length < PASSWORD_MIN_LENGTH || length > PASSWORD_MAX_LENGTH) { + return "密码长度需为 " + PASSWORD_MIN_LENGTH + "-" + PASSWORD_MAX_LENGTH + " 位"; + } + if (!HAS_LETTER.matcher(password).find()) { + return "密码必须包含字母"; + } + if (!HAS_DIGIT.matcher(password).find()) { + return "密码必须包含数字"; + } + if (!HAS_SYMBOL.matcher(password).find()) { + return "密码必须包含符号"; + } + if (containsConsecutiveSequence(password)) { + return "密码不允许出现连续自然数序列(如 1234)"; + } + return null; + } + + /** + * 是否包含「连续自然数」序列:相邻数字逐位递增 1,且连续 4 位及以上(如 1234、6789)。
+ * 仅拦截严格等差 +1 的序列;重复的(0000、8888)或非等差的(1121)均不被判定为连续自然数。 + * + * @param s 待检测字符串(一般为密码) + * @return true=包含连续自然数序列 + */ + private static boolean containsConsecutiveSequence(String s) { + int run = 1; + for (int i = 1; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') { + run = 1; + continue; + } + char prev = s.charAt(i - 1); + if (prev >= '0' && prev <= '9' && (c - prev) == 1) { + run++; + if (run >= 4) { + return true; + } + } else { + run = 1; + } + } + return false; + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/WarnInfoUtil.java b/src/main/java/com/yunhai/userbase/utils/WarnInfoUtil.java new file mode 100644 index 0000000..18d9efc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/WarnInfoUtil.java @@ -0,0 +1,8 @@ +package com.yunhai.userbase.utils; + +public class WarnInfoUtil { + + public static String MORE_RATIO_CONDITIONS(Integer key){ + return "占比设置已达" + key + "%,已超100%,请重新输入!"; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..ef71b75 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,89 @@ +server: + port: 8060 + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + servlet: + multipart: + max-file-size: 50MB # 单个文件大小 + max-request-size: 200MB # 单次请求总大小(多文件/表单整体上限,避免单文件没超但整体被拒) + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + redis: + host: 127.0.0.1 + port: 6379 + database: 0 + password: 123456 + # 连接/命令超时:让后台探活(不可达时)快速失败,避免阻塞定时器线程;不影响正常请求 + timeout: 500ms + data: + redis: + repositories: + enabled: false + http: + encoding: + force: true + charset: utf-8 + enabled: true + + datasource: + url: jdbc:mysql://localhost:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 30 + max-active: 30 + #jpa + jpa: + database: MySQL + show-sql: true + hibernate: + # 生产环境设置成 none,避免程序运行时自动更新数据库结构 + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + hibernate: + dialect: org.hibernate.dialect.MySQL5InnoDBDialect + open-in-view: true + + #本地关闭rabbit + rabbitmq: + listener: + direct: + auto-startup: false + simple: + auto-startup: false + + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com/cas/ + client-host-url: http://localhost:8060 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务) + app-key: 100616 + app-secret: 1febb9502d2744b6a235d3e4cfde4a2d + # 受信应用(服务端验签用):appKey -> appSecret + trusted-apps: + 100616: 1febb9502d2744b6a235d3e4cfde4a2d + +ADMIN_TAG_ID: 290 + +# 上传文件本地存储根目录(Linux 部署请通过环境变量/配置中心外置,避免写死 Windows 盘符) +FILE_PATH: D://bsch//bschlearn + +# 上传文件对外访问的 Web 根地址,须与 MyWebAppConfigurer 中的 /upload/{子目录}/ 映射保持一致 +FILE_WEB_PATH: http://localhost:8060/upload/ + +ZIYUAN_WEB_PATH_HTTP: http://base.bestsch.com/ziyuanku/file/ +UPLOAD_WEB_PATH_HTTP: http://localhost:8060/upload/file/ +ORC_SERVICE_URL: http://oa.bestsch.com/OrcService/api/Ocr/Upload diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..bf4aa1c --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,26 @@ +spring: + application: + name: userbase + cloud: + nacos: + config: + # Nacos 配置中心地址;生产也可通过环境变量 NACOS_ADDR 覆盖(例如内网别名 nacos:8848) + server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848} + # public 命名空间:Nacos 中 public 的 namespace id 固定为空字符串 + namespace: ${NACOS_NAMESPACE:dev} + # 应用自身配置所在分组 + group: USERBASE + # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 userbase.yaml + file-extension: yaml + encode: UTF-8 + # nacos 不可达时不强制退出,回退到本地 application.yml / application-prod.yml 兜底 + fail-fast: false + # 只读账号(仅 public 命名空间读权限),用于拉取配置;非管理员账号,可安全提交 + username: yh-reader + password: 123456 + # 共享公共配置:public 空间下的 common.yaml(所有服务公共项,如公共数据源 / Redis 等) + # 优先级低于本应用配置(userbase.yaml),即应用配置可覆盖公共配置中的同名字段 + shared-configs: + - data-id: common.yaml + group: DEFAULT_GROUP + refresh: false diff --git a/src/main/resources/bschconfig.jks b/src/main/resources/bschconfig.jks new file mode 100644 index 0000000000000000000000000000000000000000..c05e280a079845647158f445e1d30a743c09081b GIT binary patch literal 2267 zcmc(g`8U*k8^`B6Gh`bIVMruPmiP`P*|KGQhRBvB+gQfZlr?0Ov4u<$*=0>4V~;Eu z#lZ}U?nE~#WXryciMZ9dpXc-+JU?7NykF-!*SXI7^EvNpe`$XS0)ape3;10Kw?Hfo zdmZoT?d9tcc1V54A`}B45GDvngztfz%xtpE0348k9s>X-2%HF?gDJ^n)D%Q)5O@Qy zg+0Er?9=y`h*2Vtp_3XUtVFG++vT!0Str&$CR>)`JeUK;(Kc193 z%QCR`bNt%E!Xu@eY|RL-B-Cg7zJYdTzesxw={Zg|nmtDN9Nye5=4-n*$wovUo>;RZ zCY3$P@}Bj6ZfBoh86w8YQFe*lY#J>!ID-&nvkHGJdM3sCG<9`16^Wf_l;Ju-AqMmE z^^1*KD=;NmrA?k!ALzNSX z+Z!H-E8FBvu%R{$8236~b(c7mE_w;|IdfN+6$xArxbfKjDveIETdeHdWZ1O%s=J%2 zSx-AQUScN<>=i!y?q_{Tb~}K#dk{Zav@(plAt^H4YNE}1iahdyeYwjuI1;{c3pJoY z&P=z3$x&ZyD^{*B@qR!rLU`l9Dzru}pAFEe4%lt-sjd#;nQBh;<-m+2XWsT3itXte zSZ`!hlC&U}lJ9|e%FNbdh06P_oV7Z8VF*U*`+@T1kNi7#^{bXI=?;|(%H~msy6Z@G z-sQv#hRp09NmbhK73Pn=S1?HvapTWZuj^>xc<7Xdz8QxKePAa8&e{o;}xP+$9vIUq$lfE?zGDU94?Lb2X4#=JAD>Z6{a#Stip zSQy&bjx495#4|!Wq0^t$KIXQ+kySlwmjh`wS|fSyHVZ}H$kj}j6tLP!ykecAt+v{E z8=lGmIo_u5>UcEfM?`&m{M+sozHR-EnN9sUE4znvg{%39){LUYb%EHaUF?XUW{a98 zKVJUWb)iqjj(@q8%^es&>ih{r=+=CK%Ul+X<7!Dy3R5`yP=O`ePscIgDVHAXX6<}s znmeeY`+8AH)NirQKUT0Pu4X8C)FJXEc}DnL^t_?@jX}f0_M(eSW$qiHV5UW5D`cOB z6*S>1`Cd3ngy(m?(MCfCeCA`gS2i?!-pT6H%}W+)nwyPfUBdmPw>XOklf%)$x`dj+ zyAzU%)3+Ez<`XM`FF}O5R`m2k=#=Ebmp-@B>Y-lMJiEA=tk~Uy=6%!R*x-Uq-?5$c zGj3*n_Z*g0d({K;SB^z)>@A4=@SIT^4NQ(57$O~*Stc^PmVg$Px2})QvrIS*B<{s! zubmY&e)y!8bnKpCx#`Eb`Qa^YQK1_awGYOX#ofReyWMBI7R4}7)?hNDY)miLn(c(8 z1jlUhOSPAoHo7bley97TSf6u`cn>&m`;9Qpxfd5_xie`b8}8_!=xLJGgdj7?z{zW# z0hz`|l6S&k6=t8;`U1(fXEc|d={mP)Zua6z3DG%SI;jr?@tH?!>$xVs;7H^q=pOrH z%+c$?`Ki+CTp_h>&XrqgmKRBCgnBJ0&BQLYHNJDUZ%#|oG$R3y*7Z~FCIGY^v< z&KvKFItFt6(kwhImN-|u7tYoDzv##xi0g3BVHX<@v(a_DS6DFWU%>u9AOs?Rb-;MI zbr0iORPT3Qi&}aFgm?siC;1U5RZs;5g6BXq2-^RWhvNLd^8d9K5fJ;+pu_6{BLZB9 zX9Zw`5&-}bwL!;3o+gQxS54I^nvU;1Jz5H+q~G*0n?9qF(2y~?HBQkqDjBMX3>IHm z8LQ~0{qW(d5Zf4LINC z^4c8}j@1$MVnW-H02S6W2IHZhY|B`slfw$((;ro}n{+Cv*AFJ{uK&$HC_iIZAv*80 zARsld!eGQHOo(NNy!tumcra=v?C+woOO}^-wtj1)A%`OkFMy>*0rIpqEkp_O_(?mh zuCq`W00D|mfg+&r;oAyx!ujC5VD4=r2l0gZ>WUXPdt>9fX%GqXKTSORWrrpL5RrpE zoKMDu{^H?w4dYO%9$jH{p5J9c+`cLvi=p~<6X>;=f*g6+VrQfCNLIM|i-~(%BT)Bv zlnZBn98szupFssM2gv1+^C}7!yHu!=$7WV`=x3`E1N0PB98t!7i_*`2*#iAzWT5B4 z{0GIx&gU`sJ5Oz+E@B($a*y;#~6W%2&;lZOg0Gr12b`ImrbFfwM?=Yx7!R>8U7ZY5G2$);$ch Qte8qLJ=qTBUZpMk1NndI=Kufz literal 0 HcmV?d00001 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..73fa22c --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,28 @@ + + + + INFO + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + /app/logs/app.log + + /app/logs/app.%d{yyyy-MM-dd}.log + 30 + 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql/sys_group_root_init.sql b/src/main/resources/sql/sys_group_root_init.sql new file mode 100644 index 0000000..5b000a3 --- /dev/null +++ b/src/main/resources/sql/sys_group_root_init.sql @@ -0,0 +1,27 @@ +-- ============================================================ +-- 组织架构根节点初始化(教育云中心) +-- ------------------------------------------------------------ +-- 适用数据库:MySQL 5.7(用户所述“5.8”按 5.7 处理),同时兼容 MySQL 8.0。 +-- 运行方式(务必带字符集,避免中文名乱码): +-- mysql --default-character-set=utf8mb4 -u -p < sys_group_root_init.sql +-- 前置:表结构由 Hibernate(ddl-auto=update)创建,请先启动一次应用生成 sys_group 表再执行本脚本。 +-- 说明:本脚本只负责“幂等插入根节点”一条数据。 +-- 根节点(parent_id=0)的“不可手动添加/编辑/删除/移动”保护由应用代码层 +-- SysGroupService 的守卫实现(禁止代码删改),本脚本不再建 DB 触发器, +-- 因此你随时可用手动 SQL 直接删/改根节点。 +-- ============================================================ + +-- 统一会话字符集,保证后续中文(根节点名)正确写入 +SET NAMES utf8mb4; + + +-- 幂等插入根节点:仅当不存在 parent_id = 0 的记录时才插入,重复执行不会生成多条根。 +-- parent_id = 0 即根节点唯一标识;type 为 NULL(根节点无业务类型); +-- create_user 列 NOT NULL,填系统哨兵 0;create_time/update_time 由 NOW() 填充。 +-- 注:type 列可空性已由应用启动时 Hibernate(ddl-auto=update) 保证;若对“旧库曾为 NOT NULL”的库执行, +-- 可先手执行一次:ALTER TABLE sys_group MODIFY COLUMN type INT NULL;(本脚本不再内联该句以免字符集/权限干扰) +-- 重要:MySQL 的 SELECT 没有 FROM 子句时不能直接跟 WHERE,必须补 FROM DUAL(裸 SELECT+WHERE 会报 1064)。 +INSERT INTO sys_group (name, parent_id, type, sort, create_user, create_time, update_time) +SELECT '教育云中心', 0, NULL, 0, 0, NOW(), NOW() +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_group WHERE parent_id = 0); diff --git a/src/main/resources/sql/sys_user_init.sql b/src/main/resources/sql/sys_user_init.sql new file mode 100644 index 0000000..daf4e27 --- /dev/null +++ b/src/main/resources/sql/sys_user_init.sql @@ -0,0 +1,76 @@ +-- ============================================================ +-- 超级管理员账号初始化(yunhaisch / yunhaisch2 / yunhaisch3) +-- ------------------------------------------------------------ +-- 适用数据库:MySQL 5.7(用户所述“5.8”按 5.7 处理),同时兼容 MySQL 8.0。 +-- 运行方式(务必带字符集,避免中文名乱码): +-- mysql --default-character-set=utf8mb4 -u -p < sys_user_init.sql +-- 前置:表结构由 Hibernate(ddl-auto=update)创建,请先启动一次应用生成 sys_user 表再执行本脚本。 +-- 说明: +-- 1. 本脚本幂等插入 3 个超级管理员账号,重复执行不会生成重复账号(按 username 唯一去重)。 +-- 2. 密码统一为 yunhai@1357,已使用 BCrypt(strength=10,前缀 $2a$,与后端 +-- org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 默认一致)加密后写入 password 列。 +-- 3. 超级管理员角色由 roles 列承载:本系统角色枚举(SysRoleEnum)最高为 ADMIN(7), +-- 应用内 isAdminById 对 {2,5,7} 返回 true,故赋予 [7] 即具备管理员权限。 +-- 若需“全角色”超级权限,可将 '[7]' 改为 '[1,2,3,4,5,6,7]'。 +-- 4. type 列(用户类型枚举 UserTypeEnum):0=普通用户,1=超级管理员,2=学校管理员。 +-- 本脚本写入的 3 个种子账号均为 type=1(超级管理员);应用层在新增/编辑用户时会自动推导: +-- 内置超级管理员账号 -> 1,其余含 ADMIN(7) 角色 -> 2(学校管理员),默认 0(普通用户)。 +-- 5. disabled=0 表示“正常/未禁用”(StatusEnum.NORMAL);expired=0 表示未过期; +-- create_user=0 为系统哨兵值(同 sys_group_root_init.sql 约定)。 +-- 6. 用户表 id 由 MySQL AUTO_INCREMENT 生成(实体使用 GenerationType.IDENTITY)。 +-- 本脚本显式指定 3 个种子账号 id = 10000 / 10001 / 10002,使整张表 id 自 10000 段起; +-- 插入 10002 后,后续新增用户自增从 10003 起。 +-- 7. MySQL 的 SELECT 没有 FROM 子句时不能直接跟 WHERE,必须补 FROM DUAL(裸 SELECT+WHERE 会报 1064)。 +-- ============================================================ + +-- 统一会话字符集,保证后续中文(real_name)正确写入 +SET NAMES utf8mb4; + + +-- 账号 1:yunhaisch(id=10000) +INSERT INTO sys_user (id, username, password, real_name, roles, type, disabled, expired, create_user, create_time, update_time) +SELECT 10000, + 'yunhaisch', + '$2a$10$4dAsjZEyUVgYn/wsgaSUVOMcA/oykF8TT7FLptNkMf39H6.Bp8SZi', + '超级管理员', + '[7]', + 1, + 0, + 0, + 0, + NOW(), + NOW() +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'yunhaisch'); + +-- 账号 2:yunhaisch2(id=10001) +INSERT INTO sys_user (id, username, password, real_name, roles, type, disabled, expired, create_user, create_time, update_time) +SELECT 10001, + 'yunhaisch2', + '$2a$10$5kKs4Z3woPCnyq7mpdXvju3TkO/7t/lIuNR./ToAnsLiHBXCLBSDS', + '超级管理员', + '[7]', + 1, + 0, + 0, + 0, + NOW(), + NOW() +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'yunhaisch2'); + +-- 账号 3:yunhaisch3(id=10002) +INSERT INTO sys_user (id, username, password, real_name, roles, type, disabled, expired, create_user, create_time, update_time) +SELECT 10002, + 'yunhaisch3', + '$2a$10$JXjnk.qJR4Arh1J0hl0fFugN0OvUymApLDeipsK0ZLLv9a0/N1p1u', + '超级管理员', + '[7]', + 1, + 0, + 0, + 0, + NOW(), + NOW() +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'yunhaisch3'); diff --git a/src/test/java/com/yunhai/userbase/UserBaseApplicationTests.java b/src/test/java/com/yunhai/userbase/UserBaseApplicationTests.java new file mode 100644 index 0000000..7683091 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/UserBaseApplicationTests.java @@ -0,0 +1,14 @@ +package com.yunhai.userprofile; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class UserBaseApplicationTests { + + @Test + void contextLoads() { + System.out.println("test"); + } + +}