From 4ec19486e0cd6e517688cda4788ae1c159c9311f Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 14:40:23 +0800 Subject: [PATCH 01/58] 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"); + } + +} From 9ce3e831b535b3f803dc42ae549aa8d080056b3f Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 16:35:15 +0800 Subject: [PATCH 02/58] =?UTF-8?q?=E6=B5=8B=E8=AF=95ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 116 -------------------------- pom.xml | 6 ++ src/main/resources/bootstrap.yml | 3 +- src/main/resources/logback-spring.xml | 41 +++++---- 4 files changed, 32 insertions(+), 134 deletions(-) delete mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index 7970afb..0000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,116 +0,0 @@ -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/pom.xml b/pom.xml index bd1a429..83cce41 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,12 @@ com.fasterxml.jackson.core jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml index bf4aa1c..257ebb5 100644 --- a/src/main/resources/bootstrap.yml +++ b/src/main/resources/bootstrap.yml @@ -7,7 +7,7 @@ spring: # Nacos 配置中心地址;生产也可通过环境变量 NACOS_ADDR 覆盖(例如内网别名 nacos:8848) server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848} # public 命名空间:Nacos 中 public 的 namespace id 固定为空字符串 - namespace: ${NACOS_NAMESPACE:dev} + namespace: ${NACOS_NAMESPACE:ce902c5c-b568-4465-b058-a98e1e2992e7} # 应用自身配置所在分组 group: USERBASE # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 userbase.yaml @@ -23,4 +23,5 @@ spring: shared-configs: - data-id: common.yaml group: DEFAULT_GROUP + namespace: "" refresh: false diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 73fa22c..0f1304e 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,28 +1,35 @@ + + + + + - - INFO - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} - - - /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 - - + + + + /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 From 7ff8325999a15bcaa17cdb2331e6eb9f0f7b0ad1 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 16:44:20 +0800 Subject: [PATCH 03/58] chore(ci): add Gitea Actions workflow for standalone UserBaseService repo --- .gitea/workflows/ci.yml | 132 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e434bab --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,132 @@ +name: ci-userbase-service + +# 后端标准化 CI:maven 构建 jar -> docker build -> 推 Nexus docker-hosted +# 触发:push 到 main(本仓库为 UserBaseService 独立仓,代码在根目录)/ 手动 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 挂入每个 job 容器, + # 切勿在此再声明 volumes: docker.sock(会与默认挂载重复,报 + # "Duplicate mount point" 导致容器创建失败、全部 step cancelled)。 + container: + image: docker:24 + steps: + # docker:24 基于 alpine,默认无 git;先装 git 再手动 clone 本仓库 + - 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 会绑成空目录导致找不到 pom.xml。 + # 故改为:起 maven 容器 -> docker cp 源码入容器 -> exec 构建 -> cp 产物回工作区。 + # mavenbuild 加入 infra-infra-net,用容器名 nexus 直连 Nexus(8081 maven-public)。 + # 之前用 --add-host=nexus.yunhaixingtong.com:host-gateway 经 Caddy:80 反代,但该路径在 + # CI 容器内不可达,maven 回退到 /opt/docker/ubs-m2 里 08-05 的旧 yh-auth 缓存(缺 + # CasSsoAutoConfiguration),镜像启动报 serverName 崩溃。现改用 settings.xml profile 把 + # pom 的 HTTP 仓库重定向到 http://nexus:8081/repository/maven-public/(已验证 -7 可达)。 + # 先清理上次失败残留的同名容器(set -e 下末尾的 rm 不会执行) + 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 内容器名直连,已验证 -7 可达)。 + # 不用 mirror(external:http:*) 是因为它对 BOM import 解析未生效;profile 方式更可靠。 + # pom 原有的 nexus.yunhaixingtong.com(Caddy:80) 在 CI 容器内不可解析 -> 快速失败,maven 转而用本仓库。 + 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,避免本地安装优先于远端 -7); + # BOM 等其它 yh.platform 模块保留缓存(Nexus 上它们仍是 08-05 的 -1,缓存与远端一致)。 + 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 上,复用宿主 docker daemon(docker.sock 由 act_runner 自动挂载), + # 因此 docker pull / run 直接作用于 160 宿主,等价于在 160 上执行部署。 + - 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" + # 回滚锚:保留当前 userbase-service:latest(已知良好镜像),再额外打一个时间点标签 + 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, + # 用 NACOS_NAMESPACE 这个 env 名不会被 bootstrap 的 ${NACOS_NAMESPACE:dev} 解析到(会落 public); + # 凭据必须放顶层 SPRING_CLOUD_NACOS_USERNAME/PASSWORD, 放在 spring.cloud.nacos.config.* 下不生效(403)。 + 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" + # 健康检查:Spring 起来后 /userbase/ 返回 404(根路径无映射控制器),000 表示未起来 + 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 From 9d436c1ac1df498582563e56b3f4a3befc91d121 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 16:59:28 +0800 Subject: [PATCH 04/58] =?UTF-8?q?=E6=B5=8B=E8=AF=95ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/logback-spring.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 0f1304e..20612de 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -3,7 +3,7 @@ - + ${CONSOLE_LOG_PATTERN} From 2190e7d58f04b411ddc5a8d9160deb260e82b1a9 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 17:24:52 +0800 Subject: [PATCH 05/58] fix(ci): add --add-host sso.yunhaixingtong.com so CAS ticket validation can resolve inside container --- .gitea/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e434bab..90b1eef 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -109,6 +109,7 @@ jobs: # 用 NACOS_NAMESPACE 这个 env 名不会被 bootstrap 的 ${NACOS_NAMESPACE:dev} 解析到(会落 public); # 凭据必须放顶层 SPRING_CLOUD_NACOS_USERNAME/PASSWORD, 放在 spring.cloud.nacos.config.* 下不生效(403)。 docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \ + --add-host sso.yunhaixingtong.com:192.168.10.160 \ -e NACOS_ADDR=nacos:8848 \ -e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \ -e SPRING_CLOUD_NACOS_USERNAME=yh-reader \ From aa4ae32ac5ab2ff987b9291c3321e1f5584ec2cd Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 18:53:04 +0800 Subject: [PATCH 06/58] =?UTF-8?q?8=E6=9C=8810=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/userbase.nacos.yaml | 6 - docker/application-prod.yml | 6 - .../userbase/config/AppSecretConfig.java | 10 +- .../UserBaseLocalAppIdentityProvider.java | 63 +++++++++ .../controller/oapi/OpenBaseController.java | 91 +++++++++++- .../repository/ApplicationInfoRepository.java | 9 +- .../service/impl/ApplicationInfoService.java | 1 + src/main/resources/application.yml | 129 +++++++++--------- .../resources/sql/application_bootstrap.sql | 37 +++++ 9 files changed, 267 insertions(+), 85 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java create mode 100644 src/main/resources/sql/application_bootstrap.sql diff --git a/deploy/userbase.nacos.yaml b/deploy/userbase.nacos.yaml index 41698d5..f217d6b 100644 --- a/deploy/userbase.nacos.yaml +++ b/deploy/userbase.nacos.yaml @@ -83,12 +83,6 @@ yh: 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 diff --git a/docker/application-prod.yml b/docker/application-prod.yml index 6252aed..b88f43f 100644 --- a/docker/application-prod.yml +++ b/docker/application-prod.yml @@ -69,12 +69,6 @@ yh: 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 diff --git a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java index 738087f..08dcd00 100644 --- a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java +++ b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java @@ -2,6 +2,7 @@ package com.yunhai.userbase.config; import com.yunhai.platform.appauth.AppAuthProperties; import com.yunhai.platform.appauth.AppSecretProvider; +import com.yunhai.platform.appauth.LocalAppIdentityProvider; import com.yunhai.userbase.repository.ApplicationInfoRepository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -10,7 +11,9 @@ import org.springframework.context.annotation.Configuration; * 用户中心验签密钥源:UserBase 作为密钥权威源,优先从本库 application_info 解析调用方密钥 * (覆盖全部「已颁发」应用),并以配置式 {@code yh.app-auth.trustedApps} 兜底平台自身默认密钥。 * 显式覆盖 yh-app-auth 默认的 {@code @ConditionalOnMissingBean} 实现,使开放接口签名校验 - * 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。 + * 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。

+ * 同时注册 UserBase 自身作为出站调用方的身份:固定 appKey=100000,密钥落库,不再依赖 yml + * 配置 app-key/app-secret。 */ @Configuration public class AppSecretConfig { @@ -20,4 +23,9 @@ public class AppSecretConfig { AppAuthProperties properties) { return new UserBaseAppSecretProvider(repository, properties); } + + @Bean + public LocalAppIdentityProvider localAppIdentityProvider(ApplicationInfoRepository repository) { + return new UserBaseLocalAppIdentityProvider(repository); + } } diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java new file mode 100644 index 0000000..b027951 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java @@ -0,0 +1,63 @@ +package com.yunhai.userbase.config; + +import com.yunhai.platform.appauth.LocalAppIdentityProvider; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.repository.ApplicationInfoRepository; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * UserBase 自身作为「出站签名调用方」的身份提供方。

+ * 约定:
+ * 1) appKey 固定占用 100000(基石应用段),由 application_bootstrap.sql 初始化脚本幂等插入;
+ * 2) appSecret 从 application_info 表按 appKey=100000 读取,带 60s 本地缓存(密钥重设后 60s 内生效)。

+ * 显式覆盖 yh-app-auth-spring-boot-starter 默认的 PropertyLocalAppIdentityProvider(yml 不再需要 + * 配置 yh.app-auth.app-key / app-secret)。 + */ +public class UserBaseLocalAppIdentityProvider implements LocalAppIdentityProvider { + + private static final String USER_CENTER_APP_KEY = "100000"; + private static final int USER_CENTER_APP_KEY_INT = 100000; + private static final long TTL_MS = 60_000L; + + private final ApplicationInfoRepository repository; + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + public UserBaseLocalAppIdentityProvider(ApplicationInfoRepository repository) { + this.repository = repository; + } + + @Override + public String getAppKey() { + return USER_CENTER_APP_KEY; + } + + @Override + public String getAppSecret() { + long now = System.currentTimeMillis(); + CacheEntry hit = cache.get(USER_CENTER_APP_KEY); + if (hit != null && now - hit.ts < TTL_MS) { + return hit.secret; + } + String secret = null; + try { + ApplicationInfo info = repository.findByAppKey(USER_CENTER_APP_KEY_INT); + if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) { + secret = info.getAppSecret(); + } + } catch (Exception ignored) { + } + cache.put(USER_CENTER_APP_KEY, new CacheEntry(now, secret)); + return secret; + } + + 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/controller/oapi/OpenBaseController.java b/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java index bb187c9..4abe136 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java @@ -1,17 +1,29 @@ package com.yunhai.userbase.controller.oapi; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; +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.service.IGroupAuthzService; import com.yunhai.userbase.service.UserCenterService; +import org.springframework.beans.BeanUtils; 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; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + /** * 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /open/api/user/...})。

* 路径与基础平台 {@code DefaultUserCenterClient} 硬编码的契约严格一致: - * {@code GET /open/api/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}。
+ * {@code GET /open/api/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}、 + * {@code /open/api/user/modules?userId=&schId=}。
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求, * 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。 */ @@ -22,6 +34,9 @@ public class OpenBaseController { @Autowired private UserCenterService userCenterService; + @Autowired + private IGroupAuthzService groupAuthzService; + @GetMapping("byId") public BoaUser byId(@RequestParam("id") Integer id) { return userCenterService.getUserInfoWithDeptById(id); @@ -42,4 +57,78 @@ public class OpenBaseController { } return userCenterService.getUserInfoWithDeptByPhone(phone); } + + /** + * 查询用户在指定学校下的已授权模块列表。
+ * 若 {@code schId} 为 null 时,自动取用户的主学校;若仍无主学校信息,退化为取用户主归属组(groups 第 0 个)。 + * + * @param userId 用户ID(必填) + * @param schId 学校ID/组ID(可选;为 null 时按上述规则推断) + * @return 扁平化的已授权模块列表(含基础平台统一 BoaModule 模型);无权限时返回空列表 + */ + @GetMapping("modules") + public List modules(@RequestParam("userId") Integer userId, + @RequestParam(value = "schId", required = false) Integer schId) { + if (userId == null) { + return Collections.emptyList(); + } + Integer effectiveGroupId = resolveEffectiveGroupId(userId, schId); + if (effectiveGroupId == null) { + return Collections.emptyList(); + } + List tree = groupAuthzService.findUserPermissionTree(effectiveGroupId, userId); + if (tree == null || tree.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (UserPermissionCategoryVo category : tree) { + if (category.getApps() == null) continue; + for (UserPermissionAppVo appVo : category.getApps()) { + String icon = appVo.getPcIcon(); + collectModules(appVo.getModules(), icon, result); + } + } + return result; + } + + /** + * 递归收集已授权模块树到扁平列表,并把应用 PC 图标作为模块的 icon 兜底(BoaModule.icon)。 + */ + private void collectModules(List modules, + String appIcon, + List collector) { + if (modules == null || modules.isEmpty()) { + return; + } + for (UserPermissionModuleVo m : modules) { + BoaModule bm = new BoaModule(); + BeanUtils.copyProperties(m, bm); + bm.setIcon(appIcon); + collector.add(bm); + collectModules(m.getChildren(), appIcon, collector); + } + } + + /** + * 按「schId 为空时」解析出用户主学校/主组作为模块查询的上下文组ID。 + */ + private Integer resolveEffectiveGroupId(Integer userId, Integer schId) { + if (schId != null) { + return schId; + } + BoaUser boa = userCenterService.getUserInfoWithDeptById(userId); + if (boa == null) { + return null; + } + BoaGroup school = boa.getSchool(); + if (school != null && school.getId() != null) { + return school.getId(); + } + List groups = boa.getGroups(); + if (groups != null && !groups.isEmpty()) { + BoaGroup main = groups.get(0); + return main == null ? null : main.getId(); + } + return null; + } } diff --git a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java index c1cef04..8bde07a 100644 --- a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java @@ -17,7 +17,8 @@ import java.util.List; * @description */ public interface ApplicationInfoRepository extends JpaRepository, JpaSpecificationExecutor { - Integer countByCategoryId(Integer id); + @Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000") + Integer countByCategoryId(@Param("categoryId") Integer id); boolean existsByName(String name); @@ -54,21 +55,23 @@ public interface ApplicationInfoRepository extends JpaRepository * 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。
+ * 排除用户中心自身的基石应用(appKey=100000)。 * * @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") + "FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 ORDER BY a.sortNo ASC, a.id ASC") List findSortVoByCategoryId(@Param("categoryId") Integer categoryId); /** * 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。
+ * 排除用户中心自身的基石应用(appKey=100000)。 * * @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") + "FROM ApplicationInfo a WHERE a.appKey > 100000 ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC") List findAllAuthzRows(); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index 807a483..51ad550 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -191,6 +191,7 @@ public class ApplicationInfoService implements IApplicationInfoService { @Override public Predicate toPredicate(Root root, CriteriaQuery cq, CriteriaBuilder cb) { List predicates = new ArrayList(); + predicates.add(cb.greaterThan(root.get("appKey"), 100000)); if (query != null) { if (StringUtils.isNotEmpty(query.getName())) { String keyword = query.getName(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef71b75..f56c5c1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,80 +1,73 @@ server: - port: 8060 + 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: + 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: - repositories: - enabled: false - http: - encoding: - force: true - charset: utf-8 - enabled: true + 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 + 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 + 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 ADMIN_TAG_ID: 290 diff --git a/src/main/resources/sql/application_bootstrap.sql b/src/main/resources/sql/application_bootstrap.sql new file mode 100644 index 0000000..c85223c --- /dev/null +++ b/src/main/resources/sql/application_bootstrap.sql @@ -0,0 +1,37 @@ +-- ============================================================ +-- 用户中心自身应用身份初始化(appKey=100000,后续业务应用从 100001 起) +-- ------------------------------------------------------------ +-- 适用数据库:MySQL 5.7 / 8.0 +-- 运行方式: +-- mysql --default-character-set=utf8mb4 -u -p < application_bootstrap.sql +-- 前置:表结构由 Hibernate(ddl-auto=update)创建,请先启动一次应用生成 +-- application_info 表后再执行本脚本;如已执行请忽略(幂等)。 +-- 说明: +-- 1. 本脚本按 app_key=100000 做幂等判断,重复执行不会新增重复记录。 +-- 2. 用户中心作为「应用平台基石/默认调用方」,自身也需要一个合法的应用身份, +-- 用于对外发起 yh-app-auth 签名的应用签名请求;因此固定占用 appKey=100000。 +-- 3. appSecret 每次执行脚本若不存在时会重新生成一个 UUID(32 位十六进制); +-- 已存在记录不会被修改,生产上如需重置请手动 UPDATE 或在应用管理界面点「重设密钥」。 +-- 4. 业务应用新增时 ApplicationInfoService 会按 MAX(app_key)+1 分配: +-- 因此本脚本写入 100000 后,下一条业务应用 appKey=100001,与预期一致。 +-- 5. AUTO_INCREMENT=100001 仅设置 application_info 表的主键 id(非 appKey), +-- 使后续新建行的主键 id 也从 100001 起,与 appKey 段对齐,便于排查。 +-- ============================================================ + +SET NAMES utf8mb4; + +-- 1) 插入用户中心自身身份(appKey=100000),已存在则跳过 +INSERT INTO application_info (app_key, name, app_secret, app_desc, create_user, create_time, update_user, update_time) +SELECT 100000, + '用户中心平台', + REPLACE(UUID(), '-', ''), + '用户中心自身应用身份(基石应用,不对外展示于应用管理列表)', + 0, + NOW(), + 0, + NOW() +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM application_info WHERE app_key = 100000); + +-- 2) 调整主键 id 自增起点,使其与 appKey 段对齐(100001 起) +ALTER TABLE application_info AUTO_INCREMENT = 100001; From 38029801fbf553083dcac5b1ddcbf44c81cf3c38 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 21:32:28 +0800 Subject: [PATCH 07/58] ci --- .../java/com/yunhai/userbase/controller/api/BaseController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index e2f7107..45f78e0 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -38,7 +38,7 @@ public class BaseController { return authUserWithDept; } - //返回系统枚举 + //返回系统枚举1 @GetMapping("GetSysDict") public Map getSysDict(BoaUser authUserWithDept){ Map resultMap = new HashMap<>(7); From b226a567b85f0c236efb5623a572f1ad951644cf Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 21:44:03 +0800 Subject: [PATCH 08/58] ci --- .../java/com/yunhai/userbase/controller/api/BaseController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index 45f78e0..0136a66 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -38,7 +38,7 @@ public class BaseController { return authUserWithDept; } - //返回系统枚举1 + //返回系统枚举2 @GetMapping("GetSysDict") public Map getSysDict(BoaUser authUserWithDept){ Map resultMap = new HashMap<>(7); From a0aa1cfb353e0647dc0178f9829d44d3ed9e4bfd Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 10 Aug 2026 22:12:35 +0800 Subject: [PATCH 09/58] ci --- .../java/com/yunhai/userbase/controller/api/BaseController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index 0136a66..d9807e5 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -38,7 +38,7 @@ public class BaseController { return authUserWithDept; } - //返回系统枚举2 + //返回系统枚举3 @GetMapping("GetSysDict") public Map getSysDict(BoaUser authUserWithDept){ Map resultMap = new HashMap<>(7); From ed07f380da53dbbd617b212295f58dedc4cd5c65 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 11:52:10 +0800 Subject: [PATCH 10/58] =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...aseController.java => UserController.java} | 24 ++++--- .../repository/SysGroupAppRelRepository.java | 2 + .../service/impl/GroupAuthzService.java | 65 ++++++++++--------- 3 files changed, 49 insertions(+), 42 deletions(-) rename src/main/java/com/yunhai/userbase/controller/oapi/{OpenBaseController.java => UserController.java} (87%) diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java similarity index 87% rename from src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java rename to src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index 4abe136..de6438d 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/OpenBaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -3,6 +3,7 @@ package com.yunhai.userbase.controller.oapi; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.config.Config; import com.yunhai.userbase.model.groupauthz.UserPermissionAppVo; import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo; import com.yunhai.userbase.model.groupauthz.UserPermissionModuleVo; @@ -20,16 +21,18 @@ import java.util.Collections; import java.util.List; /** - * 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /open/api/user/...})。

- * 路径与基础平台 {@code DefaultUserCenterClient} 硬编码的契约严格一致: - * {@code GET /open/api/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}、 - * {@code /open/api/user/modules?userId=&schId=}。
+ * 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/user/...})。
+ *
+ * 路径复用 {@link Config#OPEN_API_PATH} 统一前缀,与基础平台 {@code DefaultUserCenterClient} + * 硬编码的契约严格一致: + * {@code GET /oapi/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}、 + * {@code /oapi/user/modules?userId=&schId=}。
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求, * 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。 */ @RestController -@RequestMapping("/open/api/user/") -public class OpenBaseController { +@RequestMapping(Config.OPEN_API_PATH + "user/") +public class UserController { @Autowired private UserCenterService userCenterService; @@ -68,7 +71,7 @@ public class OpenBaseController { */ @GetMapping("modules") public List modules(@RequestParam("userId") Integer userId, - @RequestParam(value = "schId", required = false) Integer schId) { + @RequestParam(value = "schId", required = false) Integer schId) { if (userId == null) { return Collections.emptyList(); } @@ -82,7 +85,8 @@ public class OpenBaseController { } List result = new ArrayList<>(); for (UserPermissionCategoryVo category : tree) { - if (category.getApps() == null) continue; + if (category.getApps() == null) + continue; for (UserPermissionAppVo appVo : category.getApps()) { String icon = appVo.getPcIcon(); collectModules(appVo.getModules(), icon, result); @@ -95,8 +99,8 @@ public class OpenBaseController { * 递归收集已授权模块树到扁平列表,并把应用 PC 图标作为模块的 icon 兜底(BoaModule.icon)。 */ private void collectModules(List modules, - String appIcon, - List collector) { + String appIcon, + List collector) { if (modules == null || modules.isEmpty()) { return; } diff --git a/src/main/java/com/yunhai/userbase/repository/SysGroupAppRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysGroupAppRelRepository.java index d6bff29..8ec1200 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysGroupAppRelRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysGroupAppRelRepository.java @@ -18,6 +18,8 @@ public interface SysGroupAppRelRepository extends JpaRepository findByGroupId(Integer groupId); + List findByGroupIdIn(List groupIds); + boolean existsByGroupIdAndAppId(Integer groupId, Integer appId); long countByAppId(Integer appId); diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 0a27eac..5196c6b 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -97,6 +97,29 @@ public class GroupAuthzService implements IGroupAuthzService { @Autowired private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + // ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId) ===================== + + /** + * 按用户需求:父组(学校组)若开通了应用(GetSchoolAppId返回非空),则其所有子孙组的模块权限分配面板也能看到这些应用(无需子组重复开通)。
+ * 取应用范围=sys_group_app_rel里group_id属于「本组+全部祖先组」的app_id并集;开通不继承,模块权限池继承。
+ * 对 hasModulePermission / UserPermissionTree 不做修改,避免越权(最终用户拿到权限仍需要自己的归属组开通应用+继承到模块权限,双保险)。 + */ + private Set collectAppIdsForAuthzScope(Integer groupId) { + Map allGroupMap = buildAllGroupMap(); + List chain = ancestorChain(groupId, allGroupMap); + if (chain == null || chain.isEmpty()) { + return Collections.emptySet(); + } + List rels = sysGroupAppRelRepository.findByGroupIdIn(chain); + Set appIds = new LinkedHashSet<>(); + for (SysGroupAppRel rel : rels) { + if (rel.getAppId() != null) { + appIds.add(rel.getAppId()); + } + } + return appIds; + } + // ===================== 功能1:应用开通 ===================== @Override @@ -105,7 +128,6 @@ public class GroupAuthzService implements IGroupAuthzService { if (!sysGroupRepository.existsById(groupId)) { throw new DException("系统组不存在"); } - // 系统全量应用 + 该组已开通集合,标记 checked 回显 List apps = applicationInfoRepository.findAll(); apps.sort(appOrder()); Set openedAppIds = new HashSet<>(); @@ -177,29 +199,22 @@ public class GroupAuthzService implements IGroupAuthzService { if (!sysGroupRepository.existsById(groupId)) { throw new DException("系统组不存在"); } - // 1. 该组已开通的应用;无记录则右侧不显示任何应用 - List rels = sysGroupAppRelRepository.findByGroupId(groupId); - if (rels.isEmpty()) { + Set appIdSet = collectAppIdsForAuthzScope(groupId); + if (appIdSet.isEmpty()) { return Collections.emptyList(); } - List appIds = new ArrayList<>(); - for (SysGroupAppRel rel : rels) { - appIds.add(rel.getAppId()); - } + List appIds = new ArrayList<>(appIdSet); 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(); @@ -290,15 +305,10 @@ public class GroupAuthzService implements IGroupAuthzService { } List perms = request.getPerms() == null ? Collections.emptyList() : request.getPerms(); - // 勾选的模块必须属于该组已开通应用(边界情况:未开通应用的模块权限无效,不允许写入) + Set appIdSet = collectAppIdsForAuthzScope(groupId); 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)) { + if (!appIdSet.isEmpty()) { + for (AppModule m : appModuleRepository.findByAppIdIn(new ArrayList<>(appIdSet))) { validModuleIds.add(m.getId()); } } @@ -418,31 +428,22 @@ public class GroupAuthzService implements IGroupAuthzService { 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 的上行写法)。 + Set scopeAppIds = collectAppIdsForAuthzScope(groupId); for (Integer mid : targetModuleIds) { if (modeEnum == SysModeEnum.APPEND) { - // 边界一致:该组须已开通该应用,未开通应用的模块权限无意义 - if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(groupId, appId)) { - throw new DException("组 " + groupId + " 未开通应用 " + appId + ",无法授权其模块 " + mid); + if (!scopeAppIds.contains(appId)) { + throw new DException("组及其祖先均未开通应用 " + appId + ",无法授权其模块 " + mid); } SysGroupModulePerm existing = sysGroupModulePermRepository.findByGroupIdAndModuleId(groupId, mid); if (existing != null) { @@ -457,7 +458,7 @@ public class GroupAuthzService implements IGroupAuthzService { perm.setInheritFlag(isAll); sysGroupModulePermRepository.save(perm); } - } else { // DELETE 取消模块授权 + } else { sysGroupModulePermRepository.deleteByGroupIdAndModuleIdBulk(groupId, mid); } } From a8c0793a92566cb8631f668fa7772ba430425f22 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 12:20:47 +0800 Subject: [PATCH 11/58] =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userbase/service/impl/GroupAuthzService.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 5196c6b..d6a1827 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -611,17 +611,14 @@ public class GroupAuthzService implements IGroupAuthzService { allChainIds.addAll(chain); } - // 3. 收集这些组已开通的应用 + // 3. 收集「用户归属组 + 其祖先链」所有组已开通的应用并集。 + // 安全:hasUserModulePermission 仍会判定 groupId(当前归属组)必须在 openedApps 链上(即归属组或其祖先开通应用), + // 不会出现“无组归属、父级未开”的越权;此处只是把过滤和写面板(GroupModuleTreeByCategory)联动对齐, + // 解决之前“管理员在组8面板勾了checked=true,但UserPermissionTree返回空”的对称bug。 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()); - } - } + Set apps = collectAppIdsForAuthzScope(groupIdItem); groupToApps.put(groupIdItem, apps); allAppIds.addAll(apps); } From 6cfc820ce82eadc0de58ebea0ccab6e1488ce674 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 15:59:05 +0800 Subject: [PATCH 12/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9ci=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 19 +++++++++--- src/main/resources/bootstrap.yml | 48 +++++++++++++++++++++--------- src/main/resources/bschconfig.jks | Bin 2267 -> 0 bytes 3 files changed, 49 insertions(+), 18 deletions(-) delete mode 100644 src/main/resources/bschconfig.jks diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 90b1eef..d244d4f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -105,11 +105,13 @@ jobs: 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, - # 用 NACOS_NAMESPACE 这个 env 名不会被 bootstrap 的 ${NACOS_NAMESPACE:dev} 解析到(会落 public); - # 凭据必须放顶层 SPRING_CLOUD_NACOS_USERNAME/PASSWORD, 放在 spring.cloud.nacos.config.* 下不生效(403)。 + # bootstrap.yml 默认 nacos.config.enabled=false(本地不连);CI 必须注入 NACOS_CONFIG_ENABLED=true 才会拉配置 + # SPRING_PROFILES_ACTIVE=prod 激活生产 profile(留给将来拆 application-prod.yml 用) + # namespace/凭据仍用顶层 SPRING_CLOUD_NACOS_* env(bootstrap 的 ${...} 占位符才能解析到) docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \ --add-host sso.yunhaixingtong.com:192.168.10.160 \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e NACOS_CONFIG_ENABLED=true \ -e NACOS_ADDR=nacos:8848 \ -e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \ -e SPRING_CLOUD_NACOS_USERNAME=yh-reader \ @@ -125,7 +127,16 @@ jobs: 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 + # 回滚也必须注入同样的 env,否则 bootstrap 默认 enabled=false 会连不上 nacos 拉不到线上配置 + docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \ + --add-host sso.yunhaixingtong.com:192.168.10.160 \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e NACOS_CONFIG_ENABLED=true \ + -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 \ + userbase-service:latest echo "rolled back"; exit 7 fi echo "DEPLOY OK: $IMG" diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml index 257ebb5..d5721ed 100644 --- a/src/main/resources/bootstrap.yml +++ b/src/main/resources/bootstrap.yml @@ -1,27 +1,47 @@ spring: application: name: userbase + # 本地默认 local(不连 nacos);CI 注入 SPRING_PROFILES_ACTIVE=prod + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} cloud: nacos: config: - # Nacos 配置中心地址;生产也可通过环境变量 NACOS_ADDR 覆盖(例如内网别名 nacos:8848) + # ⭐ 本地默认关闭 nacos(profile=local 时完全不连,无 403 噪音,纯走 application.yml) + # CI 注入 NACOS_CONFIG_ENABLED=true 才启用,拉 prod namespace 配置覆盖 application.yml + enabled: ${NACOS_CONFIG_ENABLED:false} + # Nacos 配置中心地址;CI 注入 NACOS_ADDR=nacos:8848(容器内网别名) server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848} - # public 命名空间:Nacos 中 public 的 namespace id 固定为空字符串 - namespace: ${NACOS_NAMESPACE:ce902c5c-b568-4465-b058-a98e1e2992e7} - # 应用自身配置所在分组 - group: USERBASE - # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 userbase.yaml + # 默认指向 prod namespace UUID,避免本地误开时连到空的 dev 命名空间 + # CI 用 SPRING_CLOUD_NACOS_CONFIG_NAMESPACE 覆盖(注意:NACOS_NAMESPACE 这个 env 名不生效,必须用 SPRING_CLOUD_NACOS_CONFIG_NAMESPACE) + namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc} + # 主配置 group:userbase.yaml / application-infra.yaml 同组 + group: DEFAULT_GROUP + # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension} = userbase.yaml file-extension: yaml encode: UTF-8 - # nacos 不可达时不强制退出,回退到本地 application.yml / application-prod.yml 兜底 + # nacos 不可达时不强制退出,回退到本地 application.yml 兜底 fail-fast: false - # 只读账号(仅 public 命名空间读权限),用于拉取配置;非管理员账号,可安全提交 - username: yh-reader - password: 123456 - # 共享公共配置:public 空间下的 common.yaml(所有服务公共项,如公共数据源 / Redis 等) - # 优先级低于本应用配置(userbase.yaml),即应用配置可覆盖公共配置中的同名字段 + # 只读账号(仅读取配置权限);CI 注入 SPRING_CLOUD_NACOS_USERNAME/PASSWORD + # 注意:凭据必须用顶层 SPRING_CLOUD_NACOS_* env,放在 spring.cloud.nacos.config.* 下不生效(403) + username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader} + password: ${SPRING_CLOUD_NACOS_PASSWORD:123456} + + # ============================================================ + # ⭐⭐⭐ 共享配置加载顺序(决定优先级!:排在数组越靠后 = 优先级越高 = 覆盖前面的同 key) + # 低 1) public 命名空间 application-default.yaml(跨环境公共:日期格式/上传大小等,所有应用都能继承) + # 中 2) prod 命名空间 application-infra.yaml(仅生产:mysql/redis/rabbitmq 中间件连接) + # 高 3) prod 命名空间 userbase.yaml(应用级主配置:上传路径/context-path/CAS等,覆盖前两个) + # 最高 4) 启动命令 -D 参数 / 环境变量(覆盖一切) + # ============================================================ shared-configs: - - data-id: common.yaml + # ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml + - data-id: application-default.yaml group: DEFAULT_GROUP - namespace: "" + namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间,不读上面的全局 prod namespace refresh: false + + # ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 prod namespace id)的 application-infra.yaml + - data-id: application-infra.yaml + group: DEFAULT_GROUP + refresh: false \ No newline at end of file diff --git a/src/main/resources/bschconfig.jks b/src/main/resources/bschconfig.jks deleted file mode 100644 index c05e280a079845647158f445e1d30a743c09081b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 From 1bfff41c10f0a49f2be8af07789d4838e00ce803 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 16:09:25 +0800 Subject: [PATCH 13/58] ci --- .../java/com/yunhai/userbase/controller/api/BaseController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index d9807e5..e2f7107 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -38,7 +38,7 @@ public class BaseController { return authUserWithDept; } - //返回系统枚举3 + //返回系统枚举 @GetMapping("GetSysDict") public Map getSysDict(BoaUser authUserWithDept){ Map resultMap = new HashMap<>(7); From 48173ba60e01cabc870271ebba74cceeeb51872f Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 16:33:34 +0800 Subject: [PATCH 14/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9ci=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 | 98 ------------------- deploy/verify_db.sh | 10 -- deploy/verify_final.sh | 19 ---- docker/application-prod.yml | 12 +-- .../java/com/bestsch/openapi/UserSysRole.java | 37 ------- .../java/com/bestsch/utils/DException.java | 22 ----- .../controller/api/AppSettingsController.java | 18 ---- .../com/yunhai/userbase/model/Operator.java | 29 ------ src/main/resources/bootstrap.yml | 4 +- 29 files changed, 7 insertions(+), 722 deletions(-) delete mode 100644 deploy/.env.example delete mode 100644 deploy/_debug_run.sh delete mode 100644 deploy/_ps_chk.txt delete mode 100644 deploy/_verify_remote.sh delete mode 100644 deploy/build-push.sh delete mode 100644 deploy/check_type_col.sh delete mode 100644 deploy/deploy.sh delete mode 100644 deploy/diag_caddy.sh delete mode 100644 deploy/diag_deep.sh delete mode 100644 deploy/docker-compose.userbase.yml delete mode 100644 deploy/find_db.sh delete mode 100644 deploy/fix_caddy.sh delete mode 100644 deploy/fix_restart.sh delete mode 100644 deploy/fix_wildcard.sh delete mode 100644 deploy/probe_nacos.sh delete mode 100644 deploy/probe_net.sh delete mode 100644 deploy/redeploy_app.sh delete mode 100644 deploy/remote_run.py delete mode 100644 deploy/smoke.sh delete mode 100644 deploy/upload.py delete mode 100644 deploy/userbase.nacos.yaml delete mode 100644 deploy/verify_db.sh delete mode 100644 deploy/verify_final.sh delete mode 100644 src/main/java/com/bestsch/openapi/UserSysRole.java delete mode 100644 src/main/java/com/bestsch/utils/DException.java delete mode 100644 src/main/java/com/yunhai/userbase/model/Operator.java diff --git a/deploy/.env.example b/deploy/.env.example deleted file mode 100644 index 6e7d395..0000000 --- a/deploy/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# 本地构建推送 / 远端部署 用的环境变量示例 -# 用法:复制为 .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 deleted file mode 100644 index 4f207bb..0000000 --- a/deploy/_debug_run.sh +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 8500e20..0000000 --- a/deploy/_ps_chk.txt +++ /dev/null @@ -1 +0,0 @@ -EOF diff --git a/deploy/_verify_remote.sh b/deploy/_verify_remote.sh deleted file mode 100644 index dca455f..0000000 --- a/deploy/_verify_remote.sh +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index ace2b66..0000000 --- a/deploy/build-push.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/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 deleted file mode 100644 index dd8664d..0000000 --- a/deploy/check_type_col.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/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 deleted file mode 100644 index d1dabd8..0000000 --- a/deploy/deploy.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/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 deleted file mode 100644 index bd3797e..0000000 --- a/deploy/diag_deep.sh +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index ba61db6..0000000 --- a/deploy/docker-compose.userbase.yml +++ /dev/null @@ -1,74 +0,0 @@ -# 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 deleted file mode 100644 index 073e482..0000000 --- a/deploy/find_db.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/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 deleted file mode 100644 index 2ea602c..0000000 --- a/deploy/fix_caddy.sh +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index d4b8568..0000000 --- a/deploy/fix_restart.sh +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index b099adc..0000000 --- a/deploy/fix_wildcard.sh +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 8152fa9..0000000 --- a/deploy/probe_nacos.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/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 deleted file mode 100644 index 6e3bfcd..0000000 --- a/deploy/probe_net.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/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 deleted file mode 100644 index e0d5944..0000000 --- a/deploy/redeploy_app.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/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 deleted file mode 100644 index b6e254e..0000000 --- a/deploy/remote_run.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/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 deleted file mode 100644 index a911205..0000000 --- a/deploy/smoke.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/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 deleted file mode 100644 index 80c0703..0000000 --- a/deploy/upload.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/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 deleted file mode 100644 index f217d6b..0000000 --- a/deploy/userbase.nacos.yaml +++ /dev/null @@ -1,98 +0,0 @@ -# ============================================================ -# 请在 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 - -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 deleted file mode 100644 index 89711fa..0000000 --- a/deploy/verify_db.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/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 deleted file mode 100644 index 9d8bd40..0000000 --- a/deploy/verify_final.sh +++ /dev/null @@ -1,19 +0,0 @@ -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/application-prod.yml b/docker/application-prod.yml index b88f43f..e1caeec 100644 --- a/docker/application-prod.yml +++ b/docker/application-prod.yml @@ -2,7 +2,7 @@ server: port: 8060 servlet: - # 对外经 caddy 暴露为 http://www.yunhaixingtong.com/userbase + # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/userbase context-path: /userbase spring: @@ -61,8 +61,8 @@ spring: # 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) yh: sso: - server-url-prefix: http://sso.yunhaixingtong.com - client-host-url: http://www.yunhaixingtong.com/userbase + server-url-prefix: http://sso.yunhaixingtong.com/cas/ + client-host-url: http://base.yunhaixingtong.com/userbase allow-headers: all allow-origins: all url-pattern: /api/* @@ -74,10 +74,8 @@ 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 +FILE_WEB_PATH: http://base.yunhaixingtong.com/userbase/upload/ +UPLOAD_WEB_PATH_HTTP: http://base.yunhaixingtong.com/userbase/upload/file/ logging: level: diff --git a/src/main/java/com/bestsch/openapi/UserSysRole.java b/src/main/java/com/bestsch/openapi/UserSysRole.java deleted file mode 100644 index ff983b2..0000000 --- a/src/main/java/com/bestsch/openapi/UserSysRole.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index a1ed5e9..0000000 --- a/src/main/java/com/bestsch/utils/DException.java +++ /dev/null @@ -1,22 +0,0 @@ -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/controller/api/AppSettingsController.java b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java index bf43b57..4681960 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java @@ -2,15 +2,11 @@ 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; @@ -26,27 +22,13 @@ 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; diff --git a/src/main/java/com/yunhai/userbase/model/Operator.java b/src/main/java/com/yunhai/userbase/model/Operator.java deleted file mode 100644 index 3f90288..0000000 --- a/src/main/java/com/yunhai/userbase/model/Operator.java +++ /dev/null @@ -1,29 +0,0 @@ -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/resources/bootstrap.yml b/src/main/resources/bootstrap.yml index d5721ed..c1fd007 100644 --- a/src/main/resources/bootstrap.yml +++ b/src/main/resources/bootstrap.yml @@ -15,8 +15,8 @@ spring: # 默认指向 prod namespace UUID,避免本地误开时连到空的 dev 命名空间 # CI 用 SPRING_CLOUD_NACOS_CONFIG_NAMESPACE 覆盖(注意:NACOS_NAMESPACE 这个 env 名不生效,必须用 SPRING_CLOUD_NACOS_CONFIG_NAMESPACE) namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc} - # 主配置 group:userbase.yaml / application-infra.yaml 同组 - group: DEFAULT_GROUP + # 主配置 group:userbase.yaml 在 USERBASE group(与下方 shared-configs 的 DEFAULT_GROUP 不同组,各自指定) + group: USERBASE # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension} = userbase.yaml file-extension: yaml encode: UTF-8 From 0bd99f9aa42f0396263c093b6af8a574db24a2e8 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 16:46:11 +0800 Subject: [PATCH 15/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yunhai/userbase/common/listener/UserAuditListener.java | 2 +- .../yunhai/userbase/controller/api/AppSettingsController.java | 1 - .../com/yunhai/userbase/controller/api/BaseController.java | 2 +- .../com/yunhai/userbase/service/impl/AppModuleService.java | 2 +- .../java/com/yunhai/userbase/service/impl/AppRoleService.java | 2 +- .../yunhai/userbase/service/impl/ApplicationInfoService.java | 2 +- .../com/yunhai/userbase/service/impl/GroupAuthzService.java | 2 +- .../java/com/yunhai/userbase/service/impl/SysGroupService.java | 2 +- .../java/com/yunhai/userbase/service/impl/SysUserService.java | 2 +- src/main/java/com/yunhai/userbase/utils/FileUtil.java | 3 ++- src/main/java/com/yunhai/userbase/utils/HttpUtils.java | 3 +-- src/main/java/com/yunhai/userbase/utils/OtherUtil.java | 2 +- 12 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/common/listener/UserAuditListener.java b/src/main/java/com/yunhai/userbase/common/listener/UserAuditListener.java index 8557f33..9de06bc 100644 --- a/src/main/java/com/yunhai/userbase/common/listener/UserAuditListener.java +++ b/src/main/java/com/yunhai/userbase/common/listener/UserAuditListener.java @@ -1,6 +1,6 @@ package com.yunhai.userbase.common.listener; -import com.bestsch.utils.DException; +import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.common.context.UserContext; import com.yunhai.userbase.entity.BaseEntity; diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java index 4681960..2c57439 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java @@ -1,6 +1,5 @@ package com.yunhai.userbase.controller.api; -import com.bestsch.utils.DException; import com.yunhai.userbase.common.annotation.OperationLog; import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index e2f7107..abe4e94 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -4,7 +4,7 @@ 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 com.yunhai.platform.utils.exception.DException; import org.springframework.web.multipart.MultipartFile; import lombok.var; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java index 495d9a2..d3ebd9e 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java @@ -1,6 +1,6 @@ package com.yunhai.userbase.service.impl; -import com.bestsch.utils.DException; +import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.entity.AppModule; import com.yunhai.userbase.model.appmodule.AppModuleQuery; import com.yunhai.userbase.model.appmodule.AppModuleRequest; diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java index 6aad9a4..46cc59b 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java @@ -1,6 +1,6 @@ package com.yunhai.userbase.service.impl; -import com.bestsch.utils.DException; +import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.entity.AppModule; import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.SysAppRole; diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index 51ad550..0c6055d 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -12,7 +12,7 @@ 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 com.yunhai.platform.utils.exception.DException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CachePut; diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index d6a1827..f78fe7e 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -1,6 +1,6 @@ package com.yunhai.userbase.service.impl; -import com.bestsch.utils.DException; +import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.entity.*; import com.yunhai.userbase.enums.IEnum; import com.yunhai.userbase.enums.SysModeEnum; diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 9741db5..54bc3fb 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -1,7 +1,7 @@ package com.yunhai.userbase.service.impl; -import com.bestsch.utils.DException; import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.userbase.enums.IEnum; import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index e53838e..18a8221 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -1,6 +1,6 @@ package com.yunhai.userbase.service.impl; -import com.bestsch.utils.DException; +import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.enums.IEnum; import com.yunhai.userbase.enums.StatusEnum; import com.yunhai.userbase.enums.SysRoleEnum; diff --git a/src/main/java/com/yunhai/userbase/utils/FileUtil.java b/src/main/java/com/yunhai/userbase/utils/FileUtil.java index 151c089..4efe128 100644 --- a/src/main/java/com/yunhai/userbase/utils/FileUtil.java +++ b/src/main/java/com/yunhai/userbase/utils/FileUtil.java @@ -1,8 +1,9 @@ package com.yunhai.userbase.utils; + +import com.yunhai.platform.utils.exception.DException; 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; diff --git a/src/main/java/com/yunhai/userbase/utils/HttpUtils.java b/src/main/java/com/yunhai/userbase/utils/HttpUtils.java index 9841624..9985365 100644 --- a/src/main/java/com/yunhai/userbase/utils/HttpUtils.java +++ b/src/main/java/com/yunhai/userbase/utils/HttpUtils.java @@ -1,7 +1,6 @@ package com.yunhai.userbase.utils; -import com.bestsch.utils.DException; - +import com.yunhai.platform.utils.exception.DException; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; diff --git a/src/main/java/com/yunhai/userbase/utils/OtherUtil.java b/src/main/java/com/yunhai/userbase/utils/OtherUtil.java index 1e0faa0..f5b1598 100644 --- a/src/main/java/com/yunhai/userbase/utils/OtherUtil.java +++ b/src/main/java/com/yunhai/userbase/utils/OtherUtil.java @@ -4,7 +4,7 @@ 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 com.yunhai.platform.utils.exception.DException; import java.util.Arrays; import java.util.List; From c0be7895efa424e72f9747ec7baf4f1fcfc7b026 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 17:39:33 +0800 Subject: [PATCH 16/58] =?UTF-8?q?ci=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 6 ++++++ docker/application-prod.yml | 22 +++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d244d4f..795cf34 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -116,6 +116,9 @@ jobs: -e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \ -e SPRING_CLOUD_NACOS_USERNAME=yh-reader \ -e SPRING_CLOUD_NACOS_PASSWORD=123456 \ + -e REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} \ + -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \ + -e RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} \ "$IMG" # 健康检查:Spring 起来后 /userbase/ 返回 404(根路径无映射控制器),000 表示未起来 OK=0 @@ -136,6 +139,9 @@ jobs: -e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \ -e SPRING_CLOUD_NACOS_USERNAME=yh-reader \ -e SPRING_CLOUD_NACOS_PASSWORD=123456 \ + -e REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} \ + -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \ + -e RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} \ userbase-service:latest echo "rolled back"; exit 7 fi diff --git a/docker/application-prod.yml b/docker/application-prod.yml index e1caeec..51bda44 100644 --- a/docker/application-prod.yml +++ b/docker/application-prod.yml @@ -20,20 +20,23 @@ spring: host: redis-server port: 6379 database: 0 - password: 'wv&WcDYAmLtqTIGM' + # 生产由 nacos application-infra.yaml 注入;此处仅作 nacos 不可达时的兜底, + # 密码不写死,运行时由容器环境变量 REDIS_PASSWORD 注入(值来自 Gitea Secrets,不入镜像) + password: ${REDIS_PASSWORD} 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 +# datasource: +# url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true +# username: root +# # 生产由 nacos application-infra.yaml 注入;兜底密码运行时由 DB_PASSWORD 环境变量注入(来自 Gitea Secrets) +# password: ${DB_PASSWORD} +# driver-class-name: com.mysql.cj.jdbc.Driver +# min-idle: 10 +# max-active: 30 jpa: database: MySQL @@ -51,7 +54,8 @@ spring: host: rabbitmq port: 5672 username: admin - password: 'JdU29I#Q2E4yXoUl' + # 生产由 nacos application-infra.yaml 注入;兜底密码运行时由 RABBITMQ_PASSWORD 环境变量注入(来自 Gitea Secrets) + password: ${RABBITMQ_PASSWORD} listener: direct: auto-startup: false From baff5fc8df5698b02867ce8d885a62a64bfdb43f Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 17:43:13 +0800 Subject: [PATCH 17/58] =?UTF-8?q?=E6=B5=8B=E8=AF=95ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yunhai/userbase/controller/api/BaseController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index abe4e94..6ff2ea8 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -38,7 +38,7 @@ public class BaseController { return authUserWithDept; } - //返回系统枚举 + //返回系统枚举2 @GetMapping("GetSysDict") public Map getSysDict(BoaUser authUserWithDept){ Map resultMap = new HashMap<>(7); From 97959757d04fb9b6d1893d3acb7f9555f7594eeb Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 18:14:06 +0800 Subject: [PATCH 18/58] =?UTF-8?q?=E6=9B=B4=E6=94=B9=E9=AA=8C=E7=AD=BE?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/OpenApiAuthEnforceConfiguration.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java index d5523af..e540691 100644 --- a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java +++ b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java @@ -16,11 +16,13 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** - * 强制开放接口必须带应用签名。

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

+ * 已验签(属性存在)则放行,否则返回 401。
+ *
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。 */ @Configuration @@ -30,7 +32,11 @@ public class OpenApiAuthEnforceConfiguration { public FilterRegistrationBean requireAppSignFilter() { FilterRegistrationBean bean = new FilterRegistrationBean<>(); bean.setFilter(new RequireAppSignFilter()); - bean.addUrlPatterns("/open/api/*", "/oapi/internal/*"); + // 两个路径体系必须全部强制验签: + // 1) /open/api/* : 过渡期旧路径(客户端 404 自动回退时会用) + // 2) /oapi/* : 新统一开放前缀(覆盖 /oapi/user/、/oapi/internal/、未来 /oapi/app/ + // /oapi/group/ 等) + bean.addUrlPatterns("/open/api/*", "/oapi/*"); bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11); return bean; } From cb2a4d7ea4749b79d4353e45126df55a0b469e6a Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 18:37:08 +0800 Subject: [PATCH 19/58] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/GroupAuthzService.java | 186 +++++++++++++++--- 1 file changed, 154 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index f78fe7e..ddb82da 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -2,8 +2,10 @@ package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.entity.*; +import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.userbase.enums.IEnum; import com.yunhai.userbase.enums.SysModeEnum; +import com.yunhai.userbase.enums.UserTypeEnum; import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest; import com.yunhai.userbase.model.groupauthz.GroupAppVo; import com.yunhai.userbase.model.groupauthz.GroupAuthzAppVo; @@ -53,10 +55,11 @@ 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 标记)。
+ * 功能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 { @@ -97,12 +100,14 @@ public class GroupAuthzService implements IGroupAuthzService { @Autowired private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; - // ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId) ===================== + // ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId) + // ===================== /** * 按用户需求:父组(学校组)若开通了应用(GetSchoolAppId返回非空),则其所有子孙组的模块权限分配面板也能看到这些应用(无需子组重复开通)。
* 取应用范围=sys_group_app_rel里group_id属于「本组+全部祖先组」的app_id并集;开通不继承,模块权限池继承。
- * 对 hasModulePermission / UserPermissionTree 不做修改,避免越权(最终用户拿到权限仍需要自己的归属组开通应用+继承到模块权限,双保险)。 + * 对 hasModulePermission / UserPermissionTree + * 不做修改,避免越权(最终用户拿到权限仍需要自己的归属组开通应用+继承到模块权限,双保险)。 */ private Set collectAppIdsForAuthzScope(Integer groupId) { Map allGroupMap = buildAllGroupMap(); @@ -303,8 +308,8 @@ public class GroupAuthzService implements IGroupAuthzService { if (!sysGroupRepository.existsById(groupId)) { throw new DException("系统组不存在"); } - List perms = - request.getPerms() == null ? Collections.emptyList() : request.getPerms(); + List perms = request.getPerms() == null ? Collections.emptyList() + : request.getPerms(); Set appIdSet = collectAppIdsForAuthzScope(groupId); Set validModuleIds = new HashSet<>(); if (!appIdSet.isEmpty()) { @@ -558,7 +563,8 @@ public class GroupAuthzService implements IGroupAuthzService { } SysUserGroupRel existing = mainRelMap.get(rel.getUserId()); // 优先取主归属(isMain=1),否则保留第一个 - if (existing == null || (existing.getIsMain() == null || existing.getIsMain() != 1) && rel.getIsMain() != null && rel.getIsMain() == 1) { + if (existing == null || (existing.getIsMain() == null || existing.getIsMain() != 1) + && rel.getIsMain() != null && rel.getIsMain() == 1) { mainRelMap.put(rel.getUserId(), rel); } } @@ -588,6 +594,12 @@ public class GroupAuthzService implements IGroupAuthzService { if (user == null) { throw new DException("用户不存在"); } + // ============================================================ + // 超级管理员(UserTypeEnum.SUPER_ADMIN,type=1):直接返回所有已开通应用下的全部模块,不做任何组/继承判定 + // ============================================================ + if (isSuperAdmin(user.getType())) { + return buildSuperAdminAllPermissions(); + } // AI-FIX(角色授权): 先取用户经「启用」角色获得的模块ID集合,与组授权做并集; // 角色授权不依赖组归属与组开通应用,故下方不再因无组归属而提前返回 Set roleModuleIds = collectEnabledRoleModuleIds(userId); @@ -612,9 +624,10 @@ public class GroupAuthzService implements IGroupAuthzService { } // 3. 收集「用户归属组 + 其祖先链」所有组已开通的应用并集。 - // 安全:hasUserModulePermission 仍会判定 groupId(当前归属组)必须在 openedApps 链上(即归属组或其祖先开通应用), - // 不会出现“无组归属、父级未开”的越权;此处只是把过滤和写面板(GroupModuleTreeByCategory)联动对齐, - // 解决之前“管理员在组8面板勾了checked=true,但UserPermissionTree返回空”的对称bug。 + // 安全:hasUserModulePermission 仍会判定 groupId(当前归属组)必须在 openedApps + // 链上(即归属组或其祖先开通应用), + // 不会出现“无组归属、父级未开”的越权;此处只是把过滤和写面板(GroupModuleTreeByCategory)联动对齐, + // 解决之前“管理员在组8面板勾了checked=true,但UserPermissionTree返回空”的对称bug。 Map> groupToApps = new HashMap<>(); Set allAppIds = new LinkedHashSet<>(); for (Integer groupIdItem : selfGroupIds) { @@ -672,7 +685,8 @@ public class GroupAuthzService implements IGroupAuthzService { Set permittedModuleIds = new LinkedHashSet<>(); for (AppModule m : allModules) { if (roleModuleIds.contains(m.getId()) - || hasUserModulePermission(m.getId(), m.getAppId(), selfGroupIds, chainMap, groupToApps, modulePermMap)) { + || hasUserModulePermission(m.getId(), m.getAppId(), selfGroupIds, chainMap, groupToApps, + modulePermMap, allGroupMap)) { permittedModuleIds.add(m.getId()); } } @@ -839,14 +853,25 @@ public class GroupAuthzService implements IGroupAuthzService { } /** - * 用户维度权限判定:用户对指定模块是否有权限。
- * 复用 hasModulePermission 的祖先链继承逻辑,但基于已预热的批量查询结果。 + * 用户维度权限判定:用户对指定模块是否有权限(两段式 + 学校截止 + 超管兜底(超管在入口短路,这里只处理普通用户))。 + * + *

+ * 两段式(用户拍板:边界1选B): + * ① 先查「本组」(归属组 selfGroupId)有没有 sys_group_module_perm + * 记录(仅记录存在即=显式设置,无论inherit_flag值)→ 有 = 本组说了算,直接true,不再查祖先; + * ② 本组无记录 = 没有显式设置 = 进入继承查找:沿祖先链向上找,直到第一个学校组(包含学校组本身,不跨学校到教育局/根),第一个「记录存在且 + * inherit_flag=true」的组即继承成功; + * ③ 学校组之上的节点(区域/教育局/根)都不查找,避免跨学校继承到未开通的应用(用户新增边界3)。 + * + *

+ * 其余不变:归属组或其祖先需已开通该模块所属应用(双保险,防越权)。 */ private boolean hasUserModulePermission(Integer moduleId, Integer appId, - Set selfGroupIds, - Map> chainMap, - Map> groupToApps, - Map> modulePermMap) { + Set selfGroupIds, + Map> chainMap, + Map> groupToApps, + Map> modulePermMap, + Map allGroupMap) { Map permMap = modulePermMap.get(moduleId); if (permMap == null || permMap.isEmpty()) { return false; @@ -860,27 +885,124 @@ public class GroupAuthzService implements IGroupAuthzService { if (chain == null || chain.isEmpty()) { continue; } + // 第一段:本组显式设置优先(记录存在=true,完全按本组来,不继承不向上) + SysGroupModulePerm selfPerm = permMap.get(selfGroupId); + if (selfPerm != null) { + return true; + } + // 第二段:本组无记录 → 向上继承查找;遇到第一个学校组(SCHOOL_GROUP)就包含它自己,处理完停止(不跨学校) for (Integer ancestorId : chain) { - SysGroupModulePerm perm = permMap.get(ancestorId); - if (perm == null) { + if (Objects.equals(ancestorId, selfGroupId)) { continue; } - if (Objects.equals(ancestorId, selfGroupId)) { + SysGroup g = allGroupMap.get(ancestorId); + SysGroupModulePerm perm = permMap.get(ancestorId); + boolean isSchool = g != null && g.getType() == GroupTypeEnum.SCHOOL_GROUP; + if (perm != null && Boolean.TRUE.equals(perm.getInheritFlag())) { return true; } - if (Boolean.TRUE.equals(perm.getInheritFlag())) { - return true; + // 用户要求:向上查找只查到学校组这一层,包含学校组本身(上面已判断学校组的perm),之后不再跨学校 + if (isSchool) { + break; } } } return false; } + // ===================== 超级管理员 & 工具方法 ===================== + + /** + * 超级管理员 type=1(UserTypeEnum.SUPER_ADMIN / DicUtil.USER_TYPE_SUPER_MANAGER=1) + */ + private static boolean isSuperAdmin(UserTypeEnum userType) { + return userType != null && UserTypeEnum.SUPER_ADMIN.getKey().equals(userType.getKey()); + } + + /** + * 超级管理员全模块权限:不依赖组/继承,直接「所有已分类应用下的模块」全量返回。 + * 应用范围 = 所有已分类(category 存在)且有模块的应用,按应用过滤构建分类→应用→模块树。 + */ + private List buildSuperAdminAllPermissions() { + List categories = appCategoryRepository.findAllByOrderBySortNoAsc(); + List allApps = applicationInfoRepository.findAll(); + allApps.sort(appOrder()); + Map appMap = new HashMap<>(); + Set allAppIds = new LinkedHashSet<>(); + for (ApplicationInfo app : allApps) { + appMap.put(app.getId(), app); + allAppIds.add(app.getId()); + } + if (allAppIds.isEmpty()) { + return Collections.emptyList(); + } + List allModules = appModuleRepository.findByAppIdIn(new ArrayList<>(allAppIds)); + if (allModules.isEmpty()) { + return Collections.emptyList(); + } + Map> appModulesMap = new HashMap<>(); + Set allModuleIds = new LinkedHashSet<>(); + for (AppModule m : allModules) { + allModuleIds.add(m.getId()); + appModulesMap.computeIfAbsent(m.getAppId(), k -> new ArrayList<>()).add(m); + } + 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(), allModuleIds); + 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(); + } + List result = new ArrayList<>(); + for (AppCategory category : categories) { + List appVos = categoryAppMap.remove(category.getId()); + if (appVos == null || appVos.isEmpty()) { + continue; + } + appVos.sort(userPermissionAppOrder()); + UserPermissionCategoryVo c = new UserPermissionCategoryVo(); + c.setId(category.getId()); + c.setName(category.getName()); + c.setSortNo(category.getSortNo()); + c.setApps(appVos); + result.add(c); + } + List remain = categoryAppMap.remove(0); + if (remain != null && !remain.isEmpty()) { + remain.sort(userPermissionAppOrder()); + UserPermissionCategoryVo uncategorized = new UserPermissionCategoryVo(); + uncategorized.setId(0); + uncategorized.setName("未分类"); + uncategorized.setSortNo(Integer.MAX_VALUE); + uncategorized.setApps(remain); + result.add(uncategorized); + } + result.sort(Comparator.comparingInt(c -> c.getSortNo() == null ? Integer.MAX_VALUE : c.getSortNo())); + return result; + } + /** * 构建用户权限一览的模块树:仅保留有权限的模块,父节点无权限时子节点挂到根级。 */ private List buildUserPermissionModuleTree(List modules, - Set permittedModuleIds) { + Set permittedModuleIds) { // 先过滤 List permitted = new ArrayList<>(); for (AppModule m : modules) { @@ -918,7 +1040,7 @@ public class GroupAuthzService implements IGroupAuthzService { } private UserPermissionModuleVo buildUserPermissionModuleNode(AppModule node, - Map> childrenMap) { + Map> childrenMap) { UserPermissionModuleVo vo = new UserPermissionModuleVo(); BeanUtils.copyProperties(node, vo); List children = childrenMap.get(node.getId()); @@ -1009,7 +1131,7 @@ public class GroupAuthzService implements IGroupAuthzService { allGroupMap.put(g.getId(), g); } // 3. 前置校验:所属组必须已开通模块所属应用(最终权限 = 继承的模块权限 ∩ 已开通应用); - // 通过校验的组构建祖先链(含自身),并收集祖先链并集用于一次批量 IN 查询 + // 通过校验的组构建祖先链(含自身),并收集祖先链并集用于一次批量 IN 查询 Map> chainMap = new HashMap<>(); Set allChainIds = new LinkedHashSet<>(); for (SysUserGroupRel rel : userRels) { @@ -1029,8 +1151,8 @@ public class GroupAuthzService implements IGroupAuthzService { } // 4. 一次批量查询目标模块在所有祖先链节点上的权限记录 Map permMap = new HashMap<>(); - List perms = - sysGroupModulePermRepository.findByModuleIdAndGroupIdIn(moduleId, new ArrayList<>(allChainIds)); + List perms = sysGroupModulePermRepository.findByModuleIdAndGroupIdIn(moduleId, + new ArrayList<>(allChainIds)); for (SysGroupModulePerm perm : perms) { permMap.put(perm.getGroupId(), perm); } @@ -1081,7 +1203,7 @@ public class GroupAuthzService implements IGroupAuthzService { * 组装单个应用下的模块权限树(沿用 AppModuleService 的组装方式:childrenMap + sortNo 排序)。
*/ private List buildModuleTree(List modules, - Map permMap) { + Map permMap) { // parentId -> 子节点列表(null 视为 0) Map> childrenMap = new HashMap<>(); for (AppModule m : modules) { @@ -1112,8 +1234,8 @@ public class GroupAuthzService implements IGroupAuthzService { * 递归组装模块节点,并按权限记录回显 checked / inheritFlag。
*/ private GroupAuthzModuleVo buildModuleNode(AppModule node, - Map> childrenMap, - Map permMap) { + Map> childrenMap, + Map permMap) { GroupAuthzModuleVo vo = new GroupAuthzModuleVo(); BeanUtils.copyProperties(node, vo); SysGroupModulePerm perm = permMap.get(node.getId()); From 54f9fb7e3f81c7476188262fed2da012fae782e7 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 18:53:43 +0800 Subject: [PATCH 20/58] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=9B=B4=E6=94=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/GroupAuthzService.java | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index ddb82da..de7645e 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -597,16 +597,22 @@ public class GroupAuthzService implements IGroupAuthzService { // ============================================================ // 超级管理员(UserTypeEnum.SUPER_ADMIN,type=1):直接返回所有已开通应用下的全部模块,不做任何组/继承判定 // ============================================================ - if (isSuperAdmin(user.getType())) { + if (isSuperAdmin(user.getType(), userId)) { return buildSuperAdminAllPermissions(); } // AI-FIX(角色授权): 先取用户经「启用」角色获得的模块ID集合,与组授权做并集; // 角色授权不依赖组归属与组开通应用,故下方不再因无组归属而提前返回 Set roleModuleIds = collectEnabledRoleModuleIds(userId); - // 1. 用户所属的全部组 + // 1. 用户所属的全部组 + 【强制包含接口传入的 groupId 上下文】 + // 场景修复:管理员在「组8的授权面板」给模块M打勾(写 sys_group_module_perm 组8的记录), + // 然后同面板点「用户权限一览」UserPermissionTree?userId=X&groupId=8,必须读到组8的设置, + // 即便用户X还没加入组8(预览模式、或界面只做"组视角下的权限查询");否则"本组设置"不生效。 List userRels = sysUserGroupRelRepository.findByUserId(userId); Set selfGroupIds = new LinkedHashSet<>(); + if (groupId != null) { + selfGroupIds.add(groupId); + } for (SysUserGroupRel rel : userRels) { if (rel.getGroupId() != null) { selfGroupIds.add(rel.getGroupId()); @@ -890,7 +896,14 @@ public class GroupAuthzService implements IGroupAuthzService { if (selfPerm != null) { return true; } - // 第二段:本组无记录 → 向上继承查找;遇到第一个学校组(SCHOOL_GROUP)就包含它自己,处理完停止(不跨学校) + // 本组无记录 → 第二段:继承查找。 + // 如果「本组本身就是学校组」:学校组已经是继承上限,学校组之上(区域/教育局/根)不再允许查找,直接跳过本段。 + // 否则(本组是班级/年级/教师/学生等学校内子组):沿祖先向上,直到包含第一个学校组为止,出了学校组立刻break,不跨学校。 + SysGroup selfG = allGroupMap.get(selfGroupId); + boolean selfIsSchool = selfG != null && selfG.getType() == GroupTypeEnum.SCHOOL_GROUP; + if (selfIsSchool) { + continue; + } for (Integer ancestorId : chain) { if (Objects.equals(ancestorId, selfGroupId)) { continue; @@ -913,10 +926,16 @@ public class GroupAuthzService implements IGroupAuthzService { // ===================== 超级管理员 & 工具方法 ===================== /** - * 超级管理员 type=1(UserTypeEnum.SUPER_ADMIN / DicUtil.USER_TYPE_SUPER_MANAGER=1) + * 超级管理员判定: + * ① 用户记录的 type = UserTypeEnum.SUPER_ADMIN(1);或 + * ② userId = DicUtil.SUPER_ADMIN_ID(100000,系统内置基石超级管理员账号)。 + * 任一命中即可视为超级管理员,直接授予全部模块权限。 */ - private static boolean isSuperAdmin(UserTypeEnum userType) { - return userType != null && UserTypeEnum.SUPER_ADMIN.getKey().equals(userType.getKey()); + private static boolean isSuperAdmin(UserTypeEnum userType, Integer userId) { + if (userType != null && UserTypeEnum.SUPER_ADMIN.getKey().equals(userType.getKey())) { + return true; + } + return userId != null && com.yunhai.userbase.utils.DicUtil.SUPER_ADMIN_ID.equals(userId); } /** From 32d03e3eb46573d0da6a31848356fea58ad0ee97 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 21:38:14 +0800 Subject: [PATCH 21/58] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/GroupAuthzService.java | 185 +++++++++++------- 1 file changed, 114 insertions(+), 71 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index de7645e..9dddf0c 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -604,18 +604,26 @@ public class GroupAuthzService implements IGroupAuthzService { // 角色授权不依赖组归属与组开通应用,故下方不再因无组归属而提前返回 Set roleModuleIds = collectEnabledRoleModuleIds(userId); - // 1. 用户所属的全部组 + 【强制包含接口传入的 groupId 上下文】 - // 场景修复:管理员在「组8的授权面板」给模块M打勾(写 sys_group_module_perm 组8的记录), - // 然后同面板点「用户权限一览」UserPermissionTree?userId=X&groupId=8,必须读到组8的设置, - // 即便用户X还没加入组8(预览模式、或界面只做"组视角下的权限查询");否则"本组设置"不生效。 + // 1. 归属组集合:区分「组上下文模式」和「用户全局模式」 + // A) 接口显式传了 groupId(= 管理员在"某个组的授权面板"点用户权限一览,是典型的组视角查询): + // 以传入的 groupId 作为唯一 selfGroupId,仅沿「该组 -> 祖先 -> 第一个学校组(含)」链路判定; + // 【关键】不再把用户真实加入的其他组(例如用户还属于学校组)并入判断。 + // 原因:如果允许用户归属的"学校组"作为独立 selfGroupId 参与本组显式判定, + // 则子组未设置的模块会直接命中学校组自己的显式记录(permMap.get(学校组)!=null), + // 导致"子组设置的覆盖限制"完全失效=父级所有模块都拿到,即你观察到的"本组继承没生效/还是父级全量"。 + // B) 未传 groupId(全局查用户权限/开放接口 modules 未传schId以外的其他上下文): + // 正常取用户所有真实归属组并集。 List userRels = sysUserGroupRelRepository.findByUserId(userId); Set selfGroupIds = new LinkedHashSet<>(); if (groupId != null) { + // 组上下文模式:只认传入的 groupId,不认其他真实归属组(避免越级命中显式记录) selfGroupIds.add(groupId); - } - for (SysUserGroupRel rel : userRels) { - if (rel.getGroupId() != null) { - selfGroupIds.add(rel.getGroupId()); + } else { + // 用户全局模式:按真实归属组并集 + for (SysUserGroupRel rel : userRels) { + if (rel.getGroupId() != null) { + selfGroupIds.add(rel.getGroupId()); + } } } @@ -667,9 +675,11 @@ public class GroupAuthzService implements IGroupAuthzService { } Set allModuleIds = new LinkedHashSet<>(); Map> appModulesMap = new HashMap<>(); + Map moduleToAppMap = new HashMap<>(); for (AppModule m : allModules) { allModuleIds.add(m.getId()); appModulesMap.computeIfAbsent(m.getAppId(), k -> new ArrayList<>()).add(m); + moduleToAppMap.put(m.getId(), m.getAppId()); } // 6. 批量查询所有相关模块在所有相关组/祖先上的权限记录 @@ -686,13 +696,26 @@ public class GroupAuthzService implements IGroupAuthzService { } } + // AI-FIX(应用级覆盖): 某组只要对该应用有任意显式模块配置,子孙/本组就只按该组配置判定, + // 不再继承更上层祖先的同应用模块权限(避免「子组只勾了1个模块,却仍继承学校全部模块」)。 + Map> groupConfiguredApps = new HashMap<>(); + for (Map.Entry> entry : modulePermMap.entrySet()) { + Integer appId = moduleToAppMap.get(entry.getKey()); + if (appId == null) { + continue; + } + for (Integer gid : entry.getValue().keySet()) { + groupConfiguredApps.computeIfAbsent(gid, k -> new HashSet<>()).add(appId); + } + } + // 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, allGroupMap)) { + modulePermMap, allGroupMap, groupConfiguredApps)) { permittedModuleIds.add(m.getId()); } } @@ -859,29 +882,57 @@ public class GroupAuthzService implements IGroupAuthzService { } /** - * 用户维度权限判定:用户对指定模块是否有权限(两段式 + 学校截止 + 超管兜底(超管在入口短路,这里只处理普通用户))。 + * 就近原则:在 groupId 的祖先链上,找到离它最近的、对目标应用有显式模块配置的组(含自身)。 * - *

- * 两段式(用户拍板:边界1选B): - * ① 先查「本组」(归属组 selfGroupId)有没有 sys_group_module_perm - * 记录(仅记录存在即=显式设置,无论inherit_flag值)→ 有 = 本组说了算,直接true,不再查祖先; - * ② 本组无记录 = 没有显式设置 = 进入继承查找:沿祖先链向上找,直到第一个学校组(包含学校组本身,不跨学校到教育局/根),第一个「记录存在且 - * inherit_flag=true」的组即继承成功; - * ③ 学校组之上的节点(区域/教育局/根)都不查找,避免跨学校继承到未开通的应用(用户新增边界3)。 + *

链首即自身({@link #ancestorChain} 由自身向根排列),天然「就近优先」;遍历时第一个 + * 「groupConfiguredApps 中包含该应用」的组即作为该应用在本组范围内的唯一权限来源返回。 * - *

- * 其余不变:归属组或其祖先需已开通该模块所属应用(双保险,防越权)。 + *

学校截止:遇到学校组(无论是否已配置)即作为继承上限,不再向教育局/根越级,避免跨学校继承到未开通的应用。 + * + *

找不到返回 null(该应用在此范围内没有任何组的显式配置,对应模块不许可)。 + */ + private Integer findNearestAuthorityGroup(Integer groupId, List chain, + Integer appId, Map> groupConfiguredApps, + Map allGroupMap) { + if (chain == null || chain.isEmpty()) { + return null; + } + for (Integer gid : chain) { + Set apps = groupConfiguredApps.get(gid); + if (apps != null && apps.contains(appId)) { + return gid; + } + SysGroup g = allGroupMap.get(gid); + if (g != null && g.getType() == GroupTypeEnum.SCHOOL_GROUP) { + break; + } + } + return null; + } + + /** + * 用户维度权限判定:用户对指定模块是否有权限(就近原则 + 学校截止 + 角色授权并集 + 超管兜底)。 + * + *

就近原则(统一了「本组覆盖」与「最近祖先继承」两种情形,修复两类合并 bug): + * 沿 selfGroupId 的祖先链(含自身)向上,找到「离它最近、且对 appId 有任意显式模块配置」的组作为 + * 该应用的唯一权限来源;该模块被许可,当且仅当这个来源组对它自身有显式 {@code sys_group_module_perm} 记录。 + * 这样: + * ① 本组有配置(如英语组只勾模块8)→ 本组即来源,只返回本组勾选的模块,不再合并学校全部模块; + * ② 本组无配置(如英语组为空)→ 取最近的有配置祖先(如教师组)为来源,不再越级到更远的学校组; + * ③ 来源组的模块集合即最终集合,更上层同应用模块(如学校组多勾的模块8)一律不合并。 + * + *

学校截止:祖先链只查到第一个学校组(含)为止,不跨学校到教育局/根。 + * + *

其余:归属组需已开通该模块所属应用(双保险,防越权)。 */ private boolean hasUserModulePermission(Integer moduleId, Integer appId, Set selfGroupIds, Map> chainMap, Map> groupToApps, Map> modulePermMap, - Map allGroupMap) { + Map allGroupMap, + Map> groupConfiguredApps) { 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)) { @@ -891,33 +942,14 @@ public class GroupAuthzService implements IGroupAuthzService { if (chain == null || chain.isEmpty()) { continue; } - // 第一段:本组显式设置优先(记录存在=true,完全按本组来,不继承不向上) - SysGroupModulePerm selfPerm = permMap.get(selfGroupId); - if (selfPerm != null) { - return true; - } - // 本组无记录 → 第二段:继承查找。 - // 如果「本组本身就是学校组」:学校组已经是继承上限,学校组之上(区域/教育局/根)不再允许查找,直接跳过本段。 - // 否则(本组是班级/年级/教师/学生等学校内子组):沿祖先向上,直到包含第一个学校组为止,出了学校组立刻break,不跨学校。 - SysGroup selfG = allGroupMap.get(selfGroupId); - boolean selfIsSchool = selfG != null && selfG.getType() == GroupTypeEnum.SCHOOL_GROUP; - if (selfIsSchool) { + // 就近原则:取离本组最近、对该应用有显式配置的组作为唯一权限来源 + Integer authority = findNearestAuthorityGroup(selfGroupId, chain, appId, groupConfiguredApps, allGroupMap); + if (authority == null) { continue; } - for (Integer ancestorId : chain) { - if (Objects.equals(ancestorId, selfGroupId)) { - continue; - } - SysGroup g = allGroupMap.get(ancestorId); - SysGroupModulePerm perm = permMap.get(ancestorId); - boolean isSchool = g != null && g.getType() == GroupTypeEnum.SCHOOL_GROUP; - if (perm != null && Boolean.TRUE.equals(perm.getInheritFlag())) { - return true; - } - // 用户要求:向上查找只查到学校组这一层,包含学校组本身(上面已判断学校组的perm),之后不再跨学校 - if (isSchool) { - break; - } + // 权限来源组对该模块有显式记录 → 许可;否则(来源组未勾此模块)不许可 + if (permMap != null && permMap.get(authority) != null) { + return true; } } return false; @@ -1135,6 +1167,7 @@ public class GroupAuthzService implements IGroupAuthzService { if (module == null) { return false; } + Integer appId = module.getAppId(); // AI-FIX(角色授权): 用户经「启用」角色获得该模块授权即通过,不要求组开通模块所属应用;禁用角色已在收集时过滤 if (collectEnabledRoleModuleIds(userId).contains(moduleId)) { return true; @@ -1149,18 +1182,20 @@ public class GroupAuthzService implements IGroupAuthzService { for (SysGroup g : sysGroupRepository.findAll()) { allGroupMap.put(g.getId(), g); } - // 3. 前置校验:所属组必须已开通模块所属应用(最终权限 = 继承的模块权限 ∩ 已开通应用); + // 3. 前置校验:所属组必须已开通模块所属应用(最终权限 = 就近来源模块权限 ∩ 已开通应用); // 通过校验的组构建祖先链(含自身),并收集祖先链并集用于一次批量 IN 查询 Map> chainMap = new HashMap<>(); Set allChainIds = new LinkedHashSet<>(); + Set selfGroupIds = new LinkedHashSet<>(); for (SysUserGroupRel rel : userRels) { Integer groupId = rel.getGroupId(); if (groupId == null || chainMap.containsKey(groupId)) { continue; } - if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(groupId, module.getAppId())) { + if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(groupId, appId)) { continue; } + selfGroupIds.add(groupId); List chain = ancestorChain(groupId, allGroupMap); chainMap.put(groupId, chain); allChainIds.addAll(chain); @@ -1168,30 +1203,38 @@ public class GroupAuthzService implements IGroupAuthzService { 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); + // 4. 一次批量查询「该应用下所有模块」在全部祖先链节点上的权限记录 + // (要判定就近来源,需要知道链上哪些组对该应用有显式配置,不能只看单模块) + List appModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); + Set appModuleIds = new LinkedHashSet<>(); + for (AppModule m : appModules) { + appModuleIds.add(m.getId()); } - 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) { + Map> modulePermMap = new HashMap<>(); + Map> groupConfiguredApps = new HashMap<>(); + if (!appModuleIds.isEmpty() && !allChainIds.isEmpty()) { + List perms = sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn( + new ArrayList<>(appModuleIds), new ArrayList<>(allChainIds)); + for (SysGroupModulePerm perm : perms) { + if (perm.getModuleId() == null || perm.getGroupId() == null) { continue; } - if (Objects.equals(ancestorId, selfGroupId)) { - return true; - } - if (Boolean.TRUE.equals(perm.getInheritFlag())) { - return true; - } + modulePermMap.computeIfAbsent(perm.getModuleId(), k -> new HashMap<>()) + .put(perm.getGroupId(), perm); + groupConfiguredApps.computeIfAbsent(perm.getGroupId(), k -> new HashSet<>()).add(appId); + } + } + // 5. 就近原则判定:对每个归属组,取离它最近的、对该应用有显式配置的组为唯一权限来源; + // 该来源组对目标模块有记录即通过(多个归属组取并集)。 + for (Integer selfGroupId : selfGroupIds) { + List chain = chainMap.get(selfGroupId); + Integer authority = findNearestAuthorityGroup(selfGroupId, chain, appId, groupConfiguredApps, allGroupMap); + if (authority == null) { + continue; + } + Map pm = modulePermMap.get(moduleId); + if (pm != null && pm.get(authority) != null) { + return true; } } return false; From 4a10fad893cbed643e429ac38c423f7d72f85ae3 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 22:02:49 +0800 Subject: [PATCH 22/58] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/GroupAuthzService.java | 31 +- .../service/impl/GroupAuthzServiceTest.java | 492 ++++++++++++++++++ 2 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 9dddf0c..2922d5e 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -910,6 +910,26 @@ public class GroupAuthzService implements IGroupAuthzService { return null; } + /** + * 子组继承闸门:判定「来源组 authority 对 moduleId 的记录」是否对本组 selfGroupId 生效。 + * + *

规则(修复「父级未勾子组继承却仍被继承」的 bug): + * ① 来源组 == 本组:本组自身勾选的模块直接生效——{@code inheritFlag} 只约束是否下放到子组,不影响本组自己; + * ② 来源组为祖先(往上回溯得来):必须勾选了「子组继承」({@code inheritFlag=true}) 才会下放到本组, + * 否则该模块不继承。即「只过滤勾选了子组继承的模块;若来源组对该应用所有模块都没勾子组继承,则本组对该应用返回空」。 + */ + private boolean isPermEffectiveForSelf(Integer authority, Integer selfGroupId, + Map permMap) { + SysGroupModulePerm perm = (permMap == null) ? null : permMap.get(authority); + if (perm == null) { + return false; + } + if (authority.equals(selfGroupId)) { + return true; + } + return Boolean.TRUE.equals(perm.getInheritFlag()); + } + /** * 用户维度权限判定:用户对指定模块是否有权限(就近原则 + 学校截止 + 角色授权并集 + 超管兜底)。 * @@ -921,6 +941,10 @@ public class GroupAuthzService implements IGroupAuthzService { * ② 本组无配置(如英语组为空)→ 取最近的有配置祖先(如教师组)为来源,不再越级到更远的学校组; * ③ 来源组的模块集合即最终集合,更上层同应用模块(如学校组多勾的模块8)一律不合并。 * + *

子组继承闸门(修复「父级未勾子组继承仍被继承」的 bug):来源组若是本组,本组勾选的模块直接生效; + * 来源组若是祖先,必须该模块记录 {@code inheritFlag=true}(勾选子组继承)才下放到本组,否则不继承—— + * 即「只过滤勾选了子组继承的模块;若来源组对该应用所有模块都没勾子组继承,则本组对该应用返回空」。 + * *

学校截止:祖先链只查到第一个学校组(含)为止,不跨学校到教育局/根。 * *

其余:归属组需已开通该模块所属应用(双保险,防越权)。 @@ -947,8 +971,9 @@ public class GroupAuthzService implements IGroupAuthzService { if (authority == null) { continue; } - // 权限来源组对该模块有显式记录 → 许可;否则(来源组未勾此模块)不许可 - if (permMap != null && permMap.get(authority) != null) { + // 来源组对该模块有显式记录,且(来源是本组 或 来源是祖先且勾选了子组继承)→ 许可; + // 否则(来源组未勾此模块 / 祖先未开启子组继承)不许可。 + if (isPermEffectiveForSelf(authority, selfGroupId, permMap)) { return true; } } @@ -1233,7 +1258,7 @@ public class GroupAuthzService implements IGroupAuthzService { continue; } Map pm = modulePermMap.get(moduleId); - if (pm != null && pm.get(authority) != null) { + if (isPermEffectiveForSelf(authority, selfGroupId, pm)) { return true; } } diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java new file mode 100644 index 0000000..b342f68 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java @@ -0,0 +1,492 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.AppCategory; +import com.yunhai.userbase.entity.AppModule; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysGroupAppRel; +import com.yunhai.userbase.entity.SysGroupModulePerm; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.enums.UserTypeEnum; +import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo; +import com.yunhai.userbase.model.groupauthz.UserPermissionModuleVo; +import com.yunhai.userbase.repository.AppCategoryRepository; +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.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +/** + * GroupAuthzService 权限计算单元测试。 + * 重点验证「就近原则 + 子组继承闸门」:沿组的祖先链向上,取最近的有显式模块配置的组作为该应用唯一权限来源, + * 只返回该来源组的模块集合(不向上合并更远的祖先);且来源组若为祖先,必须勾选子组继承(inheritFlag=true)才下放 + * (修复「子组只勾1个却继承学校全部」「返回3个而非最近祖先的2个」「父级未勾子组继承仍被继承」三类 bug)。 + */ +class GroupAuthzServiceTest { + + @InjectMocks + private GroupAuthzService groupAuthzService; + + @Mock + private SysGroupAppRelRepository sysGroupAppRelRepository; + @Mock + private SysGroupModulePermRepository sysGroupModulePermRepository; + @Mock + private SysGroupRepository sysGroupRepository; + @Mock + private ApplicationInfoRepository applicationInfoRepository; + @Mock + private AppCategoryRepository appCategoryRepository; + @Mock + private AppModuleRepository appModuleRepository; + @Mock + private SysUserGroupRelRepository sysUserGroupRelRepository; + @Mock + private SysUserRepository sysUserRepository; + @Mock + private SysAppRoleRepository sysAppRoleRepository; + @Mock + private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + @Mock + private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + initGroups(); + } + + /** + * 构造一组固定层级: + * 5 教育云中心(根) + * └─ 27 信阳教育局 + * └─ 16 云海中学(学校) + * ├─ 29 教师组 + * │ └─ 31 英语组 + * └─ 30 学生组 + */ + private void initGroups() { + when(sysGroupRepository.findAll()).thenReturn(Arrays.asList( + group(5, "教育云中心", 0, GroupTypeEnum.NORMAL_GROUP), + group(27, "信阳教育局", 5, GroupTypeEnum.AREA_GROUP), + group(16, "云海中学", 27, GroupTypeEnum.SCHOOL_GROUP), + group(28, "信阳高级中学", 27, GroupTypeEnum.SCHOOL_GROUP), + group(29, "教师组", 16, GroupTypeEnum.TEACHER_GROUP), + group(30, "学生组", 16, GroupTypeEnum.STUDENT_GROUP), + group(31, "英语组", 29, GroupTypeEnum.NORMAL_GROUP) + )); + } + + @Test + void findUserPermissionTree_whenSelfGroupConfiguredApp_shouldNotInheritAncestorModules() { + // user 1 仅属于英语组 31 + when(sysUserRepository.findById(1)).thenReturn(Optional.of(user(1, UserTypeEnum.NORMAL))); + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn( + Collections.singletonList(rel(1, 31, 1))); + + // 祖先链上的应用开通:只有学校 16 开通了 100003 + when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> { + List ids = inv.getArgument(0); + if (ids.contains(16)) { + return Collections.singletonList(appRel(16, 100003)); + } + return Collections.emptyList(); + }); + + ApplicationInfo app = new ApplicationInfo(); + app.setId(100003); + app.setName("通知公告"); + app.setAppKey(100002); + app.setCategoryId(2); + when(applicationInfoRepository.findAllById(any())).thenReturn(Collections.singletonList(app)); + + AppCategory category = new AppCategory(); + category.setId(2); + category.setName("家校互动"); + category.setSortNo(2); + when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category)); + + AppModule m8 = module(8, 100003, "已收通知"); + AppModule m9 = module(9, 100003, "已发通知"); + AppModule m10 = module(10, 100003, "全部通知"); + when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(Arrays.asList(m8, m9, m10)); + + // 权限记录:英语组 31 只有模块 8;学校 16 有 8/9/10 且都允许继承 + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList())) + .thenReturn(Arrays.asList( + perm(31, 8, true), + perm(16, 8, true), + perm(16, 9, true), + perm(16, 10, true))); + + List result = groupAuthzService.findUserPermissionTree(31, 1); + + assertEquals(1, result.size(), "应只有一个分类"); + assertEquals("家校互动", result.get(0).getName()); + assertEquals(1, result.get(0).getApps().size(), "应只有一个应用"); + assertEquals(1, result.get(0).getApps().get(0).getModules().size(), "只应返回模块 8"); + assertEquals(8, result.get(0).getApps().get(0).getModules().get(0).getId()); + } + + @Test + void findUserPermissionTree_whenSelfGroupNotConfiguredApp_shouldInheritFromSchool() { + // user 2 仅属于学生组 30,学生组没有配置通知公告应用的任何模块 + when(sysUserRepository.findById(2)).thenReturn(Optional.of(user(2, UserTypeEnum.NORMAL))); + when(sysUserGroupRelRepository.findByUserId(2)).thenReturn( + Collections.singletonList(rel(2, 30, 1))); + + when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> { + List ids = inv.getArgument(0); + if (ids.contains(16)) { + return Collections.singletonList(appRel(16, 100003)); + } + return Collections.emptyList(); + }); + + ApplicationInfo app = new ApplicationInfo(); + app.setId(100003); + app.setName("通知公告"); + app.setAppKey(100002); + app.setCategoryId(2); + when(applicationInfoRepository.findAllById(any())).thenReturn(Collections.singletonList(app)); + + AppCategory category = new AppCategory(); + category.setId(2); + category.setName("家校互动"); + category.setSortNo(2); + when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category)); + + AppModule m8 = module(8, 100003, "已收通知"); + AppModule m9 = module(9, 100003, "已发通知"); + AppModule m10 = module(10, 100003, "全部通知"); + when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(Arrays.asList(m8, m9, m10)); + + // 学校 16 三个模块都开放继承;学生组 30 没有任何记录 + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList())) + .thenReturn(Arrays.asList( + perm(16, 8, true), + perm(16, 9, true), + perm(16, 10, true))); + + List result = groupAuthzService.findUserPermissionTree(30, 2); + + assertEquals(3, result.get(0).getApps().get(0).getModules().size(), + "未配置应用时,应继承学校的全部 3 个模块"); + } + + @Test + void hasModulePermission_whenSelfGroupConfiguredApp_onlySelfRecordsCount() { + AppModule m8 = module(8, 100003, "已收通知"); + AppModule m9 = module(9, 100003, "已发通知"); + AppModule m10 = module(10, 100003, "全部通知"); + when(appModuleRepository.findById(9)).thenReturn(Optional.of(m9)); + when(sysUserAppRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList()); + + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn( + Collections.singletonList(rel(1, 31, 1))); + + when(sysGroupAppRelRepository.existsByGroupIdAndAppId(31, 100003)).thenReturn(true); + when(appModuleRepository.findByAppIdIn(Collections.singletonList(100003))).thenReturn(Arrays.asList(m8, m9, m10)); + + // 英语组 31 配置了应用 100003(模块 8);学校 16 有模块 9(但 31 已就近接管,不应继承) + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn( + Arrays.asList(8, 9, 10), Arrays.asList(31, 29, 16, 27, 5))) + .thenReturn(Arrays.asList(perm(31, 8, true), perm(16, 9, true))); + + assertFalse(groupAuthzService.hasModulePermission(1, 9), + "英语组已配置通知公告应用(就近接管),不应再继承学校的已发通知模块"); + } + + @Test + void hasModulePermission_whenSelfGroupHasTheModule_shouldReturnTrue() { + AppModule m8 = module(8, 100003, "已收通知"); + AppModule m9 = module(9, 100003, "已发通知"); + AppModule m10 = module(10, 100003, "全部通知"); + when(appModuleRepository.findById(8)).thenReturn(Optional.of(m8)); + when(sysUserAppRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList()); + + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn( + Collections.singletonList(rel(1, 31, 1))); + + when(sysGroupAppRelRepository.existsByGroupIdAndAppId(31, 100003)).thenReturn(true); + when(appModuleRepository.findByAppIdIn(Collections.singletonList(100003))).thenReturn(Arrays.asList(m8, m9, m10)); + + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn( + Arrays.asList(8, 9, 10), Arrays.asList(31, 29, 16, 27, 5))) + .thenReturn(Arrays.asList(perm(31, 8, true), perm(16, 8, true))); + + assertTrue(groupAuthzService.hasModulePermission(1, 8), + "英语组自身已配置模块 8,应返回 true"); + } + + @Test + void findUserPermissionTree_whenSelfGroupEmpty_shouldUseNearestAncestorTeacherGroup() { + // user 1 仅属于英语组 31;英语组(31)无任何模块授权,教师组(29)有模块9/10,学校(16)有8/9/10 + when(sysUserRepository.findById(1)).thenReturn(Optional.of(user(1, UserTypeEnum.NORMAL))); + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn( + Collections.singletonList(rel(1, 31, 1))); + + when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> { + List ids = inv.getArgument(0); + if (ids.contains(16)) { + return Collections.singletonList(appRel(16, 100003)); + } + return Collections.emptyList(); + }); + + ApplicationInfo app = new ApplicationInfo(); + app.setId(100003); + app.setName("通知公告"); + app.setAppKey(100002); + app.setCategoryId(2); + when(applicationInfoRepository.findAllById(any())).thenReturn(Collections.singletonList(app)); + + AppCategory category = new AppCategory(); + category.setId(2); + category.setName("家校互动"); + category.setSortNo(2); + when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category)); + + AppModule m8 = module(8, 100003, "已收通知"); + AppModule m9 = module(9, 100003, "已发通知"); + AppModule m10 = module(10, 100003, "全部通知"); + when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(Arrays.asList(m8, m9, m10)); + + // 英语组31空;教师组29有9/10;学校16有8/9/10 + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList())) + .thenReturn(Arrays.asList( + perm(29, 9, true), + perm(29, 10, true), + perm(16, 8, true), + perm(16, 9, true), + perm(16, 10, true))); + + List result = groupAuthzService.findUserPermissionTree(31, 1); + + assertEquals(1, result.size(), "应只有一个分类"); + assertEquals(1, result.get(0).getApps().size(), "应只有一个应用"); + assertEquals(2, result.get(0).getApps().get(0).getModules().size(), + "本组空→取最近教师组29的2个模块,不应合并学校组多出的模块8"); + Set moduleIds = new LinkedHashSet<>(); + for (UserPermissionModuleVo mv : result.get(0).getApps().get(0).getModules()) { + moduleIds.add(mv.getId()); + } + assertTrue(moduleIds.contains(9) && moduleIds.contains(10) && moduleIds.size() == 2, + "应仅含模块 9 和 10,不含学校组多出的模块 8"); + } + + @Test + void findUserPermissionTree_whenNearestAncestorAllNotInheritable_returnsEmpty() { + // 英语组31空;教师组29有9/10但 inheritFlag=false(未勾子组继承);学校16有8/9/10 + when(sysUserRepository.findById(1)).thenReturn(Optional.of(user(1, UserTypeEnum.NORMAL))); + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn( + Collections.singletonList(rel(1, 31, 1))); + + when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> { + List ids = inv.getArgument(0); + if (ids.contains(16)) { + return Collections.singletonList(appRel(16, 100003)); + } + return Collections.emptyList(); + }); + + ApplicationInfo app = new ApplicationInfo(); + app.setId(100003); + app.setName("通知公告"); + app.setAppKey(100002); + app.setCategoryId(2); + when(applicationInfoRepository.findAllById(any())).thenReturn(Collections.singletonList(app)); + + AppCategory category = new AppCategory(); + category.setId(2); + category.setName("家校互动"); + category.setSortNo(2); + when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category)); + + AppModule m8 = module(8, 100003, "已收通知"); + AppModule m9 = module(9, 100003, "已发通知"); + AppModule m10 = module(10, 100003, "全部通知"); + when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(Arrays.asList(m8, m9, m10)); + + // 教师组29(最近来源)所有模块 inheritFlag=false;即便学校16开放,也不应被继承 + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList())) + .thenReturn(Arrays.asList( + perm(29, 9, false), + perm(29, 10, false), + perm(16, 8, true), + perm(16, 9, true), + perm(16, 10, true))); + + List result = groupAuthzService.findUserPermissionTree(31, 1); + + // 最近来源(教师组)对该应用所有模块都未勾子组继承 → 本组该应用为空,不应合并学校组 + assertTrue(result.isEmpty(), + "最近祖先(教师组)未勾子组继承,应返回空,不应从学校组继承任何模块"); + } + + @Test + void findUserPermissionTree_whenNearestAncestorPartiallyInheritable_returnsOnlyInheritable() { + // 英语组31空;教师组29有9(inheritFlag=true)/10(inheritFlag=false);学校16有8/9/10 + when(sysUserRepository.findById(1)).thenReturn(Optional.of(user(1, UserTypeEnum.NORMAL))); + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn( + Collections.singletonList(rel(1, 31, 1))); + + when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> { + List ids = inv.getArgument(0); + if (ids.contains(16)) { + return Collections.singletonList(appRel(16, 100003)); + } + return Collections.emptyList(); + }); + + ApplicationInfo app = new ApplicationInfo(); + app.setId(100003); + app.setName("通知公告"); + app.setAppKey(100002); + app.setCategoryId(2); + when(applicationInfoRepository.findAllById(any())).thenReturn(Collections.singletonList(app)); + + AppCategory category = new AppCategory(); + category.setId(2); + category.setName("家校互动"); + category.setSortNo(2); + when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category)); + + AppModule m8 = module(8, 100003, "已收通知"); + AppModule m9 = module(9, 100003, "已发通知"); + AppModule m10 = module(10, 100003, "全部通知"); + when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(Arrays.asList(m8, m9, m10)); + + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList())) + .thenReturn(Arrays.asList( + perm(29, 9, true), + perm(29, 10, false), + perm(16, 8, true), + perm(16, 9, true), + perm(16, 10, true))); + + List result = groupAuthzService.findUserPermissionTree(31, 1); + + assertEquals(1, result.size(), "应只有一个分类"); + assertEquals(1, result.get(0).getApps().size(), "应只有一个应用"); + assertEquals(1, result.get(0).getApps().get(0).getModules().size(), + "最近来源(教师组)只有模块9勾了子组继承,应只返回模块9,模块10与学校组模块8均不继承"); + assertEquals(9, result.get(0).getApps().get(0).getModules().get(0).getId()); + } + + @Test + void hasModulePermission_whenSelfGroupEmpty_nearestAncestorIsTeacherGroup() { + // 英语组31空,教师组29有9/10,学校16有8/9/10 + AppModule m8 = module(8, 100003, "已收通知"); + AppModule m9 = module(9, 100003, "已发通知"); + AppModule m10 = module(10, 100003, "全部通知"); + when(appModuleRepository.findById(8)).thenReturn(Optional.of(m8)); + when(appModuleRepository.findById(9)).thenReturn(Optional.of(m9)); + when(appModuleRepository.findById(10)).thenReturn(Optional.of(m10)); + when(sysUserAppRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList()); + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn( + Collections.singletonList(rel(1, 31, 1))); + when(sysGroupAppRelRepository.existsByGroupIdAndAppId(31, 100003)).thenReturn(true); + when(appModuleRepository.findByAppIdIn(Collections.singletonList(100003))).thenReturn(Arrays.asList(m8, m9, m10)); + when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn( + Arrays.asList(8, 9, 10), Arrays.asList(31, 29, 16, 27, 5))) + .thenReturn(Arrays.asList( + perm(29, 9, true), perm(29, 10, true), + perm(16, 8, true), perm(16, 9, true), perm(16, 10, true))); + + assertFalse(groupAuthzService.hasModulePermission(1, 8), + "英语组为空→最近来源是教师组29,学校组多勾的模块8不应被继承"); + assertTrue(groupAuthzService.hasModulePermission(1, 9), + "教师组29勾选的模块9应可访问"); + assertTrue(groupAuthzService.hasModulePermission(1, 10), + "教师组29勾选的模块10应可访问"); + } + + // ===================== helper ===================== + + private SysGroup group(Integer id, String name, Integer parentId, GroupTypeEnum type) { + SysGroup g = new SysGroup(); + g.setId(id); + g.setName(name); + g.setParentId(parentId); + g.setType(type); + return g; + } + + private SysUser user(Integer id, UserTypeEnum type) { + SysUser u = new SysUser(); + u.setId(id); + u.setType(type); + u.setAccount("u" + id); + u.setRealName("用户" + id); + return u; + } + + private SysUserGroupRel rel(Integer userId, Integer groupId, Integer isMain) { + SysUserGroupRel r = new SysUserGroupRel(); + r.setUserId(userId); + r.setGroupId(groupId); + r.setIsMain(isMain); + return r; + } + + private SysGroupAppRel appRel(Integer groupId, Integer appId) { + SysGroupAppRel r = new SysGroupAppRel(); + r.setGroupId(groupId); + r.setAppId(appId); + return r; + } + + private SysGroupModulePerm perm(Integer groupId, Integer moduleId, boolean inherit) { + SysGroupModulePerm p = new SysGroupModulePerm(); + p.setGroupId(groupId); + p.setModuleId(moduleId); + p.setInheritFlag(inherit); + return p; + } + + private ApplicationInfo app(Integer id, String name, Integer categoryId) { + ApplicationInfo a = new ApplicationInfo(); + a.setId(id); + a.setName(name); + a.setCategoryId(categoryId); + return a; + } + + private AppModule module(Integer id, Integer appId, String name) { + AppModule m = new AppModule(); + m.setId(id); + m.setAppId(appId); + m.setName(name); + m.setCustomId(String.valueOf(id)); + m.setLevel(1); + return m; + } +} From f55c865ab13eb8ad4aa9ef46501980a5906f435f Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 11 Aug 2026 22:43:09 +0800 Subject: [PATCH 23/58] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2=EF=BC=8C=E8=B6=85=E7=AE=A1=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userbase/controller/oapi/UserController.java | 6 +++--- .../userbase/service/impl/GroupAuthzService.java | 6 ++++++ .../userbase/service/impl/GroupAuthzServiceTest.java | 12 ++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index de6438d..7fddf7e 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -76,9 +76,9 @@ public class UserController { return Collections.emptyList(); } Integer effectiveGroupId = resolveEffectiveGroupId(userId, schId); - if (effectiveGroupId == null) { - return Collections.emptyList(); - } + // 注意:超管即使没有学校/归属组,service.findUserPermissionTree 内部也会走超管短路返回全部权限, + // 因此这里不能因 effectiveGroupId 为 null 直接返回空,否则超管在 oapi/user/modules 永远拿不到权限; + // 普通用户无组/无学校时,findUserPermissionTree 同样会返回空列表,行为一致。 List tree = groupAuthzService.findUserPermissionTree(effectiveGroupId, userId); if (tree == null || tree.isEmpty()) { return Collections.emptyList(); diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 2922d5e..fc4c51d 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -1188,6 +1188,12 @@ public class GroupAuthzService implements IGroupAuthzService { public boolean hasModulePermission(Integer userId, Integer moduleId) { Objects.requireNonNull(userId, "用户ID不能为空"); Objects.requireNonNull(moduleId, "模块ID不能为空"); + // 超级管理员(UserTypeEnum.SUPER_ADMIN / 基石账号):直接赋予全部模块权限, + // 不做任何组/角色/继承判定——避免「超管不归属任何组 → userRels 为空 → 直接返回 false(即无任何权限)」的 bug。 + SysUser user = sysUserRepository.findById(userId).orElse(null); + if (user != null && isSuperAdmin(user.getType(), userId)) { + return true; + } AppModule module = appModuleRepository.findById(moduleId).orElse(null); if (module == null) { return false; diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java index b342f68..25ef956 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java @@ -429,6 +429,18 @@ class GroupAuthzServiceTest { "教师组29勾选的模块10应可访问"); } + @Test + void hasModulePermission_whenSuperAdmin_shouldReturnTrueRegardlessOfGroup() { + // 超级管理员:type=SUPER_ADMIN,且不归属任何组(超管通常不在任何组下) + when(sysUserRepository.findById(1)).thenReturn(Optional.of(user(1, UserTypeEnum.SUPER_ADMIN))); + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.emptyList()); + + assertTrue(groupAuthzService.hasModulePermission(1, 8), + "超级管理员应直接拥有全部模块权限,无需归属组/角色(修复「超管无组→返回false」的 bug)"); + assertTrue(groupAuthzService.hasModulePermission(1, 100), + "超级管理员对任意模块都应返回 true"); + } + // ===================== helper ===================== private SysGroup group(Integer id, String name, Integer parentId, GroupTypeEnum type) { From a5ced786b42275dddbc93cf0dd3d638f34dfcb46 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 12 Aug 2026 19:16:22 +0800 Subject: [PATCH 24/58] =?UTF-8?q?=E7=BB=84=E7=94=A8=E6=88=B7=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BoaUserContextInterceptor.java | 2 +- .../userbase/config/AppSecretConfig.java | 20 +- .../OpenApiAuthEnforceConfiguration.java | 47 +- .../config/UserBaseAppSecretProvider.java | 25 +- .../UserBaseLocalAppIdentityProvider.java | 8 + .../config/UserBaseUserDetailsProvider.java | 21 +- .../controller/api/AppSettingsController.java | 2 +- .../controller/api/SysUserController.java | 94 ++- .../controller/oapi/UserController.java | 2 +- .../com/yunhai/userbase/entity/SysUser.java | 4 + .../userbase/entity/SysUserGroupRel.java | 4 + .../ApplicationInfoCacheChangedEvent.java | 45 ++ .../model/sysuser/BatchImportResult.java | 47 ++ .../model/sysuser/ChangeGroupsRequest.java | 23 + .../userbase/model/sysuser/ExcelSysUser.java | 53 ++ .../model/sysuser/SysUserRequest.java | 2 + .../userbase/model/sysuser/SysUserVo.java | 4 + .../repository/SysUserGroupRelRepository.java | 9 + .../userbase/service/ISysUserService.java | 85 +++ .../userbase/service/SignKeyCacheService.java | 151 ---- .../service/impl/ApplicationInfoService.java | 23 +- .../service/impl/SignKeyCacheService.java | 275 +++++++ .../userbase/service/impl/SysUserService.java | 672 +++++++++++++++++- .../service/{ => impl}/UserCenterService.java | 2 +- .../userbase/task/SignKeyCacheDailyTask.java | 48 ++ src/main/resources/application.yml | 6 + .../sql/sys_user_add_email_gender.sql | 5 + .../sql/sys_user_group_rel_add_sort_no.sql | 11 + 28 files changed, 1488 insertions(+), 202 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/event/ApplicationInfoCacheChangedEvent.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/BatchImportResult.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/ExcelSysUser.java delete mode 100644 src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java rename src/main/java/com/yunhai/userbase/service/{ => impl}/UserCenterService.java (99%) create mode 100644 src/main/java/com/yunhai/userbase/task/SignKeyCacheDailyTask.java create mode 100644 src/main/resources/sql/sys_user_add_email_gender.sql create mode 100644 src/main/resources/sql/sys_user_group_rel_add_sort_no.sql diff --git a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java index dc2beb4..c06b8e0 100644 --- a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java +++ b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java @@ -2,7 +2,7 @@ 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 com.yunhai.userbase.service.impl.UserCenterService; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java index 08dcd00..5093768 100644 --- a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java +++ b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java @@ -6,6 +6,7 @@ import com.yunhai.platform.appauth.LocalAppIdentityProvider; import com.yunhai.userbase.repository.ApplicationInfoRepository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; /** * 用户中心验签密钥源:UserBase 作为密钥权威源,优先从本库 application_info 解析调用方密钥 @@ -13,18 +14,31 @@ import org.springframework.context.annotation.Configuration; * 显式覆盖 yh-app-auth 默认的 {@code @ConditionalOnMissingBean} 实现,使开放接口签名校验 * 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。

* 同时注册 UserBase 自身作为出站调用方的身份:固定 appKey=100000,密钥落库,不再依赖 yml - * 配置 app-key/app-secret。 + * 配置 app-key/app-secret。
+ * 注意:两个 Bean 都加 {@code @Primary},保证 yh-platform starter 在按类型注入时 + * (如 AppSignInterceptor 需要 LocalAppIdentityProvider)不会因多个候选而报 + * 「required a single bean, but 2 were found」。 */ @Configuration public class AppSecretConfig { - @Bean + /** + * 入站验签密钥提供方(按类型匹配 AppSecretProvider,@Primary 优先)。 + * SignKeyCacheService 注入时按具体类名 UserBaseAppSecretProvider 匹配,不走这个接口类型。 + */ + @Bean(name = "appSecretProvider") + @Primary public AppSecretProvider appSecretProvider(ApplicationInfoRepository repository, AppAuthProperties properties) { return new UserBaseAppSecretProvider(repository, properties); } - @Bean + /** + * 出站身份提供方(按类型匹配 LocalAppIdentityProvider,@Primary 优先)。 + * AppSignInterceptor 等 yh-platform starter 内部按接口类型注入,需要明确唯一候选。 + */ + @Bean(name = "localAppIdentityProvider") + @Primary public LocalAppIdentityProvider localAppIdentityProvider(ApplicationInfoRepository repository) { return new UserBaseLocalAppIdentityProvider(repository); } diff --git a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java index e540691..72917c5 100644 --- a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java +++ b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java @@ -1,9 +1,14 @@ package com.yunhai.userbase.config; import com.yunhai.platform.appauth.AppAuthFilter; +import com.yunhai.platform.appauth.AppAuthProperties; +import com.yunhai.platform.auth.UserDetailsProvider; +import com.yunhai.userbase.service.impl.UserCenterService; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.core.Ordered; import javax.servlet.Filter; @@ -16,31 +21,61 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** - * 强制开放接口必须带应用签名。
+ * 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。
*
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流), * 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**}) * 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性: * 已验签(属性存在)则放行,否则返回 401。
+ * 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。
*
- * 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。 + * + * 额外注册 {@code UserDetailsProvider}(@Bean @Primary,名=userDetailsProvider): + * 覆盖 yh-auth-spring-boot-starter 新版默认的 NoOpUserDetailsProvider(调用直接抛 + * UnsupportedOperationException「未配置 UserDetailsProvider」)。必须「显式 @Bean + * + @Primary」 + * 而不能只靠 @Component 扫描:starter 新版 AutoConfiguration 运行顺序可能早于 UserBase 组件扫描, + * 导致其 @ConditionalOnMissingBean 抢先注册 NoOp,PathBasedAuthInterceptor 注入后首次请求即抛错。 */ @Configuration +@EnableConfigurationProperties({ SignKeyInternalProperties.class, AppAuthProperties.class }) public class OpenApiAuthEnforceConfiguration { @Bean public FilterRegistrationBean requireAppSignFilter() { FilterRegistrationBean bean = new FilterRegistrationBean<>(); bean.setFilter(new RequireAppSignFilter()); - // 两个路径体系必须全部强制验签: - // 1) /open/api/* : 过渡期旧路径(客户端 404 自动回退时会用) - // 2) /oapi/* : 新统一开放前缀(覆盖 /oapi/user/、/oapi/internal/、未来 /oapi/app/ - // /oapi/group/ 等) bean.addUrlPatterns("/open/api/*", "/oapi/*"); bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11); return bean; } + /** + * 内部开放接口(/oapi/internal/*)来源 IP 白名单: + * 在验签过滤器之前先执行(HIGHEST_PRECEDENCE + 9),先卡掉公网来源再进行后续处理。 + * 配置项 yh.sign-key-internal.ips / enabled 可由 Nacos 下发。 + */ + @Bean + public FilterRegistrationBean signKeyInternalIpFilter( + SignKeyInternalProperties properties) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new SignKeyInternalIpFilter(properties)); + bean.addUrlPatterns("/oapi/internal/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 9); + return bean; + } + + /** + * UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。
+ * 加 {@code @Primary} 稳定覆盖 yh-auth 默认 NoOp 实现;bean 名固定=userDetailsProvider, + * 与 starter 默认同名,即使开启 allow-bean-definition-overriding=false 也能优先匹配。 + */ + @Bean(name = "userDetailsProvider") + @Primary + public UserDetailsProvider userDetailsProvider(UserCenterService userCenterService) { + return new UserBaseUserDetailsProvider(userCenterService); + } + public static class RequireAppSignFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java index 46bcaed..3f07052 100644 --- a/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java +++ b/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java @@ -9,12 +9,15 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** - * UserBase 作为「应用密钥权威源」的验签密钥提供方。

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

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

+ * 兜底,使其即便尚未落库也可验签,避免「默认 appKey 未建行」导致验签失败。
+ *
+ * 带 60s 本地 TTL 缓存,避免每次验签直打库(密钥变更最长 60s 后生效)。
+ *
* 这样 UserBase 作为颁发方不依赖 Redis 预热、也不局限于 trustedApps 写死的少量密钥, * 能够验签所有它已颁发的应用。 */ @@ -59,6 +62,22 @@ public class UserBaseAppSecretProvider implements AppSecretProvider { return secret; } + /** + * 主动失效:立刻清除本地缓存中指定 appKey 的条目,密钥重置后调用 → 下一次 getSecret 直查 DB 拿新值, + * 不再受最长 60s TTL 生效窗口限制。 + */ + public void invalidate(String appKey) { + if (appKey == null) { + return; + } + cache.remove(appKey); + } + + /** 主动失效:立刻清空全部本地缓存(refreshAll / Redis 全量对账时调用)。 */ + public void invalidateAll() { + cache.clear(); + } + private static Integer parse(String appKey) { try { return Integer.parseInt(appKey.trim()); diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java index b027951..1c553ac 100644 --- a/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java +++ b/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java @@ -51,6 +51,14 @@ public class UserBaseLocalAppIdentityProvider implements LocalAppIdentityProvide return secret; } + /** + * 主动失效:立刻清除 UserBase 自身(appKey=100000)的本地缓存,密钥重置后调用 → + * 下一次 getAppSecret 直查 DB 拿新值,不再受最长 60s TTL 生效窗口限制。 + */ + public void invalidate() { + cache.remove(USER_CENTER_APP_KEY); + } + private static final class CacheEntry { final long ts; final String secret; diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java index 96e2a65..6de8f35 100644 --- a/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java +++ b/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java @@ -2,22 +2,25 @@ 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; +import com.yunhai.userbase.service.impl.UserCenterService; /** * 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后, * 由本类查 UserBase 自身库,组装成基础平台统一的 {@link BoaUser}。

- * 以 {@code @Component} 注册为 Bean,覆盖 yh-auth 默认的 NoOp 实现(@ConditionalOnMissingBean)。 - * 注意:UserBase 自身不引入 yh-openapi-spring-boot-starter,故 openApiUserDetailsProvider 不会被创建, - * 不会形成「用户中心回调自身」的环。 + * + * 本类不再加 {@code @Component},改为由 {@link OpenApiAuthEnforceConfiguration} + * 显式以 {@code @Bean @Primary} 注册(bean 名固定为 {@code userDetailsProvider}), + * 原因:yh-auth-spring-boot-starter 新版 AutoConfiguration 处理顺序可能早于 UserBase 组件扫描, + * 导致其默认注册的 {@code NoOpUserDetailsProvider}(直接抛 UnsupportedOperationException) + * 抢先被 PathBasedAuthInterceptor 注入。显式 @Bean+@Primary 可稳定覆盖默认实现。 */ -@Component public class UserBaseUserDetailsProvider implements UserDetailsProvider { - @Autowired - private UserCenterService userCenterService; + private final UserCenterService userCenterService; + + public UserBaseUserDetailsProvider(UserCenterService userCenterService) { + this.userCenterService = userCenterService; + } @Override public BoaUser loadByLoginName(String loginName) { diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java index 2c57439..b693763 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java @@ -20,7 +20,7 @@ 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.service.impl.SignKeyCacheService; import com.yunhai.userbase.utils.PageUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Sort; diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java index 4dfc1d8..bb31fae 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -1,20 +1,31 @@ package com.yunhai.userbase.controller.api; +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.exception.ErrorCode; 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.BatchChangeStatusRequest; +import com.yunhai.userbase.model.sysuser.BatchDeleteRequest; +import com.yunhai.userbase.model.sysuser.BatchImportResult; +import com.yunhai.userbase.model.sysuser.ChangeGroupsRequest; +import com.yunhai.userbase.model.sysuser.ChangeSortNoRequest; 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.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; /** * @author chenx @@ -31,6 +42,7 @@ public class SysUserController { @OperationLog("新增用户") @PostMapping("Add") public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { + List data=new ArrayList<>(); sysUserService.add(request); } @@ -46,6 +58,24 @@ public class SysUserController { sysUserService.delete(id); } + /** + * 将用户从指定组中移除;若该用户无其它组关联则级联删除用户。 + */ + @OperationLog("从组中删除用户") + @PostMapping("DeleteFromGroup/{userId}/{groupId}") + public void deleteFromGroup(@PathVariable Integer userId, @PathVariable Integer groupId) { + sysUserService.deleteFromGroup(userId, groupId); + } + + /** + * 批量从指定组中移除用户;无其它组关联者级联删除用户。 + */ + @OperationLog("批量从组中删除用户") + @PostMapping("BatchDelete") + public void batchDelete(@RequestBody @Validated BatchDeleteRequest request) { + sysUserService.batchDeleteFromGroup(request.getUserIds(), request.getGroupId()); + } + /** 禁用用户(disabled=true) */ @OperationLog("禁用用户") @PostMapping("Disable/{id}") @@ -60,9 +90,63 @@ public class SysUserController { sysUserService.changeStatus(id, false); } + /** 批量设置用户状态 */ + @OperationLog("批量设置用户状态") + @PostMapping("BatchChangeStatus") + public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) { + sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled()); + } + @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))); + public BasePageResult list(SysUserQuery query, BasePageQuery pageQuery) { + // 排序字段在关联表,无法走 Pageable.sort,统一由 Service.Specification 控制 + // (根节点按 id 升序,具体组按组内 sort_no 升序)。此处不传 Sort,避免覆盖 Spec 的 orderBy。 + return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, null))); + } + + /** 调整用户在某组内的排序号(sort_no):1~999,可空表示清除 */ + @OperationLog("设置用户组内排序号") + @PostMapping("ChangeSortNo/{userId}") + public void changeSortNo(@PathVariable Integer userId, + @RequestBody @Validated ChangeSortNoRequest request) { + sysUserService.changeSortNo(userId, request.getGroupId(), request.getSortNo()); + } + + @OperationLog("调整组") + @PostMapping("ChangeGroups") + public void changeGroups(@RequestBody @Validated ChangeGroupsRequest request) { + sysUserService.changeGroups(request.getUserIds(), request.getGroupIds()); + } + + @GetMapping("Groups/{userId}") + public List getUserGroups(@PathVariable Integer userId) { + return sysUserService.findGroupIdsByUserId(userId); + } + + @OperationLog("批量导入用户") + @PostMapping("BatchImport") + public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) { + return sysUserService.batchImport(file); + } + + /** + * 下载用户批量导入模板(真实 .xlsx):平铺当前组及子孙组,仅填充组名(路径)与组id;其余列留空供填写。 + */ + @GetMapping("DownloadTemplate") + public void downloadTemplate(@RequestParam(value = "groupId", required = false) Integer groupId, + HttpServletResponse response) throws IOException { + sysUserService.downloadImportTemplate(groupId, response); + } + + /** + * 导出组下所有用户(含子组)为 Excel;与导入模板同构,末尾追加用户id列。 + * 前端直接跳转此地址触发浏览器下载。 + */ + @GetMapping("Export") + public void export(@RequestParam(value = "groupId", required = false) Integer groupId, + @RequestParam(value = "withSub", defaultValue = "1") Integer withSub, + @RequestParam(value = "name", required = false) String name, + HttpServletResponse response) throws IOException { + sysUserService.exportUsers(groupId, withSub, name, response); } } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index 7fddf7e..dacc9b4 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -8,7 +8,7 @@ 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.service.IGroupAuthzService; -import com.yunhai.userbase.service.UserCenterService; +import com.yunhai.userbase.service.impl.UserCenterService; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/yunhai/userbase/entity/SysUser.java b/src/main/java/com/yunhai/userbase/entity/SysUser.java index 1103b13..6a1c6ed 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUser.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUser.java @@ -40,6 +40,10 @@ public class SysUser extends BaseEntity { private String idcard; + private String email; + + private Integer gender; + //头像 private String profilePicture; diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java index 4e483b3..de68291 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java @@ -32,6 +32,10 @@ public class SysUserGroupRel { /** 是否主归属 1=本职主学校/部门 0=兼职兼任 */ private Integer isMain; + /** 组内排序号:越小越靠前;null 表示未设置,列表按 user_id 兜底升序 */ + @Column(name = "sort_no") + private Integer sortNo; + private LocalDateTime createTime; // 关联用户,仅查询,不维护外键 diff --git a/src/main/java/com/yunhai/userbase/event/ApplicationInfoCacheChangedEvent.java b/src/main/java/com/yunhai/userbase/event/ApplicationInfoCacheChangedEvent.java new file mode 100644 index 0000000..ac0aabf --- /dev/null +++ b/src/main/java/com/yunhai/userbase/event/ApplicationInfoCacheChangedEvent.java @@ -0,0 +1,45 @@ +package com.yunhai.userbase.event; + +import com.yunhai.userbase.entity.ApplicationInfo; +import org.springframework.context.ApplicationEvent; + +/** + * 应用密钥/元数据变更事件:在 ApplicationInfoService 的 save/add/edit/delete/resetSecret 等事务内发布, + * 由 SignKeyCacheService 的 @TransactionalEventListener(phase = AFTER_COMMIT 订阅, + * 保证 DB 真正提交成功后再写共享 Redis / 清本地缓存,避免事务回滚导致的脏数据。 + */ +public class ApplicationInfoCacheChangedEvent extends ApplicationEvent { + + public enum Type { + UPSERT, DELETE + } + + private final Type type; + private final Integer appKey; + + /** + * 删除事件:appKey 必填;upsert 事件:可以传 entity 非空。 + */ + private final ApplicationInfo upsertEntity; + + public ApplicationInfoCacheChangedEvent(Object source, Type type, Integer appKey, ApplicationInfo upsertEntity) { + super(source); + this.type = type; + this.appKey = appKey; + this.upsertEntity = upsertEntity; + } + + public static ApplicationInfoCacheChangedEvent upsert(Object source, ApplicationInfo entity) { + return new ApplicationInfoCacheChangedEvent(source, Type.UPSERT, entity.getAppKey(), entity); + } + + public static ApplicationInfoCacheChangedEvent delete(Object source, Integer appKey) { + return new ApplicationInfoCacheChangedEvent(source, Type.DELETE, appKey, null); + } + + public Type getType() { return type; } + + public Integer getAppKey() { return appKey; } + + public ApplicationInfo getUpsertEntity() { return upsertEntity; } +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/BatchImportResult.java b/src/main/java/com/yunhai/userbase/model/sysuser/BatchImportResult.java new file mode 100644 index 0000000..f479d3a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/BatchImportResult.java @@ -0,0 +1,47 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author chenx + * @date 2026/8/11 + * @description 用户批量导入结果 + */ +@Data +public class BatchImportResult { + + /** 总行数(含表头) */ + private int total; + + /** 成功条数 */ + private int success; + + /** 失败条数 */ + private int fail; + + /** 失败明细:行号 + 原因 */ + private List fails = new ArrayList<>(); + + @Data + public static class FailItem { + private int row; + private String reason; + + public FailItem(int row, String reason) { + this.row = row; + this.reason = reason; + } + } + + public void addSuccess() { + this.success++; + } + + public void addFail(int row, String reason) { + this.fail++; + this.fails.add(new FailItem(row, reason)); + } +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java new file mode 100644 index 0000000..5c15b38 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java @@ -0,0 +1,23 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import java.util.List; + +/** + * @author chenx + * @date 2026/8/11 + * @description 批量调整用户所属组请求 + */ +@Data +public class ChangeGroupsRequest { + + /** 待调整用户ID列表 */ + @NotEmpty(message = "待调整用户不能为空") + private List userIds; + + /** 目标组ID列表(覆盖式) */ + @NotEmpty(message = "请至少保留一个组") + private List groupIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/ExcelSysUser.java b/src/main/java/com/yunhai/userbase/model/sysuser/ExcelSysUser.java new file mode 100644 index 0000000..675e952 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/ExcelSysUser.java @@ -0,0 +1,53 @@ +package com.yunhai.userbase.model.sysuser; + +import cn.afterturn.easypoi.excel.annotation.Excel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 组用户导出 Excel 实体:与导入模板 {@code IMPORT_COLUMN_DEFS} 同构(前 12 列顺序一致), + * 末尾追加「用户id」列。导出 = 读取数据库数据填充 list,复用 EasyPoi 的 {@code @Excel} 注解生成 Excel。 + */ +@Data +public class ExcelSysUser implements Serializable { + + @Excel(name = "组名\n(不必填,供与id对应参考用)", orderNum = "0", width = 36) + private String groupName; + + @Excel(name = "组id(必须)", orderNum = "1", width = 14) + private String groupId; + + @Excel(name = "用户名(必须)", orderNum = "2", width = 20) + private String account; + + @Excel(name = "密码\n(可选,\n默认Vzuk4Dp3)", orderNum = "3", width = 18) + private String password; + + @Excel(name = "姓名(必须)", orderNum = "4", width = 16) + private String realName; + + @Excel(name = "身份证号(可选)", orderNum = "5", width = 22) + private String idcard; + + @Excel(name = "手机号(可选)", orderNum = "6", width = 16) + private String phone; + + @Excel(name = "email(可选)", orderNum = "7", width = 24) + private String email; + + @Excel(name = "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", orderNum = "8", width = 26) + private Integer nature; + + @Excel(name = "用户身份\n(1教师 2教职工 3学生 4家长)", orderNum = "9", width = 24) + private Integer identity; + + @Excel(name = "性别(可选)", orderNum = "10", width = 12) + private Integer gender; + + @Excel(name = "生日(yyyy-MM-dd)(可选)", orderNum = "11", width = 22) + private String birthday; + + @Excel(name = "用户id", orderNum = "12", width = 14) + private Integer userId; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java index d94d80b..ccdf606 100644 --- a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java @@ -35,6 +35,8 @@ public class SysUserRequest { private String phone; private LocalDate birthday; private String idcard; + private String email; + private Integer gender; private String profilePicture; private List roles; /** 启用状态:对应 StatusEnum 的 key(0=正常/未禁用,1=禁用);不传则默认启用 */ diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java index 73c0206..3dee203 100644 --- a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java @@ -19,12 +19,16 @@ public class SysUserVo { private String realName; private String phone; private String idcard; + private String email; + private Integer gender; private String profilePicture; private List roles; /** 启用状态:StatusEnum 的 key(0=正常/未禁用,1=禁用) */ private Integer disabled; /** 用户类型:UserTypeEnum 的 key(0=普通用户,1=超级管理员,2=学校管理员) */ private Integer type; + /** 当前组维度的排序号(组内 sort_no);根节点/未选组时为 null */ + private Integer sortNo; private Boolean expired; private LocalDateTime createTime; private LocalDateTime updateTime; diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java index 8152e53..0a53aff 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import java.util.List; +import java.util.Optional; /** * @author chenx @@ -19,7 +20,15 @@ public interface SysUserGroupRelRepository extends JpaRepository findByUserId(Integer userId); + List findByUserIdIn(List userIds); + + List findByUserIdInAndGroupId(List userIds, Integer groupId); + + Optional findByUserIdAndGroupId(Integer userId, Integer groupId); + List findByGroupId(Integer groupId); void deleteByUserId(Integer userId); + + void deleteByUserIdAndGroupId(Integer userId, Integer groupId); } diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index d76f50a..ff40624 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -1,11 +1,17 @@ package com.yunhai.userbase.service; +import com.yunhai.userbase.model.sysuser.BatchImportResult; import com.yunhai.userbase.model.sysuser.SysUserQuery; import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.model.sysuser.SysUserVo; +import org.springframework.web.multipart.MultipartFile; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; + /** * @author chenx * @date 2026/7/20 16:14 @@ -22,6 +28,22 @@ public interface ISysUserService { /** 删除用户:同时清理其组用户关联。 */ void delete(Integer id); + /** + * 把用户从指定组中移除;若移除后该用户已无其它组关联,则级联删除用户本身。 + * + * @param userId 用户ID + * @param groupId 当前组ID + */ + void deleteFromGroup(Integer userId, Integer groupId); + + /** + * 批量把用户从指定组中移除;无其它组关联者级联删除用户本身。 + * + * @param userIds 用户ID列表 + * @param groupId 当前组ID + */ + void batchDeleteFromGroup(List userIds, Integer groupId); + /** * 启用/禁用用户。
* @@ -30,6 +52,14 @@ public interface ISysUserService { */ void changeStatus(Integer id, boolean disabled); + /** + * 批量启用/禁用用户。 + * + * @param userIds 用户ID列表 + * @param disabled true=禁用,false=启用 + */ + void batchChangeStatus(List userIds, boolean disabled); + /** * 分页查询组下用户。
* 依据 query.groupId 与 query.withSub 决定组范围,query.name 做多字段匹配。
@@ -39,4 +69,59 @@ public interface ISysUserService { * @return 用户分页 */ Page list(SysUserQuery query, Pageable pageable); + + /** + * 查询指定用户当前所属的所有组ID。 + * + * @param userId 用户ID + * @return 组ID列表 + */ + List findGroupIdsByUserId(Integer userId); + + /** + * 批量调整用户所属组:覆盖式更新 sys_user_group_rel。
+ * 同一批用户统一设置为目标组列表,第一个组设为主归属,其余为兼职。 + * + * @param userIds 待调整用户ID列表 + * @param groupIds 目标组ID列表 + */ + void changeGroups(List userIds, List groupIds); + + /** + * 调整用户在某组内的排序号(sort_no)。
+ * sortNo 为 null 时清除排序号,列表回退到 user_id 升序兜底。 + * + * @param userId 用户ID + * @param groupId 组ID(排序号按组维度,必须落到具体组) + * @param sortNo 排序号(1~999),可空表示清除 + */ + void changeSortNo(Integer userId, Integer groupId, Integer sortNo); + + /** + * 批量导入用户:按模板解析 Excel,逐行校验并新增。 + * 失败行继续处理,返回成功/失败统计及失败原因。 + * + * @param file Excel 文件 + * @return 导入结果 + */ + BatchImportResult batchImport(MultipartFile file); + + /** + * 下载用户批量导入模板(真实 .xlsx):平铺指定组及其所有子孙组,仅填充组名(路径)与组id。 + * + * @param groupId 当前组ID(可空,空则取组织树根) + * @param response 直接写入 Excel 流 + */ + void downloadImportTemplate(Integer groupId, HttpServletResponse response) throws IOException; + + /** + * 导出组下所有用户(含子组)为 Excel:与导入模板同构,末尾追加「用户id」列。
+ * 组名 / 组id 取每个用户实际所属的主组(isMain=1);密码为密文不可逆,留空。 + * + * @param groupId 当前组ID(可空,空则取组织树根) + * @param withSub 是否包含子组:1=含所有子孙组;其它=仅当前组 + * @param name 关键字(可选) + * @param response 直接写入 Excel 流 + */ + void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException; } diff --git a/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java b/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java deleted file mode 100644 index f5f259e..0000000 --- a/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java +++ /dev/null @@ -1,151 +0,0 @@ -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/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index 0c6055d..0bedc80 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -11,17 +11,18 @@ 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.yunhai.platform.utils.exception.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.context.ApplicationEventPublisher; 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 com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeanUtils; import org.springframework.data.domain.Page; @@ -38,7 +39,6 @@ import java.util.List; import java.util.Objects; import java.util.UUID; - @Service public class ApplicationInfoService implements IApplicationInfoService { @@ -66,6 +66,9 @@ public class ApplicationInfoService implements IApplicationInfoService { @Autowired private SignKeyCacheService signKeyCacheService; + @Autowired + private ApplicationEventPublisher eventPublisher; + /** * 查询缓存:sync = true 防击穿;底层优先 Redis,Redis 故障时自动降级 Caffeine。 */ @@ -81,7 +84,9 @@ public class ApplicationInfoService implements IApplicationInfoService { @Override @CachePut(value = "appInfo", key = "#info.id") public ApplicationInfo save(ApplicationInfo info) { - return applicationInfoRepository.save(info); + ApplicationInfo saved = applicationInfoRepository.save(info); + eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); + return saved; } /** @@ -90,7 +95,11 @@ public class ApplicationInfoService implements IApplicationInfoService { @Override @CacheEvict(value = "appInfo", key = "#id") public void deleteById(Integer id) { + ApplicationInfo existing = applicationInfoRepository.findById(id).orElse(null); applicationInfoRepository.deleteById(id); + if (existing != null && existing.getAppKey() != null) { + eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.delete(this, existing.getAppKey())); + } } @Override @@ -111,7 +120,7 @@ public class ApplicationInfoService implements IApplicationInfoService { // appSecret:32 位 UUID(去除连字符) entity.setAppSecret(UUID.randomUUID().toString().replace("-", "")); ApplicationInfo saved = applicationInfoRepository.save(entity); - signKeyCacheService.syncOne(saved); + eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); } @Override @@ -127,7 +136,7 @@ public class ApplicationInfoService implements IApplicationInfoService { .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret"); ApplicationInfo saved = applicationInfoRepository.save(entity); - signKeyCacheService.syncOne(saved); + eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); } @Override @@ -164,7 +173,7 @@ public class ApplicationInfoService implements IApplicationInfoService { // 先删模块(整棵子树随 appId 一并清除),再删应用,避免残留孤儿数据 appModuleRepository.deleteByAppId(id); applicationInfoRepository.deleteById(id); - signKeyCacheService.removeOne(appKey); + eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.delete(this, appKey)); } @Override @@ -223,7 +232,7 @@ public class ApplicationInfoService implements IApplicationInfoService { .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); entity.setAppSecret(UUID.randomUUID().toString().replace("-", "")); ApplicationInfo saved = applicationInfoRepository.save(entity); - signKeyCacheService.syncOne(saved); + eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); } @Override diff --git a/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java b/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java new file mode 100644 index 0000000..3463459 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java @@ -0,0 +1,275 @@ +package com.yunhai.userbase.service.impl; + +import com.alibaba.fastjson.JSON; +import com.yunhai.userbase.config.UserBaseAppSecretProvider; +import com.yunhai.userbase.config.UserBaseLocalAppIdentityProvider; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +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(等价于禁用)。
+ *
+ * 变更同步说明(IDEA 灰色=正常):本类的 {@code onCacheChanged(...)} 由 Spring 容器在运行时通过 + * 反射注册为事务监听器({@code @TransactionalEventListener}),不会在 IDE 的静态调用链中直接显示 + * 引用,属正常现象。真实触发点在 + * {@link com.yunhai.userbase.service.impl.ApplicationInfoService} + * 的增/改/删/重置密钥、以及 + * 通用 save/deleteById 方法末尾,统一发布 {@link ApplicationInfoCacheChangedEvent}。 + */ +@Service +public class SignKeyCacheService implements ApplicationRunner, ApplicationContextAware { + + private static final Logger log = LoggerFactory.getLogger(SignKeyCacheService.class); + + /** Redis key 前缀;读侧 SignKeyResolver 必须保持一致:sign:app:{appKey} */ + public static final String KEY_PREFIX = "sign:app:"; + + /** UserBase 本身作为调用方(出站签名)时固定使用的基石应用 appKey,对应 100000 这条初始化记录 */ + public static final String USER_BASE_APP_KEY = "100000"; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + private ApplicationContext applicationContext; + + /** + * 事务提交后的安全订阅:只有 ApplicationInfoService 对应事务真正 commit 后, + * 才会去写共享 Redis / 清本地缓存,避免事务回滚导致的 DB-Redis 数据不一致。 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCacheChanged(ApplicationInfoCacheChangedEvent event) { + if (event.getType() == ApplicationInfoCacheChangedEvent.Type.DELETE) { + removeOne(event.getAppKey()); + return; + } + if (event.getUpsertEntity() != null) { + syncOne(event.getUpsertEntity()); + } + } + + /** + * 同步单条应用到 Redis(新增 / 编辑 / 重置密钥时调用)。 + *

+ * 同步前主动失效本机的本地缓存,保证密钥变更后本机入站验签 / 出站签名 0 窗口生效。 + */ + 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)); + invalidateLocal(info.getAppKey()); + } + + /** + * 从 Redis 移除某应用的密钥(删除应用时调用,等价于禁用该应用签名)。 + * 删除前同步失效本机本地缓存。 + */ + public void removeOne(Integer appKey) { + if (appKey == null) { + return; + } + stringRedisTemplate.delete(KEY_PREFIX + appKey); + invalidateLocal(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); + } + } + } + // 全量刷新后清空本地缓存,保证之前残留的任何旧密钥在下一次 getSecret 时重新查 DB 获取新值 + invalidateAllLocal(); + log.info("[SignKeyCache] refreshAll done, synced={}", dbKeys.size()); + return dbKeys.size(); + } + + /** + * 3A 方案兜底:每日对账定时任务已迁移到 + * {@link com.yunhai.userbase.task.SignKeyCacheDailyTask#syncAll()}。 + * 保持 Service 层单纯(CRUD 同步 + 启动预热),定时任务统一放在 task 包,便于运维集中查看。 + * + * @deprecated 请使用 task 包下的 SignKeyCacheDailyTask.syncAll() 每日对账。 + * 本方法保留仅为兼容手动调用场景。 + */ + @Deprecated + public void scheduledRefreshAll() { + try { + int n = refreshAll(); + log.info("[SignKeyCache] scheduled daily refreshAll done, synced={}", n); + } catch (Exception e) { + log.warn("[SignKeyCache] scheduled daily refreshAll failed: {}", e.getMessage()); + } + } + + /** + * 启动预热:应用启动后尽力把密钥同步到 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()); + } + } + + /** + * 失效本机本地缓存:变更某应用密钥/删除该应用时, + * 同时失效 AppSecretProvider(入站验签读的缓存)和若其为基石应用则失效出站签名缓存。 + */ + public void invalidateLocal(Integer appKey) { + if (appKey == null) { + return; + } + try { + Map providers = applicationContext + .getBeansOfType(UserBaseAppSecretProvider.class); + if (providers != null && !providers.isEmpty()) { + for (UserBaseAppSecretProvider p : providers.values()) { + p.invalidate(String.valueOf(appKey)); + } + } + } catch (Exception ignored) { + } + if (USER_BASE_APP_KEY.equals(String.valueOf(appKey))) { + try { + Map locals = applicationContext + .getBeansOfType(UserBaseLocalAppIdentityProvider.class); + if (locals != null && !locals.isEmpty()) { + for (UserBaseLocalAppIdentityProvider p : locals.values()) { + p.invalidate(); + } + } + } catch (Exception ignored) { + } + } + } + + /** 全量对账/预热时,直接清空本机的所有密钥缓存,避免残留。 */ + public void invalidateAllLocal() { + try { + Map providers = applicationContext + .getBeansOfType(UserBaseAppSecretProvider.class); + if (providers != null && !providers.isEmpty()) { + for (UserBaseAppSecretProvider p : providers.values()) { + p.invalidateAll(); + } + } + } catch (Exception ignored) { + } + try { + Map locals = applicationContext + .getBeansOfType(UserBaseLocalAppIdentityProvider.class); + if (locals != null && !locals.isEmpty()) { + for (UserBaseLocalAppIdentityProvider p : locals.values()) { + p.invalidate(); + } + } + } catch (Exception ignored) { + } + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + /** + * 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/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 18a8221..5c1c3c5 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -1,12 +1,14 @@ package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.entity.SysGroup; 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.BatchImportResult; import com.yunhai.userbase.model.sysuser.SysUserQuery; import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.model.sysuser.SysUserVo; @@ -16,6 +18,11 @@ 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 cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; +import com.yunhai.userbase.model.sysuser.ExcelSysUser; +import com.yunhai.userbase.utils.EasyPoiUtils; import com.yunhai.userbase.utils.ValidatorUtil; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeanUtils; @@ -26,16 +33,28 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.persistence.criteria.Join; +import javax.persistence.criteria.JoinType; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import javax.persistence.criteria.Subquery; -import javax.transaction.Transactional; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +import org.springframework.transaction.annotation.Transactional; + +import javax.servlet.http.HttpServletResponse; /** * @author chenx @@ -73,6 +92,18 @@ public class SysUserService implements ISysUserService { @Override @Transactional public void add(SysUserRequest request) { + // 单条新增走完整校验(含密码强度) + createUser(request, true); + } + + /** + * 创建用户并写入组关联。 + * + * @param enforcePasswordStrength 是否强制密码强度校验: + * - 单条新增 = true(前端已交互式校验); + * - 批量导入 = false(默认密码 Vzuk4Dp3 不含符号,按业务约定直接放行)。 + */ + private void createUser(SysUserRequest request, boolean enforcePasswordStrength) { if (sysUserRepository.countByAccount(request.getAccount()) > 0) { throw new DException("账号已存在"); } @@ -80,7 +111,9 @@ public class SysUserService implements ISysUserService { throw new DException("所属分组不存在"); } validateProfile(request); - String encodedPassword = validateAndEncodePassword(request.getPassword(), request.getConfirmPassword()); + String encodedPassword = enforcePasswordStrength + ? validateAndEncodePassword(request.getPassword(), request.getConfirmPassword()) + : passwordEncoder.encode(request.getPassword()); SysUser entity = new SysUser(); entity.setAccount(request.getAccount()); @@ -89,6 +122,8 @@ public class SysUserService implements ISysUserService { entity.setPhone(request.getPhone()); entity.setBirthday(request.getBirthday()); entity.setIdcard(request.getIdcard()); + entity.setEmail(request.getEmail()); + entity.setGender(request.getGender()); entity.setProfilePicture(request.getProfilePicture()); entity.setRoles(request.getRoles()); // 用户类型推导:超级管理员账号固定为 1;其余含 ADMIN(7) 角色 -> 2 学校管理员;默认 0 普通用户 @@ -126,6 +161,8 @@ public class SysUserService implements ISysUserService { entity.setPhone(request.getPhone()); entity.setBirthday(request.getBirthday()); entity.setIdcard(request.getIdcard()); + entity.setEmail(request.getEmail()); + entity.setGender(request.getGender()); entity.setProfilePicture(request.getProfilePicture()); entity.setRoles(request.getRoles()); // 用户类型推导:仅当本次传入了角色时才重算,避免编辑时未传角色把 type 误重置为 0 @@ -154,6 +191,38 @@ public class SysUserService implements ISysUserService { sysUserRepository.deleteById(id); } + @Override + @Transactional + public void deleteFromGroup(Integer userId, Integer groupId) { + batchDeleteFromGroup(Collections.singletonList(userId), groupId); + } + + @Override + @Transactional + public void batchDeleteFromGroup(List userIds, Integer groupId) { + if (userIds == null || userIds.isEmpty()) { + throw new DException("待删除用户不能为空"); + } + if (groupId == null) { + throw new DException("组ID不能为空"); + } + + List users = sysUserRepository.findAllById(userIds); + if (users.size() != userIds.size()) { + throw new DException("存在无效用户"); + } + + for (Integer userId : userIds) { + // 删除当前组关联 + sysUserGroupRelRepository.deleteByUserIdAndGroupId(userId, groupId); + // 若用户已无其它组关联,则级联删除用户本身及其角色绑定 + if (sysUserGroupRelRepository.countByUserId(userId) == 0) { + sysUserAppRoleRelRepository.deleteByUserIdBulk(userId); + sysUserRepository.deleteById(userId); + } + } + } + @Override @Transactional public void changeStatus(Integer id, boolean disabled) { @@ -164,29 +233,396 @@ public class SysUserService implements ISysUserService { sysUserRepository.save(entity); } + @Override + @Transactional + public void batchChangeStatus(List userIds, boolean disabled) { + if (userIds == null || userIds.isEmpty()) { + throw new DException("待设置用户不能为空"); + } + List users = sysUserRepository.findAllById(userIds); + if (users.size() != userIds.size()) { + throw new DException("存在无效用户"); + } + StatusEnum targetStatus = disabled ? StatusEnum.DISABLED : StatusEnum.NORMAL; + for (SysUser user : users) { + user.setDisabled(targetStatus); + } + sysUserRepository.saveAll(users); + } + + @Override + @Transactional + public void changeSortNo(Integer userId, Integer groupId, Integer sortNo) { + if (!sysUserRepository.existsById(userId)) { + throw new DException("系统用户不存在"); + } + // 排序号按组维度存储,必须落到具体的 (userId, groupId) 关联行 + SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId) + .orElseThrow(() -> new DException("用户不在该组,无法设置排序号")); + rel.setSortNo(sortNo); + sysUserGroupRelRepository.save(rel); + } + @Override public Page list(SysUserQuery query, Pageable pageable) { Specification spec = buildSpecification(query); - return sysUserRepository.findAll(spec, pageable).map(this::convertToVo); + Page page = sysUserRepository.findAll(spec, pageable); + // 把当前组维度的 sortNo 带到 Vo(仅在具体组上下文有意义;根节点/未选组时为 null) + if (query.getGroupId() != null) { + Map sortNoMap = resolveUserSortNoInGroup( + page.getContent().stream().map(SysUser::getId).collect(Collectors.toList()), + query.getGroupId()); + return page.map(u -> convertToVo(u, sortNoMap.get(u.getId()))); + } + return page.map(u -> convertToVo(u, null)); + } + + @Override + public List findGroupIdsByUserId(Integer userId) { + return sysUserGroupRelRepository.findByUserId(userId).stream() + .map(SysUserGroupRel::getGroupId) + .collect(Collectors.toList()); + } + + @Override + @Transactional + public void changeGroups(List userIds, List groupIds) { + if (userIds == null || userIds.isEmpty()) { + throw new DException("待调整用户不能为空"); + } + if (groupIds == null || groupIds.isEmpty()) { + throw new DException("请至少保留一个组"); + } + + List users = sysUserRepository.findAllById(userIds); + if (users.size() != userIds.size()) { + throw new DException("存在无效用户"); + } + List groups = sysGroupRepository.findAllById(groupIds); + if (groups.size() != groupIds.size()) { + throw new DException("存在无效分组"); + } + + LocalDateTime now = LocalDateTime.now(); + for (Integer userId : userIds) { + sysUserGroupRelRepository.deleteByUserId(userId); + List rels = new ArrayList<>(groupIds.size()); + for (int i = 0; i < groupIds.size(); i++) { + SysUserGroupRel rel = new SysUserGroupRel(); + rel.setUserId(userId); + rel.setGroupId(groupIds.get(i)); + rel.setIsMain(i == 0 ? IS_MAIN : 0); + rel.setCreateTime(now); + rels.add(rel); + } + sysUserGroupRelRepository.saveAll(rels); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public BatchImportResult batchImport(MultipartFile file) { + BatchImportResult result = new BatchImportResult(); + if (file == null || file.isEmpty()) { + throw new DException("上传文件不能为空"); + } + + Workbook workbook = null; + try { + workbook = WorkbookFactory.create(file.getInputStream()); + Sheet sheet = workbook.getSheetAt(0); + int lastRowNum = sheet.getLastRowNum(); + + for (int i = 1; i <= lastRowNum; i++) { + Row row = sheet.getRow(i); + if (row == null || isBlankRow(row)) { + continue; + } + try { + SysUserRequest request = parseImportRow(row); + createUser(request, false); + result.addSuccess(); + } catch (DException e) { + result.addFail(i + 1, e.getMessage()); + } catch (Exception e) { + result.addFail(i + 1, "系统异常:" + e.getMessage()); + } + } + // 仅统计实际参与解析的数据行(跳过空行),使 总数 = 成功 + 失败 + result.setTotal(result.getSuccess() + result.getFail()); + } catch (Exception e) { + throw new DException("文件读取失败:" + e.getMessage()); + } finally { + if (workbook != null) { + try { + workbook.close(); + } catch (IOException ignored) { + // 关闭资源失败不阻断主流程 + } + } + } + return result; + } + + // ===================== 导入模板下载 ===================== + + /** + * 导入/导出共用的列定义(顺序即 Excel 列顺序)。
+ * 导入模板生成与(后续)导出复用同一套,避免两边列顺序/文案漂移。 + */ + private static final List IMPORT_COLUMN_DEFS = Arrays.asList( + new ImportColumnDef("groupName", "组名\n(不必填,供与id对应参考用)", 36, false, null), + new ImportColumnDef("groupId", "组id(必须)", 14, true, null), + new ImportColumnDef("account", "用户名(必须)", 20, true, null), + new ImportColumnDef("password", "密码\n(可选,\n默认Vzuk4Dp3)", 18, false, ""), + new ImportColumnDef("realName", "姓名(必须)", 16, true, null), + new ImportColumnDef("idcard", "身份证号(可选)", 22, false, null), + new ImportColumnDef("phone", "手机号(可选)", 16, false, null), + new ImportColumnDef("email", "email(可选)", 24, false, null), + new ImportColumnDef("nature", "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", 24, false, "0"), + new ImportColumnDef("identity", "用户身份\n(1教师 2教职工 3学生 4家长)", 24, false, null), + new ImportColumnDef("gender", "性别(可选)", 12, false, null), + new ImportColumnDef("birthday", "生日(yyyy-MM-dd)(可选)", 22, false, null) + ); + + /** 组织树虚拟根 parentId(与 SysGroupService 约定一致)。 */ + private static final int ROOT_PARENT_ID = 0; + + /** + * 下载用户导入模板:以指定组(含)为根,将其自身及所有子孙组平铺为数据行, + * 仅填充「组名(路径)」与「组id」两列,其余列为空供用户填写。
+ * 生成真实 .xlsx(POI XSSF),可被后端 BatchImport 正确解析(HTML 表格伪装 .xls 无法被 POI 解析)。 + */ + @Override + public void downloadImportTemplate(Integer groupId, HttpServletResponse response) throws IOException { + List groups = buildImportGroupPaths(groupId); + + XSSFWorkbook workbook = new XSSFWorkbook(); + try { + XSSFSheet sheet = workbook.createSheet("用户导入模板"); + + // 表头样式:加粗、居中、自动换行、细边框 + XSSFCellStyle headerStyle = workbook.createCellStyle(); + XSSFFont headerFont = workbook.createFont(); + headerFont.setBold(true); + headerStyle.setFont(headerFont); + headerStyle.setWrapText(true); + headerStyle.setVerticalAlignment(VerticalAlignment.CENTER); + headerStyle.setAlignment(HorizontalAlignment.CENTER); + headerStyle.setBorderTop(BorderStyle.THIN); + headerStyle.setBorderBottom(BorderStyle.THIN); + headerStyle.setBorderLeft(BorderStyle.THIN); + headerStyle.setBorderRight(BorderStyle.THIN); + + XSSFRow headerRow = sheet.createRow(0); + for (int i = 0; i < IMPORT_COLUMN_DEFS.size(); i++) { + ImportColumnDef col = IMPORT_COLUMN_DEFS.get(i); + XSSFCell cell = headerRow.createCell(i); + cell.setCellValue(col.getTitle()); + cell.setCellStyle(headerStyle); + sheet.setColumnWidth(i, col.getWidth() * 256); + } + headerRow.setHeightInPoints(64); + + // 数据行:每个组一行,仅填 组名(路径) + 组id + int r = 1; + for (GroupPath g : groups) { + XSSFRow row = sheet.createRow(r++); + row.createCell(IDX_GROUP_NAME).setCellValue(g.getPath()); + row.createCell(IDX_GROUP_ID).setCellValue(String.valueOf(g.getId())); + } + + String filename = URLEncoder.encode("用户导入模板.xlsx", "UTF-8"); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", + "attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + filename); + try (OutputStream out = response.getOutputStream()) { + workbook.write(out); + } + } finally { + workbook.close(); + } + } + + // ===================== 导出 ===================== + + /** + * 导出组下所有用户(含子组)为 Excel:与导入模板 {@code IMPORT_COLUMN_DEFS} 同构,末尾追加「用户id」列。
+ * - 组名 / 组id:取每个用户实际所属的主组(isMain=1),与导入模板的「参考」列保持一致;
+ * - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);
+ * - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);
+ * - 用户身份 identity:取 roles 数组第一个元素(系统角色枚举 key:1管理员 2老师 3校领导 4局领导 5学生 6家长 7工作号)。 + */ + @Override + public void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException { + // 1. 解析组范围(含子孙)并查询范围内全部用户(不分页) + SysUserQuery query = new SysUserQuery(); + query.setGroupId(groupId); + query.setWithSub(withSub); + query.setName(name); + List users = sysUserRepository.findAll(buildSpecification(query)); + + // 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列 + List userIds = users.stream().map(SysUser::getId).collect(Collectors.toList()); + Map userMainGroup = resolveUserMainGroup(userIds); + + // 3. 批量取组名 + Map groupNameMap = sysGroupRepository.findAllById(userMainGroup.values()) + .stream().collect(Collectors.toMap(SysGroup::getId, SysGroup::getName)); + + // 4. 组装导出行 + List rows = new ArrayList<>(users.size()); + for (SysUser u : users) { + ExcelSysUser row = new ExcelSysUser(); + Integer gid = userMainGroup.get(u.getId()); + row.setGroupId(gid == null ? "" : String.valueOf(gid)); + row.setGroupName(gid != null ? groupNameMap.get(gid) : null); + row.setAccount(u.getAccount()); + row.setPassword(""); // 密文不可逆,导出留空 + row.setRealName(u.getRealName()); + row.setIdcard(u.getIdcard()); + row.setPhone(u.getPhone()); + row.setEmail(u.getEmail()); + row.setNature(u.getType() == null ? null : u.getType().getKey()); + row.setIdentity(u.getRoles() == null || u.getRoles().isEmpty() ? null : u.getRoles().get(0)); + row.setGender(u.getGender()); + row.setBirthday(u.getBirthday() == null ? "" : u.getBirthday().toString()); + row.setUserId(u.getId()); + rows.add(row); + } + + // 5. 写出(HSSF .xls,与 EasyPoiUtils.downLoadExcel 的 .xls 兜底一致) + ExportParams exportParams = new ExportParams("组用户导出", "组用户"); + exportParams.setType(ExcelType.HSSF); + Workbook workbook = ExcelExportUtil.exportExcel(exportParams, ExcelSysUser.class, rows); + EasyPoiUtils.downLoadExcel("组用户导出", response, workbook); + } + + /** + * 批量解析 用户 -> 主组(groupId) 映射:优先取 isMain=1 的组,否则取第一个关联组。 + */ + private Map resolveUserMainGroup(List userIds) { + Map mainGroup = new HashMap<>(); + if (userIds.isEmpty()) { + return mainGroup; + } + List rels = sysUserGroupRelRepository.findByUserIdIn(userIds); + Map> byUser = rels.stream() + .collect(Collectors.groupingBy(SysUserGroupRel::getUserId)); + for (Map.Entry> e : byUser.entrySet()) { + Integer gid = e.getValue().stream() + .filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN) + .map(SysUserGroupRel::getGroupId) + .findFirst() + .orElse(e.getValue().get(0).getGroupId()); + mainGroup.put(e.getKey(), gid); + } + return mainGroup; + } + + /** + * 收集导入模板所需的「组路径 + 组id」列表:以指定组为根做先序 DFS, + * 路径用「/」拼接(如 上海鸿育初级中学/教师组/校长室)。 + */ + private List buildImportGroupPaths(Integer groupId) { + List all = sysGroupRepository.findAll(); + Map byId = new HashMap<>(); + Map> childrenMap = new HashMap<>(); + for (SysGroup g : all) { + byId.put(g.getId(), g); + Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId(); + childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g); + } + // 同层级按 sort 升序、再按 id 升序,保证模板顺序稳定且与树展示一致 + Comparator order = (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(order); + } + + List result = new ArrayList<>(); + List roots; + if (groupId != null && byId.containsKey(groupId)) { + roots = Collections.singletonList(byId.get(groupId)); + } else { + roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); + } + for (SysGroup root : roots) { + dfsGroup(root, "", childrenMap, result); + } + return result; + } + + private void dfsGroup(SysGroup node, String parentPath, + Map> childrenMap, List out) { + String path = parentPath.isEmpty() ? node.getName() : parentPath + "/" + node.getName(); + GroupPath gp = new GroupPath(); + gp.setId(node.getId()); + gp.setPath(path); + out.add(gp); + List children = childrenMap.get(node.getId()); + if (children != null) { + for (SysGroup child : children) { + dfsGroup(child, path, childrenMap, out); + } + } + } + + /** 导入/导出列定义(轻量 DTO)。 */ + @lombok.Data + private static class ImportColumnDef { + private final String key; + private final String title; + private final int width; + private final boolean required; + private final String sample; + } + + /** 模板所需的组路径节点。 */ + @lombok.Data + private static class GroupPath { + private Integer id; + private String path; } // ===================== 私有工具 ===================== /** * 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。
+ * 排序按「是否选中具体组」分治:
+ * - 选中具体组(groupId != null):JOIN 关联表,按组内 sort_no 正序(NULLS LAST),其次 user_id 正序; + * 含子组时同一用户可能命中多组,启用 distinct 去重(同时保证导出不去重重复行)。
+ * - 根节点 / 未选组(groupId == null):不 JOIN 关联表,直接按主键 id 正序(聚簇索引顺序,无 filesort), + * 平台总用户量不影响查询性能。 */ private Specification buildSpecification(SysUserQuery query) { return (root, cbQuery, cb) -> { List predicates = new ArrayList<>(); - // 组范围过滤:用户ID 属于目标组集合下的关联记录 if (query.getGroupId() != null) { + // 具体组上下文:JOIN 关联表以按组内 sort_no 排序 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)); + Join rel = root.join("userGroupRelList", JoinType.INNER); + predicates.add(rel.get("groupId").in(groupIds)); + cbQuery.distinct(true); + // 排序规则:已设排序号者排最前(NULLS LAST),其次 sort_no 升序,最后 user_id 升序兜底 + // 注:Hibernate 5.6 的 AST 解析器不支持把 IS NULL 表达式直接放进 ORDER BY, + // 改用 CASE WHEN sort_no IS NULL THEN 1 ELSE 0 END(产出普通整数,语义等价 NULLS LAST)。 + // rel.get("sortNo") 返回 Path,直接作为 Expression 传入 orderBy 即可。 + cbQuery.orderBy( + cb.asc(cb.selectCase().when(cb.isNull(rel.get("sortNo")), 1).otherwise(0)), + cb.asc(rel.get("sortNo")), + cb.asc(root.get("id")) + ); + } else { + // 根节点 / 未选组:无组上下文,按主键 id 升序(聚簇索引顺序,不产生 filesort) + cbQuery.orderBy(cb.asc(root.get("id"))); } // 关键字:账号/姓名/身份证模糊,手机号精确 @@ -208,6 +644,21 @@ public class SysUserService implements ISysUserService { }; } + /** + * 批量取「用户在某组下的排序号」,用于列表 Vo 携带当前组维度 sortNo(仅具体组上下文调用)。 + */ + private Map resolveUserSortNoInGroup(List userIds, Integer groupId) { + Map sortNoMap = new HashMap<>(); + if (userIds.isEmpty()) { + return sortNoMap; + } + List rels = sysUserGroupRelRepository.findByUserIdInAndGroupId(userIds, groupId); + for (SysUserGroupRel rel : rels) { + sortNoMap.put(rel.getUserId(), rel.getSortNo()); + } + return sortNoMap; + } + /** 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */ private List resolveGroupIds(Integer groupId, Integer withSub) { if (withSub != null && withSub == 1) { @@ -243,12 +694,18 @@ public class SysUserService implements ISysUserService { } private SysUserVo convertToVo(SysUser entity) { + return convertToVo(entity, null); + } + + private SysUserVo convertToVo(SysUser entity, Integer sortNo) { 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()); + // 当前组维度的排序号;根节点 / 未选组时为 null + vo.setSortNo(sortNo); return vo; } @@ -276,4 +733,191 @@ public class SysUserService implements ISysUserService { } return statusEnum; } + + // ===================== 批量导入私有工具 ===================== + + private static final int IDX_GROUP_NAME = 0; + private static final int IDX_GROUP_ID = 1; + private static final int IDX_ACCOUNT = 2; + private static final int IDX_PASSWORD = 3; + private static final int IDX_REAL_NAME = 4; + private static final int IDX_IDCARD = 5; + private static final int IDX_PHONE = 6; + private static final int IDX_EMAIL = 7; + private static final int IDX_NATURE = 8; + private static final int IDX_IDENTITY = 9; + private static final int IDX_GENDER = 10; + private static final int IDX_BIRTHDAY = 11; + + /** + * 解析 Excel 一行为 SysUserRequest。 + * 列顺序与前端导入模板严格一致: + * 0组名 1组id 2用户名 3密码 4姓名 5身份证 6手机 7email 8用户性质 9用户身份 10性别 11生日。 + */ + private SysUserRequest parseImportRow(Row row) { + SysUserRequest request = new SysUserRequest(); + + Integer groupId = parseInteger(getCellString(row, IDX_GROUP_ID)); + if (groupId == null) { + throw new DException("组id不能为空"); + } + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("组id【" + groupId + "】不存在"); + } + request.setGroupId(groupId); + + String account = getCellString(row, IDX_ACCOUNT); + if (StringUtils.isBlank(account)) { + throw new DException("用户名不能为空"); + } + request.setAccount(account.trim()); + + String password = getCellString(row, IDX_PASSWORD); + if (StringUtils.isBlank(password)) { + password = "Vzuk4Dp3"; + } + request.setPassword(password); + request.setConfirmPassword(password); + + String realName = getCellString(row, IDX_REAL_NAME); + if (StringUtils.isBlank(realName)) { + throw new DException("姓名不能为空"); + } + request.setRealName(realName.trim()); + + request.setIdcard(getCellString(row, IDX_IDCARD)); + request.setPhone(getCellString(row, IDX_PHONE)); + request.setEmail(getCellString(row, IDX_EMAIL)); + + Integer nature = parseInteger(getCellString(row, IDX_NATURE)); + Integer identity = parseInteger(getCellString(row, IDX_IDENTITY)); + request.setRoles(buildImportRoles(nature, identity)); + + request.setGender(parseGender(getCellString(row, IDX_GENDER))); + request.setBirthday(parseBirthday(row.getCell(IDX_BIRTHDAY))); + + validateProfile(request); + return request; + } + + /** + * 根据模板中的用户性质与用户身份构建 roles 列表。 + * - 用户性质:0普通用户(不加管理员),2组管理员(加 ADMIN=7);其他值视为非法。 + * - 用户身份:1教师->TEACHER(1),2教职工->SCHOOL_LEADER(2),3学生->STUDENT(3),4家长->PARENT(4)。 + */ + private List buildImportRoles(Integer nature, Integer identity) { + Set roleSet = new HashSet<>(); + if (nature != null) { + if (nature == 2) { + roleSet.add(SysRoleEnum.ADMIN.getKey()); + } else if (nature != 0) { + throw new DException("用户性质只能为 0 或 2"); + } + } + if (identity != null) { + switch (identity) { + case 1: + roleSet.add(SysRoleEnum.TEACHER.getKey()); + break; + case 2: + roleSet.add(SysRoleEnum.SCHOOL_LEADER.getKey()); + break; + case 3: + roleSet.add(SysRoleEnum.STUDENT.getKey()); + break; + case 4: + roleSet.add(SysRoleEnum.PARENT.getKey()); + break; + default: + throw new DException("用户身份只能为 1/2/3/4"); + } + } + return new ArrayList<>(roleSet); + } + + private Integer parseGender(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + String v = value.trim(); + if ("1".equals(v) || "男".equals(v)) { + return 1; + } + if ("0".equals(v) || "女".equals(v)) { + return 0; + } + throw new DException("性别只能为 0/女 或 1/男"); + } + + private LocalDate parseBirthday(Cell cell) { + if (cell == null) { + return null; + } + if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { + return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + String value = getCellString(cell); + if (StringUtils.isBlank(value)) { + return null; + } + try { + return LocalDate.parse(value.trim()); + } catch (Exception e) { + throw new DException("生日格式错误,请使用 yyyy-MM-dd"); + } + } + + private boolean isBlankRow(Row row) { + for (int i = 0; i <= IDX_BIRTHDAY; i++) { + if (StringUtils.isNotBlank(getCellString(row, i))) { + return false; + } + } + return true; + } + + private String getCellString(Row row, int cellIndex) { + Cell cell = row.getCell(cellIndex); + if (cell == null) { + return ""; + } + return getCellString(cell); + } + + private String getCellString(Cell cell) { + switch (cell.getCellType()) { + case STRING: + return StringUtils.trim(cell.getStringCellValue()); + case NUMERIC: + if (DateUtil.isCellDateFormatted(cell)) { + return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString(); + } + double numeric = cell.getNumericCellValue(); + if (numeric == Math.floor(numeric)) { + return String.valueOf((long) numeric); + } + return String.valueOf(numeric); + case BOOLEAN: + return String.valueOf(cell.getBooleanCellValue()); + case FORMULA: + try { + return String.valueOf(cell.getNumericCellValue()); + } catch (Exception e) { + return cell.getStringCellValue(); + } + default: + return ""; + } + } + + private Integer parseInteger(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return Integer.valueOf(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } } diff --git a/src/main/java/com/yunhai/userbase/service/UserCenterService.java b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java similarity index 99% rename from src/main/java/com/yunhai/userbase/service/UserCenterService.java rename to src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java index 1bc177c..cea7c2a 100644 --- a/src/main/java/com/yunhai/userbase/service/UserCenterService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java @@ -1,4 +1,4 @@ -package com.yunhai.userbase.service; +package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaUser; diff --git a/src/main/java/com/yunhai/userbase/task/SignKeyCacheDailyTask.java b/src/main/java/com/yunhai/userbase/task/SignKeyCacheDailyTask.java new file mode 100644 index 0000000..ccaf22f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/task/SignKeyCacheDailyTask.java @@ -0,0 +1,48 @@ +package com.yunhai.userbase.task; + +import com.yunhai.userbase.service.impl.SignKeyCacheService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 签名密钥共享缓存的对账/同步定时任务。
+ *
+ * 每天凌晨 3:05 执行(让 {@link OperationLogCleanupTask} 的 3:00 先跑完,避免同一时间打库): + *
    + *
  1. 把 application_info 表全量同步到共享 Redis;
  2. + *
  3. 清理 DB 已删除、但 Redis 残留的旧应用 key;
  4. + *
  5. 清空本机 AppSecretProvider / LocalAppIdentityProvider 的本地密钥缓存, + * 避免长驻进程本地缓存和 DB 不一致。
  6. + *
+ *

+ * 注:应用日常新增/删除/重置密钥会通过 + * {@link com.yunhai.userbase.service.impl.ApplicationInfoService} 的事务事件 + * 实时触发 {@link SignKeyCacheService} 的 syncOne/removeOne, + * 本任务仅作为「每日对账兜底」,防止 Redis 故障或人工介入导致的偏差。 + */ +@Component +public class SignKeyCacheDailyTask { + + private static final Logger log = LoggerFactory.getLogger(SignKeyCacheDailyTask.class); + + @Autowired + private SignKeyCacheService signKeyCacheService; + + /** + * 每天凌晨 3:05 执行,错开 OperationLogCleanupTask 的 3:00。 + */ + @Scheduled(cron = "0 5 3 * * ?") + @Async + public void syncAll() { + try { + int n = signKeyCacheService.refreshAll(); + log.info("[SignKeyCache] daily sync-all done, synced apps={}", n); + } catch (Exception e) { + log.warn("[SignKeyCache] daily sync-all failed: {}", e.getMessage()); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f56c5c1..a804672 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -68,6 +68,12 @@ yh: mobi-api-pattern: /mobiapi/* app-auth: enabled: true + sign-key-internal: + enabled: true + ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.10.0/200 ADMIN_TAG_ID: 290 diff --git a/src/main/resources/sql/sys_user_add_email_gender.sql b/src/main/resources/sql/sys_user_add_email_gender.sql new file mode 100644 index 0000000..7217d43 --- /dev/null +++ b/src/main/resources/sql/sys_user_add_email_gender.sql @@ -0,0 +1,5 @@ +-- 为 sys_user 表新增 email、gender 字段,支撑批量导入模板中的邮箱与性别列 +-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑 +ALTER TABLE sys_user + ADD COLUMN email VARCHAR(255) NULL COMMENT '邮箱', + ADD COLUMN gender INT NULL COMMENT '性别:0女 1男'; diff --git a/src/main/resources/sql/sys_user_group_rel_add_sort_no.sql b/src/main/resources/sql/sys_user_group_rel_add_sort_no.sql new file mode 100644 index 0000000..ae8b64a --- /dev/null +++ b/src/main/resources/sql/sys_user_group_rel_add_sort_no.sql @@ -0,0 +1,11 @@ +-- 为 sys_user_group_rel 关联表新增 sort_no 字段,用于「按组维度」对用户排序 +-- 默认 null(未设置)→ 列表按 user_id 升序兜底;1~999 正整数,越小越靠前。 +-- 执行方式:由 DBA 在 dev / prod 各自手工执行,AI 不代跑(本地 dev 靠 ddl-auto=update 自动加列)。 +-- 注意:覆盖索引 (group_id, sort_no, user_id) 让「具体组 + 排序」的 JOIN 走索引、减少回表; +-- 表达式排序 (sort_no IS NULL) 无法完全命中索引,但仍能借助 group_id 前缀快速圈定组范围。 + +ALTER TABLE sys_user_group_rel + ADD COLUMN sort_no INT NULL COMMENT '组内排序号,越小越靠前;null 按 user_id 兜底'; + +ALTER TABLE sys_user_group_rel + ADD INDEX idx_group_sort_user (group_id, sort_no, user_id); From b4a6af0ec538e8f26a2e5e12a4f4aecad04b86b5 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 12 Aug 2026 19:51:01 +0800 Subject: [PATCH 25/58] =?UTF-8?q?add=E6=BC=8F=E6=8E=89=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/SignKeyInternalIpFilter.java | 124 ++++++++++++++++++ .../config/SignKeyInternalProperties.java | 55 ++++++++ .../sysuser/BatchChangeStatusRequest.java | 24 ++++ .../model/sysuser/BatchDeleteRequest.java | 24 ++++ .../model/sysuser/ChangeSortNoRequest.java | 29 ++++ 5 files changed, 256 insertions(+) create mode 100644 src/main/java/com/yunhai/userbase/config/SignKeyInternalIpFilter.java create mode 100644 src/main/java/com/yunhai/userbase/config/SignKeyInternalProperties.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/BatchChangeStatusRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/BatchDeleteRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/ChangeSortNoRequest.java diff --git a/src/main/java/com/yunhai/userbase/config/SignKeyInternalIpFilter.java b/src/main/java/com/yunhai/userbase/config/SignKeyInternalIpFilter.java new file mode 100644 index 0000000..2359640 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/SignKeyInternalIpFilter.java @@ -0,0 +1,124 @@ +package com.yunhai.userbase.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.CollectionUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +/** + * 内部开放接口(/oapi/internal/*)来源 IP 白名单过滤器。

+ * 防止密钥兜底端点(/oapi/internal/sign-key/{appId})被公网直接枚举调用; + * 只允许内网 / 显式配置的白名单来源通过,未命中返回 403。

+ * 支持 X-Forwarded-For / X-Real-IP 等常见代理头解析,取最左侧真实来源 IP 做校验。 + */ +public class SignKeyInternalIpFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(SignKeyInternalIpFilter.class); + + private final SignKeyInternalProperties properties; + + public SignKeyInternalIpFilter(SignKeyInternalProperties properties) { + this.properties = properties; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!properties.isEnabled()) { + filterChain.doFilter(request, response); + return; + } + String sourceIp = resolveSourceIp(request); + List effectiveIps = properties.getEffectiveIps(); + if (CollectionUtils.isEmpty(effectiveIps) || matchAny(sourceIp, effectiveIps)) { + filterChain.doFilter(request, response); + return; + } + log.warn("[SignKeyInternal] 拒绝非白名单来源访问内部接口: uri={}, sourceIp={}, whitelist={}", + request.getRequestURI(), sourceIp, effectiveIps); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":403,\"msg\":\"内部接口不允许从该来源访问\",\"data\":null}"); + response.getWriter().flush(); + } + + private static String resolveSourceIp(HttpServletRequest request) { + String[] headers = new String[] { + "X-Forwarded-For", + "X-Real-IP", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + for (String header : headers) { + String raw = request.getHeader(header); + if (raw == null || raw.isEmpty() || "unknown".equalsIgnoreCase(raw)) { + continue; + } + // X-Forwarded-For 可能是 多个IP逗号分隔,取最左侧 + String first = raw.split(",")[0].trim(); + if (!first.isEmpty()) { + return first; + } + } + return request.getRemoteAddr(); + } + + private static boolean matchAny(String ip, List cidrs) { + for (String cidr : cidrs) { + if (matchCidr(ip, cidr)) { + return true; + } + } + return false; + } + + private static boolean matchCidr(String ip, String cidr) { + if (ip == null || cidr == null) { + return false; + } + try { + int prefixLen; + String network; + if (cidr.contains("/")) { + String[] parts = cidr.split("/"); + network = parts[0].trim(); + prefixLen = Integer.parseInt(parts[1].trim()); + } else { + network = cidr.trim(); + // 判断地址族决定默认前缀长度 + InetAddress na = InetAddress.getByName(network); + prefixLen = (na instanceof Inet6Address) ? 128 : 32; + } + InetAddress ipAddr = InetAddress.getByName(ip); + InetAddress netAddr = InetAddress.getByName(network); + if (ipAddr.getClass() != netAddr.getClass()) { + return false; + } + byte[] ipBytes = ipAddr.getAddress(); + byte[] netBytes = netAddr.getAddress(); + int bits = prefixLen; + for (int i = 0; i < ipBytes.length && bits > 0; i++) { + int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits)); + if ((ipBytes[i] & mask) != (netBytes[i] & mask)) { + return false; + } + bits -= 8; + } + return true; + } catch (UnknownHostException | NumberFormatException ignored) { + return false; + } + } +} diff --git a/src/main/java/com/yunhai/userbase/config/SignKeyInternalProperties.java b/src/main/java/com/yunhai/userbase/config/SignKeyInternalProperties.java new file mode 100644 index 0000000..f5eb76b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/SignKeyInternalProperties.java @@ -0,0 +1,55 @@ +package com.yunhai.userbase.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * 内部开放接口(/oapi/internal/*)访问白名单 IP 配置。
+ * 支持单 IP、CIDR(如 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.1/32)。
+ *

+ * 可在 Nacos 上配置,例如:

+ * yh:
+ *   sign-key-internal:
+ *     ips:
+ *       - 10.0.0.0/8
+ *       - 172.16.0.0/12
+ *       - 192.168.0.0/16
+ *       - 127.0.0.1/32
+ *       - 172.20.10.5/32   # 某台固定机器
+ * 
+ * 未配置时,默认允许 10/172.16-31/192.168 私有段 + 127.0.0.1(方便本地开发/内网部署)。 + */ +@ConfigurationProperties(prefix = "yh.sign-key-internal") +public class SignKeyInternalProperties { + + /** 显式允许的来源 IP(CIDR 或 单 IP)。为空时使用默认私有段规则。 */ + private List ips = new ArrayList<>(); + + /** 是否启用白名单校验;设置 false 可临时关闭(生产不建议)。 */ + private boolean enabled = true; + + public List getIps() { return ips; } + + public void setIps(List ips) { this.ips = ips; } + + public boolean isEnabled() { return enabled; } + + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + /** 未配置 ips 时返回默认私有段 + localhost 规则。 */ + public List getEffectiveIps() { + if (ips != null && !ips.isEmpty()) { + return ips; + } + return Arrays.asList( + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.1/32", + "0:0:0:0:0:0:0:1/128" + ); + } +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/BatchChangeStatusRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/BatchChangeStatusRequest.java new file mode 100644 index 0000000..28905f7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/BatchChangeStatusRequest.java @@ -0,0 +1,24 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author chenx + * @date 2026/8/12 + * @description 批量设置用户状态请求 + */ +@Data +public class BatchChangeStatusRequest { + + /** 待设置用户ID列表 */ + @NotEmpty(message = "待设置用户不能为空") + private List userIds; + + /** 是否禁用:true=禁用,false=启用 */ + @NotNull(message = "状态不能为空") + private Boolean disabled; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/BatchDeleteRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/BatchDeleteRequest.java new file mode 100644 index 0000000..5f1b4cb --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/BatchDeleteRequest.java @@ -0,0 +1,24 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author chenx + * @date 2026/8/12 + * @description 批量从当前组删除用户请求 + */ +@Data +public class BatchDeleteRequest { + + /** 待删除用户ID列表 */ + @NotEmpty(message = "待删除用户不能为空") + private List userIds; + + /** 当前组ID */ + @NotNull(message = "组ID不能为空") + private Integer groupId; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/ChangeSortNoRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/ChangeSortNoRequest.java new file mode 100644 index 0000000..0631049 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/ChangeSortNoRequest.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; + +/** + * @author chenx + * @date 2026/8/12 + * @description 调整用户在某组内的排序号 + */ +@Data +public class ChangeSortNoRequest { + + /** 所属分组(排序号按组维度存储,必须明确是哪个组) */ + @NotNull(message = "所属分组不能为空") + private Integer groupId; + + /** + * 组内排序号:1~999 的正整数;越小越靠前。
+ * 允许为 null —— 表示清除排序号,列表回退到 user_id 升序兜底。
+ * 注意:@Min/@Max 对 null 不生效,null 由业务层解释为「清除」。 + */ + @Min(1) + @Max(999) + private Integer sortNo; +} From c35a9fa47edd9935d32873a38f3029b39173c4f3 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 12 Aug 2026 20:55:02 +0800 Subject: [PATCH 26/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=92=E5=BA=8Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userbase/service/impl/SysUserService.java | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 5c1c3c5..55e7564 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -37,8 +37,6 @@ import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.*; import org.springframework.web.multipart.MultipartFile; -import javax.persistence.criteria.Join; -import javax.persistence.criteria.JoinType; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import javax.persistence.criteria.Subquery; @@ -267,11 +265,13 @@ public class SysUserService implements ISysUserService { public Page list(SysUserQuery query, Pageable pageable) { Specification spec = buildSpecification(query); Page page = sysUserRepository.findAll(spec, pageable); - // 把当前组维度的 sortNo 带到 Vo(仅在具体组上下文有意义;根节点/未选组时为 null) + // 把当前组维度的 sortNo 带到 Vo(仅在具体组上下文有意义;根节点/未选组时为 null)。 + // 组范围须与列表排序一致:含子组时取自身+子孙组范围内的最小 sort_no,避免「排序按范围、展示按单组」的不一致。 if (query.getGroupId() != null) { + List groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub()); Map sortNoMap = resolveUserSortNoInGroup( page.getContent().stream().map(SysUser::getId).collect(Collectors.toList()), - query.getGroupId()); + groupIds); return page.map(u -> convertToVo(u, sortNoMap.get(u.getId()))); } return page.map(u -> convertToVo(u, null)); @@ -596,9 +596,10 @@ public class SysUserService implements ISysUserService { /** * 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。
* 排序按「是否选中具体组」分治:
- * - 选中具体组(groupId != null):JOIN 关联表,按组内 sort_no 正序(NULLS LAST),其次 user_id 正序; - * 含子组时同一用户可能命中多组,启用 distinct 去重(同时保证导出不去重重复行)。
- * - 根节点 / 未选组(groupId == null):不 JOIN 关联表,直接按主键 id 正序(聚簇索引顺序,无 filesort), + * - 选中具体组(groupId != null):用 EXISTS 子查询按组范围过滤成员,用「组内最小 sort_no」标量子查询排序 + * (NULLS LAST),其次 user_id 正序;不使用 JOIN+distinct,规避 MySQL 的 DISTINCT+ORDER BY 限制, + * 且含子组时同一用户命中多组也不会产生重复行。
+ * - 根节点 / 未选组(groupId == null):不关联组表,直接按主键 id 正序(聚簇索引顺序,无 filesort), * 平台总用户量不影响查询性能。 */ private Specification buildSpecification(SysUserQuery query) { @@ -606,18 +607,35 @@ public class SysUserService implements ISysUserService { List predicates = new ArrayList<>(); if (query.getGroupId() != null) { - // 具体组上下文:JOIN 关联表以按组内 sort_no 排序 + // 具体组上下文:按组内 sort_no 排序、按组范围过滤成员。 + // 注意:不能 root.join(rel) + distinct 后再 ORDER BY rel.sortNo —— + // MySQL 在 DISTINCT 下禁止 ORDER BY 未选中列(报 "incompatible with DISTINCT"), + // 且 WITH SUB 时同一用户命中多组会因 JOIN 产生重复行。 + // 改用「成员资格 EXISTS 子查询」过滤 + 「组内最小 sort_no 标量子查询」排序: + // 不产生重复行,也规避 DISTINCT+ORDER BY 限制,分页/排序语义保持不变。 List groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub()); - Join rel = root.join("userGroupRelList", JoinType.INNER); - predicates.add(rel.get("groupId").in(groupIds)); - cbQuery.distinct(true); - // 排序规则:已设排序号者排最前(NULLS LAST),其次 sort_no 升序,最后 user_id 升序兜底 - // 注:Hibernate 5.6 的 AST 解析器不支持把 IS NULL 表达式直接放进 ORDER BY, - // 改用 CASE WHEN sort_no IS NULL THEN 1 ELSE 0 END(产出普通整数,语义等价 NULLS LAST)。 - // rel.get("sortNo") 返回 Path,直接作为 Expression 传入 orderBy 即可。 + + // 成员过滤:用户须归属于 groupIds 中的某一组 + Subquery memberSq = cbQuery.subquery(Integer.class); + Root memberRoot = memberSq.from(SysUserGroupRel.class); + memberSq.select(memberRoot.get("userId")); + memberSq.where( + memberRoot.get("groupId").in(groupIds), + cb.equal(memberRoot.get("userId"), root.get("id")) + ); + predicates.add(cb.exists(memberSq)); + + // 排序:取该用户在组范围内的「最小 sort_no」作为排序键(NULLS LAST),其次 user_id 兜底 + Subquery sortSq = cbQuery.subquery(Integer.class); + Root sortRoot = sortSq.from(SysUserGroupRel.class); + sortSq.select(cb.min(sortRoot.get("sortNo"))); + sortSq.where( + sortRoot.get("groupId").in(groupIds), + cb.equal(sortRoot.get("userId"), root.get("id")) + ); cbQuery.orderBy( - cb.asc(cb.selectCase().when(cb.isNull(rel.get("sortNo")), 1).otherwise(0)), - cb.asc(rel.get("sortNo")), + cb.asc(cb.selectCase().when(cb.isNull(sortSq), 1).otherwise(0)), + cb.asc(sortSq), cb.asc(root.get("id")) ); } else { @@ -645,16 +663,21 @@ public class SysUserService implements ISysUserService { } /** - * 批量取「用户在某组下的排序号」,用于列表 Vo 携带当前组维度 sortNo(仅具体组上下文调用)。 + * 批量取「用户在组范围内」的排序号(组内最小 sort_no),用于列表 Vo 携带当前组维度 sortNo。
+ * 与列表排序键(组内最小 sort_no)严格一致:含子组时 groupIds 为自身+子孙组,用户跨多组命中时取最小, + * 因此「排序顺序」与「展示的排序号」完全对应。 */ - private Map resolveUserSortNoInGroup(List userIds, Integer groupId) { + private Map resolveUserSortNoInGroup(List userIds, List groupIds) { Map sortNoMap = new HashMap<>(); - if (userIds.isEmpty()) { + if (userIds.isEmpty() || groupIds.isEmpty()) { return sortNoMap; } - List rels = sysUserGroupRelRepository.findByUserIdInAndGroupId(userIds, groupId); - for (SysUserGroupRel rel : rels) { - sortNoMap.put(rel.getUserId(), rel.getSortNo()); + List rows = sysUserGroupRelRepository.findMinSortNoByUserIdInAndGroupIdIn(userIds, groupIds); + for (Object[] row : rows) { + Integer userId = (Integer) row[0]; + Object minObj = row[1]; // MIN(sort_no) 可能为空(该用户范围内均未设置排序号) + Integer sortNo = minObj == null ? null : ((Number) minObj).intValue(); + sortNoMap.put(userId, sortNo); } return sortNoMap; } From ec407970e01931f1960edec2a3cf2be0635139d9 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 12 Aug 2026 20:57:18 +0800 Subject: [PATCH 27/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=92=E5=BA=8Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/SysUserGroupRelRepository.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java index 0a53aff..ceff7c7 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java @@ -3,6 +3,8 @@ 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 org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -24,6 +26,15 @@ public interface SysUserGroupRelRepository extends JpaRepository findByUserIdInAndGroupId(List userIds, Integer groupId); + /** + * 取「用户在指定组范围内」的最小 sort_no(按 userId 聚合),用于列表 Vo 携带排序号。
+ * 与列表排序(组内最小 sort_no)保持一致;组范围内无记录则返回空。 + */ + @Query("SELECT r.userId, MIN(r.sortNo) FROM SysUserGroupRel r " + + "WHERE r.userId IN :userIds AND r.groupId IN :groupIds GROUP BY r.userId") + List findMinSortNoByUserIdInAndGroupIdIn(@Param("userIds") List userIds, + @Param("groupIds") List groupIds); + Optional findByUserIdAndGroupId(Integer userId, Integer groupId); List findByGroupId(Integer groupId); From cfee8012a6d81e054512789db84b65c5717c7bd5 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 12 Aug 2026 21:21:12 +0800 Subject: [PATCH 28/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=92=E5=BA=8Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/SysUserGroupRelRepository.java | 11 -- .../repository/SysUserRepository.java | 41 ++++++ .../userbase/service/impl/SysUserService.java | 134 ++++++++---------- 3 files changed, 103 insertions(+), 83 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java index ceff7c7..0a53aff 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java @@ -3,8 +3,6 @@ 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 org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -26,15 +24,6 @@ public interface SysUserGroupRelRepository extends JpaRepository findByUserIdInAndGroupId(List userIds, Integer groupId); - /** - * 取「用户在指定组范围内」的最小 sort_no(按 userId 聚合),用于列表 Vo 携带排序号。
- * 与列表排序(组内最小 sort_no)保持一致;组范围内无记录则返回空。 - */ - @Query("SELECT r.userId, MIN(r.sortNo) FROM SysUserGroupRel r " + - "WHERE r.userId IN :userIds AND r.groupId IN :groupIds GROUP BY r.userId") - List findMinSortNoByUserIdInAndGroupIdIn(@Param("userIds") List userIds, - @Param("groupIds") List groupIds); - Optional findByUserIdAndGroupId(Integer userId, Integer groupId); List findByGroupId(Integer groupId); diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java index f885532..16b5f86 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java @@ -1,8 +1,14 @@ package com.yunhai.userbase.repository; import com.yunhai.userbase.entity.SysUser; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; 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 @@ -20,4 +26,39 @@ public interface SysUserRepository extends JpaRepository, JpaS /** 按手机号查用户,供用户中心组装 BoaUser。 */ SysUser findByPhone(String phone); + + /** + * 组上下文列表专用分页(原生 SQL)。
+ * INNER JOIN 关联表后按 user_id 聚合(GROUP BY u.id),以「组内最小 sort_no」为排序键 + * (NULLS LAST + sort_no 升序 + user_id 兜底升序)。GROUP BY 天然去重,规避 MySQL 的 + * DISTINCT+ORDER BY 限制与 Hibernate 不支持 ORDER BY 标量子查询的限制。
+ * 入参 name / disabled 为可空:为空(NULL)时该过滤条件整体跳过。 + */ + @Query(value = + "SELECT u.id AS userId, MIN(r.sort_no) AS minSortNo " + + "FROM sys_user u " + + "INNER JOIN sys_user_group_rel r ON r.user_id = u.id " + + "WHERE r.group_id IN (:groupIds) " + + " AND (:name IS NULL OR u.account LIKE CONCAT('%', :name, '%') " + + " OR u.real_name LIKE CONCAT('%', :name, '%') " + + " OR u.idcard LIKE CONCAT('%', :name, '%') " + + " OR u.phone = :name) " + + " AND (:disabled IS NULL OR u.disabled = :disabled) " + + "GROUP BY u.id " + + "ORDER BY CASE WHEN MIN(r.sort_no) IS NULL THEN 1 ELSE 0 END, MIN(r.sort_no) ASC, u.id ASC", + countQuery = + "SELECT COUNT(DISTINCT u.id) " + + "FROM sys_user u " + + "INNER JOIN sys_user_group_rel r ON r.user_id = u.id " + + "WHERE r.group_id IN (:groupIds) " + + " AND (:name IS NULL OR u.account LIKE CONCAT('%', :name, '%') " + + " OR u.real_name LIKE CONCAT('%', :name, '%') " + + " OR u.idcard LIKE CONCAT('%', :name, '%') " + + " OR u.phone = :name) " + + " AND (:disabled IS NULL OR u.disabled = :disabled)", + nativeQuery = true) + Page findGroupUserPage(@Param("groupIds") List groupIds, + @Param("name") String name, + @Param("disabled") Integer disabled, + Pageable pageable); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 55e7564..81667ea 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -28,6 +28,8 @@ 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.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.security.crypto.password.PasswordEncoder; @@ -39,7 +41,6 @@ import org.springframework.web.multipart.MultipartFile; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; -import javax.persistence.criteria.Subquery; import java.io.IOException; import java.io.OutputStream; @@ -263,20 +264,64 @@ public class SysUserService implements ISysUserService { @Override public Page list(SysUserQuery query, Pageable pageable) { + // 组上下文(选中具体组):走原生 SQL 按「组内最小 sort_no」排序的专用分页, + // 规避 MySQL 的 DISTINCT+ORDER BY 限制以及 Hibernate 不支持 ORDER BY 标量子查询的限制。 + if (query.getGroupId() != null) { + return listByGroup(query, pageable); + } + // 根节点 / 未选组:用 Specification 过滤,按主键 id 升序(聚簇索引顺序,无 filesort)。 Specification spec = buildSpecification(query); Page page = sysUserRepository.findAll(spec, pageable); - // 把当前组维度的 sortNo 带到 Vo(仅在具体组上下文有意义;根节点/未选组时为 null)。 - // 组范围须与列表排序一致:含子组时取自身+子孙组范围内的最小 sort_no,避免「排序按范围、展示按单组」的不一致。 - if (query.getGroupId() != null) { - List groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub()); - Map sortNoMap = resolveUserSortNoInGroup( - page.getContent().stream().map(SysUser::getId).collect(Collectors.toList()), - groupIds); - return page.map(u -> convertToVo(u, sortNoMap.get(u.getId()))); - } return page.map(u -> convertToVo(u, null)); } + /** + * 组上下文列表:原生 SQL 分页。
+ * 思路:INNER JOIN 关联表后按 user_id 聚合(GROUP BY u.id),用 MIN(sort_no) 作为排序键 + * (NULLS LAST + sort_no 升序 + user_id 兜底升序)。GROUP BY 天然去重,既无 DISTINCT+ORDER BY 冲突, + * 也无 ORDER BY 标量子查询(Hibernate 5.6 不支持),分页/排序语义与「组内自定义排序」完全一致。 + * 同页的 (userId, minSortNo) 一并查出,省去二次查询。 + */ + private Page listByGroup(SysUserQuery query, Pageable pageable) { + List groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub()); + String name = StringUtils.isNotBlank(query.getName()) ? query.getName().trim() : null; + Integer disabledKey = query.getDisabled(); + + Page idPage = sysUserRepository.findGroupUserPage( + groupIds, name, disabledKey, + PageRequest.of(pageable.getPageNumber(), pageable.getPageSize())); + + List rows = idPage.getContent(); + if (rows.isEmpty()) { + return new PageImpl<>(Collections.emptyList(), pageable, 0); + } + + // 保持 DB 返回的排序顺序,并记录每个用户的「组范围内最小 sort_no」 + List orderedIds = new ArrayList<>(rows.size()); + Map sortNoMap = new LinkedHashMap<>(); + for (Object[] row : rows) { + Integer userId = ((Number) row[0]).intValue(); + Object minObj = row[1]; // MIN(sort_no) 可能为空(该用户在组范围内均未设置排序号) + Integer sortNo = minObj == null ? null : ((Number) minObj).intValue(); + orderedIds.add(userId); + sortNoMap.put(userId, sortNo); + } + + // 按有序 ID 取实体(findAllById 返回顺序不可靠,需按 orderedIds 重排) + List users = sysUserRepository.findAllById(orderedIds); + Map userMap = users.stream() + .collect(Collectors.toMap(SysUser::getId, u -> u, (a, b) -> a)); + + List voList = new ArrayList<>(orderedIds.size()); + for (Integer id : orderedIds) { + SysUser u = userMap.get(id); + if (u != null) { + voList.add(convertToVo(u, sortNoMap.get(id))); + } + } + return new PageImpl<>(voList, pageable, idPage.getTotalElements()); + } + @Override public List findGroupIdsByUserId(Integer userId) { return sysUserGroupRelRepository.findByUserId(userId).stream() @@ -594,54 +639,17 @@ public class SysUserService implements ISysUserService { // ===================== 私有工具 ===================== /** - * 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。
- * 排序按「是否选中具体组」分治:
- * - 选中具体组(groupId != null):用 EXISTS 子查询按组范围过滤成员,用「组内最小 sort_no」标量子查询排序 - * (NULLS LAST),其次 user_id 正序;不使用 JOIN+distinct,规避 MySQL 的 DISTINCT+ORDER BY 限制, - * 且含子组时同一用户命中多组也不会产生重复行。
- * - 根节点 / 未选组(groupId == null):不关联组表,直接按主键 id 正序(聚簇索引顺序,无 filesort), - * 平台总用户量不影响查询性能。 + * 组装「根节点 / 未选组」上下文的用户列表查询条件:关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态, + * 按主键 id 升序(聚簇索引顺序,无 filesort)。
+ * 注意:选中具体组的上下文不走 Specification,改由 {@link #listByGroup} 用原生 SQL 分页, + * 以规避 MySQL 的 DISTINCT+ORDER BY 限制与 Hibernate 不支持 ORDER BY 标量子查询的限制。 */ private Specification buildSpecification(SysUserQuery query) { return (root, cbQuery, cb) -> { List predicates = new ArrayList<>(); - if (query.getGroupId() != null) { - // 具体组上下文:按组内 sort_no 排序、按组范围过滤成员。 - // 注意:不能 root.join(rel) + distinct 后再 ORDER BY rel.sortNo —— - // MySQL 在 DISTINCT 下禁止 ORDER BY 未选中列(报 "incompatible with DISTINCT"), - // 且 WITH SUB 时同一用户命中多组会因 JOIN 产生重复行。 - // 改用「成员资格 EXISTS 子查询」过滤 + 「组内最小 sort_no 标量子查询」排序: - // 不产生重复行,也规避 DISTINCT+ORDER BY 限制,分页/排序语义保持不变。 - List groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub()); - - // 成员过滤:用户须归属于 groupIds 中的某一组 - Subquery memberSq = cbQuery.subquery(Integer.class); - Root memberRoot = memberSq.from(SysUserGroupRel.class); - memberSq.select(memberRoot.get("userId")); - memberSq.where( - memberRoot.get("groupId").in(groupIds), - cb.equal(memberRoot.get("userId"), root.get("id")) - ); - predicates.add(cb.exists(memberSq)); - - // 排序:取该用户在组范围内的「最小 sort_no」作为排序键(NULLS LAST),其次 user_id 兜底 - Subquery sortSq = cbQuery.subquery(Integer.class); - Root sortRoot = sortSq.from(SysUserGroupRel.class); - sortSq.select(cb.min(sortRoot.get("sortNo"))); - sortSq.where( - sortRoot.get("groupId").in(groupIds), - cb.equal(sortRoot.get("userId"), root.get("id")) - ); - cbQuery.orderBy( - cb.asc(cb.selectCase().when(cb.isNull(sortSq), 1).otherwise(0)), - cb.asc(sortSq), - cb.asc(root.get("id")) - ); - } else { - // 根节点 / 未选组:无组上下文,按主键 id 升序(聚簇索引顺序,不产生 filesort) - cbQuery.orderBy(cb.asc(root.get("id"))); - } + // 根节点 / 未选组:无组上下文,按主键 id 升序 + cbQuery.orderBy(cb.asc(root.get("id"))); // 关键字:账号/姓名/身份证模糊,手机号精确 if (StringUtils.isNotBlank(query.getName())) { @@ -663,26 +671,8 @@ public class SysUserService implements ISysUserService { } /** - * 批量取「用户在组范围内」的排序号(组内最小 sort_no),用于列表 Vo 携带当前组维度 sortNo。
- * 与列表排序键(组内最小 sort_no)严格一致:含子组时 groupIds 为自身+子孙组,用户跨多组命中时取最小, - * 因此「排序顺序」与「展示的排序号」完全对应。 + * 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */ - private Map resolveUserSortNoInGroup(List userIds, List groupIds) { - Map sortNoMap = new HashMap<>(); - if (userIds.isEmpty() || groupIds.isEmpty()) { - return sortNoMap; - } - List rows = sysUserGroupRelRepository.findMinSortNoByUserIdInAndGroupIdIn(userIds, groupIds); - for (Object[] row : rows) { - Integer userId = (Integer) row[0]; - Object minObj = row[1]; // MIN(sort_no) 可能为空(该用户范围内均未设置排序号) - Integer sortNo = minObj == null ? null : ((Number) minObj).intValue(); - sortNoMap.put(userId, sortNo); - } - return sortNoMap; - } - - /** 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */ private List resolveGroupIds(Integer groupId, Integer withSub) { if (withSub != null && withSub == 1) { return sysGroupService.listSelfAndDescendantIds(groupId); From 3855ea2eca23b05bccfba6be0ee9e61d9907af28 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 12 Aug 2026 21:33:08 +0800 Subject: [PATCH 29/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=92=E5=BA=8Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yunhai/userbase/repository/SysUserRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java index 16b5f86..5fd440b 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java @@ -39,7 +39,7 @@ public interface SysUserRepository extends JpaRepository, JpaS "FROM sys_user u " + "INNER JOIN sys_user_group_rel r ON r.user_id = u.id " + "WHERE r.group_id IN (:groupIds) " + - " AND (:name IS NULL OR u.account LIKE CONCAT('%', :name, '%') " + + " AND (:name IS NULL OR u.username LIKE CONCAT('%', :name, '%') " + " OR u.real_name LIKE CONCAT('%', :name, '%') " + " OR u.idcard LIKE CONCAT('%', :name, '%') " + " OR u.phone = :name) " + @@ -51,7 +51,7 @@ public interface SysUserRepository extends JpaRepository, JpaS "FROM sys_user u " + "INNER JOIN sys_user_group_rel r ON r.user_id = u.id " + "WHERE r.group_id IN (:groupIds) " + - " AND (:name IS NULL OR u.account LIKE CONCAT('%', :name, '%') " + + " AND (:name IS NULL OR u.username LIKE CONCAT('%', :name, '%') " + " OR u.real_name LIKE CONCAT('%', :name, '%') " + " OR u.idcard LIKE CONCAT('%', :name, '%') " + " OR u.phone = :name) " + From 81130454716ff3271a20f7bd779f2258d0951c8a Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 13 Aug 2026 08:15:03 +0800 Subject: [PATCH 30/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=92=E5=BA=8Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/SysUserRepository.java | 22 +++++++++++++++++++ .../userbase/service/impl/SysUserService.java | 17 ++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java index 5fd440b..015d584 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java @@ -61,4 +61,26 @@ public interface SysUserRepository extends JpaRepository, JpaS @Param("name") String name, @Param("disabled") Integer disabled, Pageable pageable); + + /** + * 组上下文导出专用(原生 SQL,非分页):INNER JOIN 关联表后按 user_id 聚合, + * 仅返回「属于指定组范围」的用户(无组归属的用户天然被排除);超管(type=1)豁免 + * (type 为 NULL 视为非超管,一并保留)。入参 name 为可空:为空时跳过关键字过滤。 + * 排序按 user_id 升序,保证导出结果稳定。 + */ + @Query(value = + "SELECT u.id " + + "FROM sys_user u " + + "INNER JOIN sys_user_group_rel r ON r.user_id = u.id " + + "WHERE r.group_id IN (:groupIds) " + + " AND (:name IS NULL OR u.username LIKE CONCAT('%', :name, '%') " + + " OR u.real_name LIKE CONCAT('%', :name, '%') " + + " OR u.idcard LIKE CONCAT('%', :name, '%') " + + " OR u.phone = :name) " + + " AND (u.type IS NULL OR u.type != 1) " + + "GROUP BY u.id " + + "ORDER BY u.id ASC", + nativeQuery = true) + List findGroupUserIds(@Param("groupIds") List groupIds, + @Param("name") String name); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 81667ea..a3ef3f5 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -500,15 +500,18 @@ public class SysUserService implements ISysUserService { */ @Override public void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException { - // 1. 解析组范围(含子孙)并查询范围内全部用户(不分页) - SysUserQuery query = new SysUserQuery(); - query.setGroupId(groupId); - query.setWithSub(withSub); - query.setName(name); - List users = sysUserRepository.findAll(buildSpecification(query)); + // 1. 解析组范围(含子孙),复用与列表一致的 INNER JOIN 原生 SQL 取范围内用户(含超管豁免),不分页。 + // 无组归属的用户被 INNER JOIN 天然排除;type=1 超管在 SQL 中显式豁免,永不导出。 + List groupIds = resolveGroupIds(groupId, withSub); + String nameKey = StringUtils.isNotBlank(name) ? name.trim() : null; + List userIds = sysUserRepository.findGroupUserIds(groupIds, nameKey); + List users = userIds.isEmpty() + ? Collections.emptyList() + : sysUserRepository.findAllById(userIds).stream() + .sorted(Comparator.comparing(SysUser::getId)) + .collect(Collectors.toList()); // 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列 - List userIds = users.stream().map(SysUser::getId).collect(Collectors.toList()); Map userMainGroup = resolveUserMainGroup(userIds); // 3. 批量取组名 From 5cfed17956ba1b20c438317e78348a682bd4e89f Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 13 Aug 2026 19:15:47 +0800 Subject: [PATCH 31/58] =?UTF-8?q?=E5=BC=80=E6=94=BE=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=8E=B7=E5=8F=96=E7=BB=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenApiAuthEnforceConfiguration.java | 1 + .../config/UserBaseUserDetailsProvider.java | 1 + .../controller/api/SysUserController.java | 1 + .../userbase/service/ISysGroupService.java | 27 ++++ .../service/impl/SysGroupService.java | 122 ++++++++++++++++++ 5 files changed, 152 insertions(+) diff --git a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java index 72917c5..2779fac 100644 --- a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java +++ b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java @@ -3,6 +3,7 @@ package com.yunhai.userbase.config; import com.yunhai.platform.appauth.AppAuthFilter; import com.yunhai.platform.appauth.AppAuthProperties; import com.yunhai.platform.auth.UserDetailsProvider; +import com.yunhai.userbase.repository.ApplicationInfoRepository; import com.yunhai.userbase.service.impl.UserCenterService; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java index 6de8f35..ccfec7b 100644 --- a/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java +++ b/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java @@ -3,6 +3,7 @@ package com.yunhai.userbase.config; import com.yunhai.platform.auth.UserDetailsProvider; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.service.impl.UserCenterService; +import com.yunhai.userbase.service.impl.UserCenterService; /** * 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后, diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java index bb31fae..186d5d7 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -43,6 +43,7 @@ public class SysUserController { @PostMapping("Add") public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { List data=new ArrayList<>(); + sysUserService.add(request); } diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java index ae3ad47..3ffc6e0 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -5,6 +5,7 @@ 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.platform.utils.model.BoaGroup; import com.yunhai.userbase.model.sysgroup.SysGroupVo; import java.util.List; @@ -51,4 +52,30 @@ public interface ISysGroupService { * @return 含自身在内的组ID集合;组不存在时返回仅含自身的集合 */ List listSelfAndDescendantIds(Integer groupId); + + /** + * 获取整个平台的组树:从根节点(pid=0)开始,children 递归嵌套全部后代。
+ * 根节点唯一,取最小 id 作为树入口。表为空时返回 null。 + */ + BoaGroup getGroups(); + + /** + * 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树),false/不传时只返回节点本身(children 为 null)。
+ * 组不存在时返回 null。 + * + * @param groupId 组ID + * @param withSub 是否展开子节点,缺省 false + * @return 组节点(BoaGroup) + */ + BoaGroup getGroupById(Integer groupId, Boolean withSub); + + /** + * 批量获取组;ids 逗号/空格分隔,按入参顺序返回,不存在的 id 跳过。
+ * withSub 语义同 {@link #getGroupById(Integer, Boolean)}。 + * + * @param ids 组ID列表(逗号/空格分隔) + * @param withSub 是否展开子节点,缺省 false + * @return 组节点列表(按入参顺序;不存在的跳过) + */ + List getGroupList(String ids, Boolean withSub); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 54bc3fb..f357502 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -2,6 +2,7 @@ package com.yunhai.userbase.service.impl; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.userbase.enums.IEnum; import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; @@ -223,6 +224,77 @@ public class SysGroupService implements ISysGroupService { return vo; } + // ===================== 开放接口:组树(BoaGroup) ===================== + + /** + * 获取整个平台的组树:从根节点(pid=0)开始,children 递归嵌套全部后代。
+ * 根节点唯一,取最小 id 作为树入口。表为空或无根时返回 null。 + */ + @Override + public BoaGroup getGroups() { + List all = sysGroupRepository.findAll(); + if (all.isEmpty()) { + return null; + } + Map> childrenMap = buildSortedChildrenMap(all); + List roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); + if (roots.isEmpty()) { + return null; + } + // 根节点唯一,取最小 id 作为整棵树入口 + SysGroup root = roots.stream() + .min(Comparator.comparingInt(SysGroup::getId)) + .orElse(roots.get(0)); + return buildBoaNode(root, childrenMap, true); + } + + /** + * 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树),false/不传时只返回节点本身(children 为 null)。
+ * 组不存在时返回 null。 + */ + @Override + public BoaGroup getGroupById(Integer groupId, Boolean withSub) { + if (groupId == null) { + return null; + } + SysGroup entity = sysGroupRepository.findById(groupId).orElse(null); + if (entity == null) { + return null; + } + if (!Boolean.TRUE.equals(withSub)) { + return convertToBoaGroup(entity); + } + List all = sysGroupRepository.findAll(); + Map> childrenMap = buildSortedChildrenMap(all); + return buildBoaNode(entity, childrenMap, true); + } + + /** + * 批量获取组;ids 逗号/空格分隔,按入参顺序返回,不存在的 id 跳过。
+ * withSub 语义同 {@link #getGroupById(Integer, Boolean)}。 + */ + @Override + public List getGroupList(String ids, Boolean withSub) { + List idList = parseIds(ids); + if (idList.isEmpty()) { + return new ArrayList<>(); + } + boolean expand = Boolean.TRUE.equals(withSub); + List all = sysGroupRepository.findAll(); + Map allMap = all.stream() + .collect(Collectors.toMap(SysGroup::getId, g -> g, (a, b) -> a)); + Map> childrenMap = buildSortedChildrenMap(all); + List result = new ArrayList<>(); + for (Integer id : idList) { + SysGroup g = allMap.get(id); + if (g == null) { + continue; + } + result.add(expand ? buildBoaNode(g, childrenMap, true) : convertToBoaGroup(g)); + } + return result; + } + // ===================== 批量新增 ===================== @Override @@ -374,6 +446,56 @@ public class SysGroupService implements ISysGroupService { // ===================== 私有工具 ===================== + /** SysGroup -> BoaGroup(id/name/parentId/type/sort);children 默认 null(未展开)。 */ + private BoaGroup convertToBoaGroup(SysGroup entity) { + BoaGroup bg = new BoaGroup(); + bg.setId(entity.getId()); + bg.setName(entity.getName()); + bg.setParentId(entity.getParentId()); + bg.setType(entity.getType() == null ? null : entity.getType().getKey()); + bg.setSort(entity.getSort()); + return bg; + } + + /** + * 递归组装 BoaGroup 树节点。withSub=true 时挂上全部子孙(子树),children 为列表(无子则空列表); + * withSub=false 时直接返回节点,children 保持 null(表示未展开)。 + */ + private BoaGroup buildBoaNode(SysGroup node, Map> childrenMap, boolean withSub) { + BoaGroup bg = convertToBoaGroup(node); + if (!withSub) { + return bg; + } + List childBgs = new ArrayList<>(); + List children = childrenMap.get(node.getId()); + if (children != null) { + for (SysGroup child : children) { + childBgs.add(buildBoaNode(child, childrenMap, true)); + } + } + bg.setChildren(childBgs); + return bg; + } + + /** 解析逗号/空格分隔的 id 串为 Integer 列表;非数字片段忽略。 */ + private List parseIds(String ids) { + List result = new ArrayList<>(); + if (ids == null || ids.trim().isEmpty()) { + return result; + } + for (String s : ids.split("[,\\s]+")) { + if (s == null || s.trim().isEmpty()) { + continue; + } + try { + result.add(Integer.parseInt(s.trim())); + } catch (NumberFormatException ignored) { + // 跳过非数字片段 + } + } + return result; + } + /** pid 下已有子节点的最大 sort +1;无子节点返回 1。 */ private int nextSort(Integer pid) { Integer max = sysGroupRepository.findMaxSortByParentId(pid); From b5f6679eb58e1dd2a2f2eb21d8a8b4354356c662 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 13 Aug 2026 19:19:58 +0800 Subject: [PATCH 32/58] =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=A1=A5=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/oapi/GroupController.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java new file mode 100644 index 0000000..e9bb1e4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java @@ -0,0 +1,63 @@ +package com.yunhai.userbase.controller.oapi; + +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.service.ISysGroupService; +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; + +import java.util.List; + +/** + * 组织架构开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/group/...})。
+ *
+ * 路径复用 {@link Config#OPEN_API_PATH} 统一前缀,与基础平台 {@code DefaultUserCenterClient} + * 硬编码的契约严格一致: + * {@code GET /oapi/group/tree}、{@code /byId?groupId=&withSub=}、{@code /list?ids=&withSub=}。
+ * 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求, + * 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。 + */ +@RestController +@RequestMapping(Config.OPEN_API_PATH + "group/") +public class GroupController { + + @Autowired + private ISysGroupService sysGroupService; + + /** + * 获取整个平台的组树(从根节点开始,children 递归嵌套全部后代)。 + */ + @GetMapping("tree") + public BoaGroup tree() { + return sysGroupService.getGroups(); + } + + /** + * 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树)。 + * + * @param groupId 组ID(必填) + * @param withSub 是否展开子节点,缺省 false + * @return 组节点;不存在返回 null + */ + @GetMapping("byId") + public BoaGroup byId(@RequestParam("groupId") Integer groupId, + @RequestParam(value = "withSub", required = false) Boolean withSub) { + return sysGroupService.getGroupById(groupId, withSub); + } + + /** + * 批量获取组;ids 逗号/空格分隔,按入参顺序返回,不存在的跳过。 + * + * @param ids 组ID列表(逗号/空格分隔) + * @param withSub 是否展开子节点,缺省 false + * @return 组节点列表(按入参顺序;不存在的跳过) + */ + @GetMapping("list") + public List list(@RequestParam("ids") String ids, + @RequestParam(value = "withSub", required = false) Boolean withSub) { + return sysGroupService.getGroupList(ids, withSub); + } +} From 00278d997a5bc91bc09ba35f007f53db6f9b7986 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 13 Aug 2026 21:30:19 +0800 Subject: [PATCH 33/58] =?UTF-8?q?=E6=8E=A8=E9=80=81ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .mvnlog/mvn-gs.xml | 3 +++ .mvnlog/mvn-secure.xml | 29 +++++++++++++++++++++++++++++ javac_args.txt | 8 ++++++++ 3 files changed, 40 insertions(+) create mode 100644 .mvnlog/mvn-gs.xml create mode 100644 .mvnlog/mvn-secure.xml create mode 100644 javac_args.txt diff --git a/.mvnlog/mvn-gs.xml b/.mvnlog/mvn-gs.xml new file mode 100644 index 0000000..85f34a6 --- /dev/null +++ b/.mvnlog/mvn-gs.xml @@ -0,0 +1,3 @@ + + + diff --git a/.mvnlog/mvn-secure.xml b/.mvnlog/mvn-secure.xml new file mode 100644 index 0000000..4c31414 --- /dev/null +++ b/.mvnlog/mvn-secure.xml @@ -0,0 +1,29 @@ + + + + + + nexus-releases + admin + @4Jo8iDj10uSHvg + + + nexus-snapshots + admin + @4Jo8iDj10uSHvg + + + + + + nexus-public + Nexus Public Group + http://nexus.yunhaixingtong.com/repository/maven-public/ + * + true + + + + diff --git a/javac_args.txt b/javac_args.txt new file mode 100644 index 0000000..b774805 --- /dev/null +++ b/javac_args.txt @@ -0,0 +1,8 @@ +-encoding +UTF-8 +-cp +c:/Users/Administrator/.m2/repository/antlr/antlr/2.7.2/antlr-2.7.2.jar;c:/Users/Administrator/.m2/repository/antlr/antlr/2.7.7/antlr-2.7.7.jar;c:/Users/Administrator/.m2/repository/aopalliance/aopalliance/1.0/aopalliance-1.0.jar;c:/Users/Administrator/.m2/repository/asm/asm/3.3.1/asm-3.3.1.jar;c:/Users/Administrator/.m2/repository/asm/asm-analysis/3.2/asm-analysis-3.2.jar;c:/Users/Administrator/.m2/repository/asm/asm-commons/3.3.1/asm-commons-3.3.1.jar;c:/Users/Administrator/.m2/repository/asm/asm-tree/3.3.1/asm-tree-3.3.1.jar;c:/Users/Administrator/.m2/repository/asm/asm-util/3.2/asm-util-3.2.jar;c:/Users/Administrator/.m2/repository/backport-util-concurrent/backport-util-concurrent/3.1/backport-util-concurrent-3.1.jar;c:/Users/Administrator/.m2/repository/bouncycastle/bcmail-jdk14/138/bcmail-jdk14-138.jar;c:/Users/Administrator/.m2/repository/bouncycastle/bcprov-jdk14/138/bcprov-jdk14-138.jar;c:/Users/Administrator/.m2/repository/cglib/cglib-nodep/2.1_3/cglib-nodep-2.1_3.jar;c:/Users/Administrator/.m2/repository/ch/qos/logback/logback-classic/1.1.11/logback-classic-1.1.11.jar;c:/Users/Administrator/.m2/repository/ch/qos/logback/logback-classic/1.2.12/logback-classic-1.2.12.jar;c:/Users/Administrator/.m2/repository/ch/qos/logback/logback-classic/1.2.3/logback-classic-1.2.3.jar;c:/Users/Administrator/.m2/repository/ch/qos/logback/logback-core/1.1.11/logback-core-1.1.11.jar;c:/Users/Administrator/.m2/repository/ch/qos/logback/logback-core/1.2.12/logback-core-1.2.12.jar;c:/Users/Administrator/.m2/repository/ch/qos/logback/logback-core/1.2.3/logback-core-1.2.3.jar;c:/Users/Administrator/.m2/repository/classworlds/classworlds/1.1/classworlds-1.1.jar;c:/Users/Administrator/.m2/repository/classworlds/classworlds/1.1-alpha-2/classworlds-1.1-alpha-2.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-annotation/3.2.0/easypoi-annotation-3.2.0.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-annotation/4.1.0/easypoi-annotation-4.1.0.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-annotation/4.2.0/easypoi-annotation-4.2.0.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-base/3.2.0/easypoi-base-3.2.0.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-base/4.1.0/easypoi-base-4.1.0.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-base/4.2.0/easypoi-base-4.2.0.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-spring-boot-starter/4.2.0/easypoi-spring-boot-starter-4.2.0.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-web/3.2.0/easypoi-web-3.2.0.jar;c:/Users/Administrator/.m2/repository/cn/afterturn/easypoi-web/4.2.0/easypoi-web-4.2.0.jar;c:/Users/Administrator/.m2/repository/cn/hutool/hutool-all/4.6.3/hutool-all-4.6.3.jar;c:/Users/Administrator/.m2/repository/cn/hutool/hutool-all/5.0.4/hutool-all-5.0.4.jar;c:/Users/Administrator/.m2/repository/cn/hutool/hutool-all/5.7.11/hutool-all-5.7.11.jar;c:/Users/Administrator/.m2/repository/cn/hutool/hutool-all/5.8.16/hutool-all-5.8.16.jar;c:/Users/Administrator/.m2/repository/cn/hutool/hutool-all/5.8.25/hutool-all-5.8.25.jar;c:/Users/Administrator/.m2/repository/cn/hutool/hutool-all/5.8.44/hutool-all-5.8.44.jar;c:/Users/Administrator/.m2/repository/cn/hutool/hutool-core/5.8.36/hutool-core-5.8.36.jar;c:/Users/Administrator/.m2/repository/com/alibaba/cloud/spring-cloud-alibaba-commons/2021.0.5.0/spring-cloud-alibaba-commons-2021.0.5.0.jar;c:/Users/Administrator/.m2/repository/com/alibaba/cloud/spring-cloud-starter-alibaba-nacos-config/2021.0.5.0/spring-cloud-starter-alibaba-nacos-config-2021.0.5.0.jar;c:/Users/Administrator/.m2/repository/com/alibaba/dashscope-sdk-java/2.20.1/dashscope-sdk-java-2.20.1.jar;c:/Users/Administrator/.m2/repository/com/alibaba/easyexcel/3.1.2/easyexcel-3.1.2.jar;c:/Users/Administrator/.m2/repository/com/alibaba/easyexcel-core/3.1.2/easyexcel-core-3.1.2.jar;c:/Users/Administrator/.m2/repository/com/alibaba/easyexcel-support/3.1.2/easyexcel-support-3.1.2.jar;c:/Users/Administrator/.m2/repository/com/alibaba/fastjson/1.2.47/fastjson-1.2.47.jar;c:/Users/Administrator/.m2/repository/com/alibaba/fastjson/1.2.54/fastjson-1.2.54.jar;c:/Users/Administrator/.m2/repository/com/alibaba/fastjson/1.2.68/fastjson-1.2.68.jar;c:/Users/Administrator/.m2/repository/com/alibaba/fastjson/1.2.80/fastjson-1.2.80.jar;c:/Users/Administrator/.m2/repository/com/alibaba/fastjson/1.2.83/fastjson-1.2.83.jar;c:/Users/Administrator/.m2/repository/com/alibaba/fastjson2/fastjson2/2.0.52/fastjson2-2.0.52.jar;c:/Users/Administrator/.m2/repository/com/alibaba/nacos/nacos-auth-plugin/2.2.0/nacos-auth-plugin-2.2.0.jar;c:/Users/Administrator/.m2/repository/com/alibaba/nacos/nacos-client/2.2.0/nacos-client-2.2.0.jar;c:/Users/Administrator/.m2/repository/com/alibaba/nacos/nacos-encryption-plugin/2.2.0/nacos-encryption-plugin-2.2.0.jar;c:/Users/Administrator/.m2/repository/com/alibaba/spring/spring-context-support/1.0.11/spring-context-support-1.0.11.jar;c:/Users/Administrator/.m2/repository/com/baidu/aip/java-sdk/4.16.19/java-sdk-4.16.19.jar;c:/Users/Administrator/.m2/repository/com/belerweb/pinyin4j/2.5.1/pinyin4j-2.5.1.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-api-std/1.0.5-SNAPSHOT/bsch-api-std-1.0.5-20240325.091737-9.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-api-std/1.0.5-SNAPSHOT/bsch-api-std-1.0.5-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-dding-sso/0.0.1-SNAPSHOT/bsch-dding-sso-0.0.1-20230520.033331-17.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-dding-sso/0.0.1-SNAPSHOT/bsch-dding-sso-0.0.1-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-msg-api/1.0.0-SNAPSHOT/bsch-msg-api-1.0.0-20230105.054753-16.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-msg-api/1.0.0-SNAPSHOT/bsch-msg-api-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-open-api/1.1.2-SNAPSHOT/bsch-open-api-1.1.2-20250911.060839-67.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-open-api/1.1.2-SNAPSHOT/bsch-open-api-1.1.2-20251121.074709-69.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-open-api/1.1.2-SNAPSHOT/bsch-open-api-1.1.2-20251218.074724-70.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-open-api/1.1.2-SNAPSHOT/bsch-open-api-1.1.2-20260702.033235-71.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-open-api/1.1.2-SNAPSHOT/bsch-open-api-1.1.2-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-qywx-sso/0.0.1-SNAPSHOT/bsch-qywx-sso-0.0.1-20251214.042146-34.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-qywx-sso/0.0.1-SNAPSHOT/bsch-qywx-sso-0.0.1-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-spring-boot-starter/1.2.5-SNAPSHOT/bsch-spring-boot-starter-1.2.5-20220527.064557-3.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-spring-boot-starter/1.2.5-SNAPSHOT/bsch-spring-boot-starter-1.2.5-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-sso/1.2.2-SNAPSHOT/bsch-sso-1.2.2-20250515.015006-72.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-sso/1.2.2-SNAPSHOT/bsch-sso-1.2.2-20251113.075204-73.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-sso/1.2.2-SNAPSHOT/bsch-sso-1.2.2-20260514.075917-75.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-sso/1.2.2-SNAPSHOT/bsch-sso-1.2.2-20260724.013240-76.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-sso/1.2.2-SNAPSHOT/bsch-sso-1.2.2-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-utils/1.2-SNAPSHOT/bsch-utils-1.2-20250515.014957-56.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-utils/1.2-SNAPSHOT/bsch-utils-1.2-20251113.075223-57.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-utils/1.2-SNAPSHOT/bsch-utils-1.2-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-wxgzh-sso/0.0.1-SNAPSHOT/bsch-wxgzh-sso-0.0.1-20250730.091156-21.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bsch-wxgzh-sso/0.0.1-SNAPSHOT/bsch-wxgzh-sso-0.0.1-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/bestsch/bschlearn/0.0.1/bschlearn-0.0.1.jar;c:/Users/Administrator/.m2/repository/com/beust/jcommander/1.48/jcommander-1.48.jar;c:/Users/Administrator/.m2/repository/com/cenqua/clover/clover/3.1.12/clover-3.1.12.jar;c:/Users/Administrator/.m2/repository/com/deepoove/poi-ooxml-schemas-extra/5.1.0/poi-ooxml-schemas-extra-5.1.0.jar;c:/Users/Administrator/.m2/repository/com/deepoove/poi-tl/1.11.1/poi-tl-1.11.1.jar;c:/Users/Administrator/.m2/repository/com/deepoove/poi-tl/1.7.2/poi-tl-1.7.2.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/classmate/1.5.0/classmate-1.5.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/classmate/1.5.1/classmate-1.5.1.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.10.0/jackson-annotations-2.10.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.10.1/jackson-annotations-2.10.1.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.10.2/jackson-annotations-2.10.2.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.11.0/jackson-annotations-2.11.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.13.0/jackson-annotations-2.13.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.13.5/jackson-annotations-2.13.5.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.8.0/jackson-annotations-2.8.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.9.9/jackson-annotations-2.9.9.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.10.0/jackson-core-2.10.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.10.1/jackson-core-2.10.1.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.10.2/jackson-core-2.10.2.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.11.0/jackson-core-2.11.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.13.0/jackson-core-2.13.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.13.5/jackson-core-2.13.5.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.8.11/jackson-core-2.8.11.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.9.3/jackson-core-2.9.3.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.10.0/jackson-databind-2.10.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.10.1/jackson-databind-2.10.1.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.10.2/jackson-databind-2.10.2.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.11.0/jackson-databind-2.11.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.13.0/jackson-databind-2.13.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.13.5/jackson-databind-2.13.5.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.8.11.3/jackson-databind-2.8.11.3.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.9.9/jackson-databind-2.9.9.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.10.2/jackson-dataformat-yaml-2.10.2.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.13.5/jackson-dataformat-yaml-2.13.5.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.8.11/jackson-dataformat-yaml-2.8.11.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.9.9/jackson-dataformat-yaml-2.9.9.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-guava/2.8.11/jackson-datatype-guava-2.8.11.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-guava/2.9.9/jackson-datatype-guava-2.9.9.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jdk8/2.10.0/jackson-datatype-jdk8-2.10.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jdk8/2.10.1/jackson-datatype-jdk8-2.10.1.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jdk8/2.10.2/jackson-datatype-jdk8-2.10.2.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jdk8/2.11.0/jackson-datatype-jdk8-2.11.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jdk8/2.13.5/jackson-datatype-jdk8-2.13.5.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.10.0/jackson-datatype-jsr310-2.10.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.10.1/jackson-datatype-jsr310-2.10.1.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.10.2/jackson-datatype-jsr310-2.10.2.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.11.0/jackson-datatype-jsr310-2.11.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.13.5/jackson-datatype-jsr310-2.13.5.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.8.11/jackson-datatype-jsr310-2.8.11.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.9.9/jackson-datatype-jsr310-2.9.9.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/jaxrs/jackson-jaxrs-base/2.8.11/jackson-jaxrs-base-2.8.11.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/jaxrs/jackson-jaxrs-base/2.9.9/jackson-jaxrs-base-2.9.9.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/jaxrs/jackson-jaxrs-json-provider/2.8.11/jackson-jaxrs-json-provider-2.8.11.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/jaxrs/jackson-jaxrs-json-provider/2.9.9/jackson-jaxrs-json-provider-2.9.9.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/module/jackson-module-jaxb-annotations/2.8.11/jackson-module-jaxb-annotations-2.8.11.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/module/jackson-module-jaxb-annotations/2.9.9/jackson-module-jaxb-annotations-2.9.9.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/module/jackson-module-parameter-names/2.10.0/jackson-module-parameter-names-2.10.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/module/jackson-module-parameter-names/2.10.1/jackson-module-parameter-names-2.10.1.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/module/jackson-module-parameter-names/2.10.2/jackson-module-parameter-names-2.10.2.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/module/jackson-module-parameter-names/2.11.0/jackson-module-parameter-names-2.11.0.jar;c:/Users/Administrator/.m2/repository/com/fasterxml/jackson/module/jackson-module-parameter-names/2.13.5/jackson-module-parameter-names-2.13.5.jar;c:/Users/Administrator/.m2/repository/com/github/axet/threads/0.0.14/threads-0.0.14.jar;c:/Users/Administrator/.m2/repository/com/github/axet/wget/1.4.9/wget-1.4.9.jar;c:/Users/Administrator/.m2/repository/com/github/ben-manes/caffeine/caffeine/2.3.5/caffeine-2.3.5.jar;c:/Users/Administrator/.m2/repository/com/github/ben-manes/caffeine/caffeine/2.6.2/caffeine-2.6.2.jar;c:/Users/Administrator/.m2/repository/com/github/ben-manes/caffeine/caffeine/2.9.3/caffeine-2.9.3.jar;c:/Users/Administrator/.m2/repository/com/github/ben-manes/caffeine/guava/2.6.2/guava-2.6.2.jar;c:/Users/Administrator/.m2/repository/com/github/jai-imageio/jai-imageio-core/1.4.0/jai-imageio-core-1.4.0.jar;c:/Users/Administrator/.m2/repository/com/github/javaparser/javaparser-core/3.6.5/javaparser-core-3.6.5.jar;c:/Users/Administrator/.m2/repository/com/github/luben/zstd-jni/1.5.5-11/zstd-jni-1.5.5-11.jar;c:/Users/Administrator/.m2/repository/com/github/luben/zstd-jni/1.5.7-6/zstd-jni-1.5.7-6.jar;c:/Users/Administrator/.m2/repository/com/github/oshi/oshi-core/3.5.0/oshi-core-3.5.0.jar;c:/Users/Administrator/.m2/repository/com/github/penggle/kaptcha/2.3.2/kaptcha-2.3.2.jar;c:/Users/Administrator/.m2/repository/com/github/scribejava/scribejava-apis/5.6.0/scribejava-apis-5.6.0.jar;c:/Users/Administrator/.m2/repository/com/github/scribejava/scribejava-core/5.6.0/scribejava-core-5.6.0.jar;c:/Users/Administrator/.m2/repository/com/github/stephenc/jcip/jcip-annotations/1.0-1/jcip-annotations-1.0-1.jar;c:/Users/Administrator/.m2/repository/com/github/victools/jsonschema-generator/4.31.1/jsonschema-generator-4.31.1.jar;c:/Users/Administrator/.m2/repository/com/github/virtuald/curvesapi/1.03/curvesapi-1.03.jar;c:/Users/Administrator/.m2/repository/com/github/virtuald/curvesapi/1.04/curvesapi-1.04.jar;c:/Users/Administrator/.m2/repository/com/github/virtuald/curvesapi/1.06/curvesapi-1.06.jar;c:/Users/Administrator/.m2/repository/com/github/virtuald/curvesapi/1.07/curvesapi-1.07.jar;c:/Users/Administrator/.m2/repository/com/github/virtuald/curvesapi/1.08/curvesapi-1.08.jar;c:/Users/Administrator/.m2/repository/com/google/code/findbugs/jsr305/1.3.9/jsr305-1.3.9.jar;c:/Users/Administrator/.m2/repository/com/google/code/findbugs/jsr305/2.0.1/jsr305-2.0.1.jar;c:/Users/Administrator/.m2/repository/com/google/code/findbugs/jsr305/3.0.1/jsr305-3.0.1.jar;c:/Users/Administrator/.m2/repository/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar;c:/Users/Administrator/.m2/repository/com/google/code/gson/gson/2.8.5/gson-2.8.5.jar;c:/Users/Administrator/.m2/repository/com/google/code/gson/gson/2.8.6/gson-2.8.6.jar;c:/Users/Administrator/.m2/repository/com/google/collections/google-collections/1.0/google-collections-1.0.jar;c:/Users/Administrator/.m2/repository/com/google/errorprone/error_prone_annotations/2.0.18/error_prone_annotations-2.0.18.jar;c:/Users/Administrator/.m2/repository/com/google/errorprone/error_prone_annotations/2.1.3/error_prone_annotations-2.1.3.jar;c:/Users/Administrator/.m2/repository/com/google/errorprone/error_prone_annotations/2.10.0/error_prone_annotations-2.10.0.jar;c:/Users/Administrator/.m2/repository/com/google/errorprone/error_prone_annotations/2.3.4/error_prone_annotations-2.3.4.jar;c:/Users/Administrator/.m2/repository/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/10.0.1/guava-10.0.1.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/16.0.1/guava-16.0.1.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/18.0/guava-18.0.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/19.0/guava-19.0.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/20.0/guava-20.0.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/23.0/guava-23.0.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/25.0-jre/guava-25.0-jre.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/25.1-jre/guava-25.1-jre.jar;c:/Users/Administrator/.m2/repository/com/google/guava/guava/28.2-android/guava-28.2-android.jar;c:/Users/Administrator/.m2/repository/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;c:/Users/Administrator/.m2/repository/com/google/inject/guice/4.0/guice-4.0-no_aop.jar;c:/Users/Administrator/.m2/repository/com/google/j2objc/j2objc-annotations/1.1/j2objc-annotations-1.1.jar;c:/Users/Administrator/.m2/repository/com/google/j2objc/j2objc-annotations/1.3/j2objc-annotations-1.3.jar;c:/Users/Administrator/.m2/repository/com/google/zxing/core/3.3.2/core-3.3.2.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/commons/7.2.3/commons-7.2.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/font-asian/7.2.3/font-asian-7.2.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/forms/7.2.3/forms-7.2.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/html2pdf/4.0.3/html2pdf-4.0.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/io/7.2.3/io-7.2.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/itext-asian/5.2.0/itext-asian-5.2.0.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/itextpdf/5.5.13/itextpdf-5.5.13.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/kernel/7.2.3/kernel-7.2.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/layout/7.2.3/layout-7.2.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/styled-xml-parser/7.2.3/styled-xml-parser-7.2.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/svg/7.2.3/svg-7.2.3.jar;c:/Users/Administrator/.m2/repository/com/itextpdf/tool/xmlworker/5.5.13/xmlworker-5.5.13.jar;c:/Users/Administrator/.m2/repository/com/jayway/jsonpath/json-path/2.4.0/json-path-2.4.0.jar;c:/Users/Administrator/.m2/repository/com/jayway/jsonpath/json-path/2.7.0/json-path-2.7.0.jar;c:/Users/Administrator/.m2/repository/com/jhlabs/filters/2.0.235-1/filters-2.0.235-1.jar;c:/Users/Administrator/.m2/repository/com/lmax/disruptor/3.4.2/disruptor-3.4.2.jar;c:/Users/Administrator/.m2/repository/com/lowagie/itext/2.1.7/itext-2.1.7.jar;c:/Users/Administrator/.m2/repository/com/lowagie/itext-rtf/2.1.7/itext-rtf-2.1.7.jar;c:/Users/Administrator/.m2/repository/com/mchange/mchange-commons-java/0.2.11/mchange-commons-java-0.2.11.jar;c:/Users/Administrator/.m2/repository/com/microsoft/azure/adal4j/1.0.0/adal4j-1.0.0.jar;c:/Users/Administrator/.m2/repository/com/microsoft/azure/azure-core/0.9.3/azure-core-0.9.3.jar;c:/Users/Administrator/.m2/repository/com/microsoft/azure/azure-keyvault/0.9.3/azure-keyvault-0.9.3.jar;c:/Users/Administrator/.m2/repository/com/microsoft/sqlserver/mssql-jdbc/6.1.0.jre7/mssql-jdbc-6.1.0.jre7.jar;c:/Users/Administrator/.m2/repository/com/microsoft/sqlserver/mssql-jdbc/6.4.0.jre8/mssql-jdbc-6.4.0.jre8.jar;c:/Users/Administrator/.m2/repository/com/mysql/mysql-connector-j/8.0.33/mysql-connector-j-8.0.33.jar;c:/Users/Administrator/.m2/repository/com/nimbusds/lang-tag/1.7/lang-tag-1.7.jar;c:/Users/Administrator/.m2/repository/com/nimbusds/nimbus-jose-jwt/6.0.2/nimbus-jose-jwt-6.0.2.jar;c:/Users/Administrator/.m2/repository/com/nimbusds/oauth2-oidc-sdk/4.5/oauth2-oidc-sdk-4.5.jar;c:/Users/Administrator/.m2/repository/com/nimbusds/oauth2-oidc-sdk/6.5/oauth2-oidc-sdk-6.5.jar;c:/Users/Administrator/.m2/repository/com/papertrail/profiler/1.0.2/profiler-1.0.2.jar;c:/Users/Administrator/.m2/repository/com/rabbitmq/amqp-client/5.7.3/amqp-client-5.7.3.jar;c:/Users/Administrator/.m2/repository/com/rabbitmq/amqp-client/5.9.0/amqp-client-5.9.0.jar;c:/Users/Administrator/.m2/repository/com/rimerosolutions/maven/plugins/wrapper-maven-plugin/0.0.5/wrapper-maven-plugin-0.0.5.jar;c:/Users/Administrator/.m2/repository/com/ryantenney/metrics/metrics-spring/3.1.3/metrics-spring-3.1.3.jar;c:/Users/Administrator/.m2/repository/com/squareup/okhttp/logging-interceptor/2.7.5/logging-interceptor-2.7.5.jar;c:/Users/Administrator/.m2/repository/com/squareup/okhttp/okhttp/2.7.5/okhttp-2.7.5.jar;c:/Users/Administrator/.m2/repository/com/squareup/okhttp3/logging-interceptor/4.12.0/logging-interceptor-4.12.0.jar;c:/Users/Administrator/.m2/repository/com/squareup/okhttp3/okhttp/3.14.9/okhttp-3.14.9.jar;c:/Users/Administrator/.m2/repository/com/squareup/okhttp3/okhttp/3.9.1/okhttp-3.9.1.jar;c:/Users/Administrator/.m2/repository/com/squareup/okhttp3/okhttp/4.9.3/okhttp-4.9.3.jar;c:/Users/Administrator/.m2/repository/com/squareup/okhttp3/okhttp-sse/4.12.0/okhttp-sse-4.12.0.jar;c:/Users/Administrator/.m2/repository/com/squareup/okio/okio/1.13.0/okio-1.13.0.jar;c:/Users/Administrator/.m2/repository/com/squareup/okio/okio/2.8.0/okio-2.8.0.jar;c:/Users/Administrator/.m2/repository/com/squareup/okio/okio/3.6.0/okio-3.6.0.jar;c:/Users/Administrator/.m2/repository/com/squareup/okio/okio-jvm/3.6.0/okio-jvm-3.6.0.jar;c:/Users/Administrator/.m2/repository/com/squareup/retrofit2/adapter-rxjava2/2.9.0/adapter-rxjava2-2.9.0.jar;c:/Users/Administrator/.m2/repository/com/squareup/retrofit2/converter-jackson/2.9.0/converter-jackson-2.9.0.jar;c:/Users/Administrator/.m2/repository/com/squareup/retrofit2/retrofit/2.9.0/retrofit-2.9.0.jar;c:/Users/Administrator/.m2/repository/com/sun/activation/jakarta.activation/1.2.2/jakarta.activation-1.2.2.jar;c:/Users/Administrator/.m2/repository/com/sun/istack/istack-commons-runtime/3.0.11/istack-commons-runtime-3.0.11.jar;c:/Users/Administrator/.m2/repository/com/sun/istack/istack-commons-runtime/3.0.12/istack-commons-runtime-3.0.12.jar;c:/Users/Administrator/.m2/repository/com/sun/istack/istack-commons-runtime/3.0.8/istack-commons-runtime-3.0.8.jar;c:/Users/Administrator/.m2/repository/com/sun/jersey/jersey-client/1.13/jersey-client-1.13.jar;c:/Users/Administrator/.m2/repository/com/sun/jersey/jersey-core/1.13/jersey-core-1.13.jar;c:/Users/Administrator/.m2/repository/com/sun/jersey/jersey-json/1.13/jersey-json-1.13.jar;c:/Users/Administrator/.m2/repository/com/sun/mail/javax.mail/1.5.6/javax.mail-1.5.6.jar;c:/Users/Administrator/.m2/repository/com/sun/mail/javax.mail/1.6.2/javax.mail-1.6.2.jar;c:/Users/Administrator/.m2/repository/com/sun/xml/bind/jaxb-impl/2.2.3-1/jaxb-impl-2.2.3-1.jar;c:/Users/Administrator/.m2/repository/com/sun/xml/fastinfoset/FastInfoset/1.2.16/FastInfoset-1.2.16.jar;c:/Users/Administrator/.m2/repository/com/tencentcloudapi/tencentcloud-sdk-java-common/3.1.1471/tencentcloud-sdk-java-common-3.1.1471.jar;c:/Users/Administrator/.m2/repository/com/tencentcloudapi/tencentcloud-sdk-java-common/3.1.1473/tencentcloud-sdk-java-common-3.1.1473.jar;c:/Users/Administrator/.m2/repository/com/tencentcloudapi/tencentcloud-sdk-java-common/3.1.1479/tencentcloud-sdk-java-common-3.1.1479.jar;c:/Users/Administrator/.m2/repository/com/tencentcloudapi/tencentcloud-sdk-java-cvm/3.1.1468/tencentcloud-sdk-java-cvm-3.1.1468.jar;c:/Users/Administrator/.m2/repository/com/tencentcloudapi/tencentcloud-sdk-java-cvm/3.1.1473/tencentcloud-sdk-java-cvm-3.1.1473.jar;c:/Users/Administrator/.m2/repository/com/tencentcloudapi/tencentcloud-sdk-java-cvm/3.1.1474/tencentcloud-sdk-java-cvm-3.1.1474.jar;c:/Users/Administrator/.m2/repository/com/thoughtworks/qdox/qdox/2.0-M8/qdox-2.0-M8.jar;c:/Users/Administrator/.m2/repository/com/thoughtworks/qdox/qdox/2.0-M9/qdox-2.0-M9.jar;c:/Users/Administrator/.m2/repository/com/thoughtworks/qdox/qdox/2.0.1/qdox-2.0.1.jar;c:/Users/Administrator/.m2/repository/com/thoughtworks/qdox/qdox/2.0.3/qdox-2.0.3.jar;c:/Users/Administrator/.m2/repository/com/thoughtworks/qdox/qdox/2.1.0/qdox-2.1.0.jar;c:/Users/Administrator/.m2/repository/com/thoughtworks/qdox/qdox/2.2.0/qdox-2.2.0.jar;c:/Users/Administrator/.m2/repository/com/thoughtworks/xstream/xstream/1.4.11.1/xstream-1.4.11.1.jar;c:/Users/Administrator/.m2/repository/com/thoughtworks/xstream/xstream/1.4.4/xstream-1.4.4.jar;c:/Users/Administrator/.m2/repository/com/timgroup/java-statsd-client/3.1.0/java-statsd-client-3.1.0.jar;c:/Users/Administrator/.m2/repository/com/vaadin/external/google/android-json/0.0.20131108.vaadin1/android-json-0.0.20131108.vaadin1.jar;c:/Users/Administrator/.m2/repository/com/vdurmont/semver4j/2.2.0/semver4j-2.2.0.jar;c:/Users/Administrator/.m2/repository/com/volcengine/volcengine-java-sdk-ark/2.0.6/volcengine-java-sdk-ark-2.0.6.jar;c:/Users/Administrator/.m2/repository/com/volcengine/volcengine-java-sdk-ark/2.0.8/volcengine-java-sdk-ark-2.0.8.jar;c:/Users/Administrator/.m2/repository/com/volcengine/volcengine-java-sdk-ark-runtime/2.0.6/volcengine-java-sdk-ark-runtime-2.0.6.jar;c:/Users/Administrator/.m2/repository/com/volcengine/volcengine-java-sdk-ark-runtime/2.0.8/volcengine-java-sdk-ark-runtime-2.0.8.jar;c:/Users/Administrator/.m2/repository/com/volcengine/volcengine-java-sdk-core/2.0.6/volcengine-java-sdk-core-2.0.6.jar;c:/Users/Administrator/.m2/repository/com/volcengine/volcengine-java-sdk-core/2.0.8/volcengine-java-sdk-core-2.0.8.jar;c:/Users/Administrator/.m2/repository/com/whalin/Memcached-Java-Client/3.0.2/Memcached-Java-Client-3.0.2.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-api-std/1.0.0-SNAPSHOT/yh-api-std-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-app-auth-spring-boot-starter/1.0.0-SNAPSHOT/yh-app-auth-spring-boot-starter-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-auth-core/1.0.0-SNAPSHOT/yh-auth-core-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter/1.0.0-SNAPSHOT/yh-auth-spring-boot-starter-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-http-spring-boot-starter/1.0.0-SNAPSHOT/yh-http-spring-boot-starter-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-openapi-client/1.0.0-SNAPSHOT/yh-openapi-client-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-openapi-spring-boot-starter/1.0.0-SNAPSHOT/yh-openapi-spring-boot-starter-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-spring-boot-starter/1.0.0-SNAPSHOT/yh-spring-boot-starter-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/yunhai/platform/yh-utils-core/1.0.0-SNAPSHOT/yh-utils-core-1.0.0-SNAPSHOT.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/HikariCP/2.5.1/HikariCP-2.5.1.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/HikariCP/2.7.9/HikariCP-2.7.9.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/HikariCP/3.2.0/HikariCP-3.2.0.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/HikariCP/3.4.1/HikariCP-3.4.1.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/HikariCP/3.4.2/HikariCP-3.4.2.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/HikariCP/3.4.5/HikariCP-3.4.5.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/HikariCP/4.0.3/HikariCP-4.0.3.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/SparseBitSet/1.2/SparseBitSet-1.2.jar;c:/Users/Administrator/.m2/repository/com/zaxxer/SparseBitSet/1.3/SparseBitSet-1.3.jar;c:/Users/Administrator/.m2/repository/commons-attributes/commons-attributes-api/2.1/commons-attributes-api-2.1.jar;c:/Users/Administrator/.m2/repository/commons-attributes/commons-attributes-compiler/2.1/commons-attributes-compiler-2.1.jar;c:/Users/Administrator/.m2/repository/commons-beanutils/commons-beanutils/1.7.0/commons-beanutils-1.7.0.jar;c:/Users/Administrator/.m2/repository/commons-beanutils/commons-beanutils/1.8.0/commons-beanutils-1.8.0.jar;c:/Users/Administrator/.m2/repository/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar;c:/Users/Administrator/.m2/repository/commons-chain/commons-chain/1.1/commons-chain-1.1.jar;c:/Users/Administrator/.m2/repository/commons-cli/commons-cli/1.0/commons-cli-1.0.jar;c:/Users/Administrator/.m2/repository/commons-cli/commons-cli/1.2/commons-cli-1.2.jar;c:/Users/Administrator/.m2/repository/commons-cli/commons-cli/1.4/commons-cli-1.4.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.10/commons-codec-1.10.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.11/commons-codec-1.11.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.13/commons-codec-1.13.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.14/commons-codec-1.14.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.15/commons-codec-1.15.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.16.1/commons-codec-1.16.1.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.19.0/commons-codec-1.19.0.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.3/commons-codec-1.3.jar;c:/Users/Administrator/.m2/repository/commons-codec/commons-codec/1.6/commons-codec-1.6.jar;c:/Users/Administrator/.m2/repository/commons-collections/commons-collections/3.1/commons-collections-3.1.jar;c:/Users/Administrator/.m2/repository/commons-collections/commons-collections/3.2.1/commons-collections-3.2.1.jar;c:/Users/Administrator/.m2/repository/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar;c:/Users/Administrator/.m2/repository/commons-digester/commons-digester/1.6/commons-digester-1.6.jar;c:/Users/Administrator/.m2/repository/commons-digester/commons-digester/1.8/commons-digester-1.8.jar;c:/Users/Administrator/.m2/repository/commons-fileupload/commons-fileupload/1.3.1/commons-fileupload-1.3.1.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/1.3.2/commons-io-1.3.2.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/1.4/commons-io-1.4.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/2.16.1/commons-io-2.16.1.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/2.2/commons-io-2.2.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/2.21.0/commons-io-2.21.0.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/2.4/commons-io-2.4.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/2.5/commons-io-2.5.jar;c:/Users/Administrator/.m2/repository/commons-io/commons-io/2.6/commons-io-2.6.jar;c:/Users/Administrator/.m2/repository/commons-jexl/commons-jexl/1.1/commons-jexl-1.1.jar;c:/Users/Administrator/.m2/repository/commons-lang/commons-lang/2.1/commons-lang-2.1.jar;c:/Users/Administrator/.m2/repository/commons-lang/commons-lang/2.3/commons-lang-2.3.jar;c:/Users/Administrator/.m2/repository/commons-lang/commons-lang/2.4/commons-lang-2.4.jar;c:/Users/Administrator/.m2/repository/commons-lang/commons-lang/2.5/commons-lang-2.5.jar;c:/Users/Administrator/.m2/repository/commons-lang/commons-lang/2.6/commons-lang-2.6.jar;c:/Users/Administrator/.m2/repository/commons-logging/commons-logging/1.0.4/commons-logging-1.0.4.jar;c:/Users/Administrator/.m2/repository/commons-logging/commons-logging/1.1.1/commons-logging-1.1.1.jar;c:/Users/Administrator/.m2/repository/commons-logging/commons-logging/1.2/commons-logging-1.2.jar;c:/Users/Administrator/.m2/repository/commons-logging/commons-logging-api/1.1/commons-logging-api-1.1.jar;c:/Users/Administrator/.m2/repository/commons-pool/commons-pool/1.5.6/commons-pool-1.5.6.jar;c:/Users/Administrator/.m2/repository/commons-pool/commons-pool/1.6/commons-pool-1.6.jar;c:/Users/Administrator/.m2/repository/commons-validator/commons-validator/1.3.1/commons-validator-1.3.1.jar;c:/Users/Administrator/.m2/repository/commons-validator/commons-validator/1.6/commons-validator-1.6.jar;c:/Users/Administrator/.m2/repository/concurrent/concurrent/1.3.4/concurrent-1.3.4.jar;c:/Users/Administrator/.m2/repository/de/rototor/snuggletex/snuggletex-core/1.3.0/snuggletex-core-1.3.0.jar;c:/Users/Administrator/.m2/repository/dom4j/dom4j/1.1/dom4j-1.1.jar;c:/Users/Administrator/.m2/repository/dom4j/dom4j/1.6.1/dom4j-1.6.1.jar;c:/Users/Administrator/.m2/repository/geronimo-spec/geronimo-spec-jta/1.0.1B-rc4/geronimo-spec-jta-1.0.1B-rc4.jar;c:/Users/Administrator/.m2/repository/io/airlift/aircompressor/0.27/aircompressor-0.27.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-annotation/3.1.5/metrics-annotation-3.1.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-annotation/3.2.5/metrics-annotation-3.2.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-core/3.1.5/metrics-core-3.1.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-core/3.2.5/metrics-core-3.2.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-healthchecks/3.1.5/metrics-healthchecks-3.1.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-healthchecks/3.2.5/metrics-healthchecks-3.2.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-json/3.1.5/metrics-json-3.1.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-json/3.2.5/metrics-json-3.2.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-jvm/3.1.5/metrics-jvm-3.1.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-jvm/3.2.5/metrics-jvm-3.2.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-servlets/3.1.5/metrics-servlets-3.1.5.jar;c:/Users/Administrator/.m2/repository/io/dropwizard/metrics/metrics-servlets/3.2.5/metrics-servlets-3.2.5.jar;c:/Users/Administrator/.m2/repository/io/github/draco1023/poi-tl-ext/0.3.11/poi-tl-ext-0.3.11.jar;c:/Users/Administrator/.m2/repository/io/github/mymonstercat/rapidocr/0.0.7/rapidocr-0.0.7.jar;c:/Users/Administrator/.m2/repository/io/github/mymonstercat/rapidocr-common/0.0.7/rapidocr-common-0.0.7.jar;c:/Users/Administrator/.m2/repository/io/github/mymonstercat/rapidocr-onnx-linux-x86_64/1.2.2/rapidocr-onnx-linux-x86_64-1.2.2.jar;c:/Users/Administrator/.m2/repository/io/github/mymonstercat/rapidocr-onnx-models/1.2.2/rapidocr-onnx-models-1.2.2.jar;c:/Users/Administrator/.m2/repository/io/github/mymonstercat/rapidocr-onnx-platform/0.0.7/rapidocr-onnx-platform-0.0.7.jar;c:/Users/Administrator/.m2/repository/io/github/mymonstercat/rapidocr-onnx-windows-x86_64/1.2.2/rapidocr-onnx-windows-x86_64-1.2.2.jar;c:/Users/Administrator/.m2/repository/io/github/openfeign/feign-core/11.10/feign-core-11.10.jar;c:/Users/Administrator/.m2/repository/io/gsonfire/gson-fire/1.8.3/gson-fire-1.8.3.jar;c:/Users/Administrator/.m2/repository/io/lettuce/lettuce-core/5.2.1.RELEASE/lettuce-core-5.2.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/io/lettuce/lettuce-core/5.3.0.RELEASE/lettuce-core-5.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/io/lettuce/lettuce-core/6.1.10.RELEASE/lettuce-core-6.1.10.RELEASE.jar;c:/Users/Administrator/.m2/repository/io/micrometer/micrometer-core/1.3.2/micrometer-core-1.3.2.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-buffer/4.1.101.Final/netty-buffer-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-buffer/4.1.43.Final/netty-buffer-4.1.43.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-buffer/4.1.45.Final/netty-buffer-4.1.45.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-buffer/4.1.49.Final/netty-buffer-4.1.49.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-codec/4.1.101.Final/netty-codec-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-codec/4.1.43.Final/netty-codec-4.1.43.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-codec/4.1.45.Final/netty-codec-4.1.45.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-codec/4.1.49.Final/netty-codec-4.1.49.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-codec-dns/4.1.101.Final/netty-codec-dns-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-codec-dns/4.1.45.Final/netty-codec-dns-4.1.45.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-common/4.1.101.Final/netty-common-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-common/4.1.43.Final/netty-common-4.1.43.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-common/4.1.45.Final/netty-common-4.1.45.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-common/4.1.49.Final/netty-common-4.1.49.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-handler/4.1.101.Final/netty-handler-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-handler/4.1.43.Final/netty-handler-4.1.43.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-handler/4.1.45.Final/netty-handler-4.1.45.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-handler/4.1.49.Final/netty-handler-4.1.49.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-resolver/4.1.101.Final/netty-resolver-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-resolver/4.1.43.Final/netty-resolver-4.1.43.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-resolver/4.1.45.Final/netty-resolver-4.1.45.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-resolver/4.1.49.Final/netty-resolver-4.1.49.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-resolver-dns/4.1.101.Final/netty-resolver-dns-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-resolver-dns/4.1.45.Final/netty-resolver-dns-4.1.45.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-transport/4.1.101.Final/netty-transport-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-transport/4.1.43.Final/netty-transport-4.1.43.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-transport/4.1.45.Final/netty-transport-4.1.45.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-transport/4.1.49.Final/netty-transport-4.1.49.Final.jar;c:/Users/Administrator/.m2/repository/io/netty/netty-transport-native-unix-common/4.1.101.Final/netty-transport-native-unix-common-4.1.101.Final.jar;c:/Users/Administrator/.m2/repository/io/projectreactor/reactor-core/3.3.1.RELEASE/reactor-core-3.3.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/io/projectreactor/reactor-core/3.3.2.RELEASE/reactor-core-3.3.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/io/projectreactor/reactor-core/3.3.5.RELEASE/reactor-core-3.3.5.RELEASE.jar;c:/Users/Administrator/.m2/repository/io/projectreactor/reactor-core/3.4.34/reactor-core-3.4.34.jar;c:/Users/Administrator/.m2/repository/io/prometheus/simpleclient/0.15.0/simpleclient-0.15.0.jar;c:/Users/Administrator/.m2/repository/io/prometheus/simpleclient_tracer_common/0.15.0/simpleclient_tracer_common-0.15.0.jar;c:/Users/Administrator/.m2/repository/io/prometheus/simpleclient_tracer_otel/0.15.0/simpleclient_tracer_otel-0.15.0.jar;c:/Users/Administrator/.m2/repository/io/prometheus/simpleclient_tracer_otel_agent/0.15.0/simpleclient_tracer_otel_agent-0.15.0.jar;c:/Users/Administrator/.m2/repository/io/reactivex/rxjava2/rxjava/2.2.17/rxjava-2.2.17.jar;c:/Users/Administrator/.m2/repository/io/reactivex/rxjava2/rxjava/2.2.21/rxjava-2.2.21.jar;c:/Users/Administrator/.m2/repository/io/springfox/springfox-core/2.9.2/springfox-core-2.9.2.jar;c:/Users/Administrator/.m2/repository/io/springfox/springfox-schema/2.9.2/springfox-schema-2.9.2.jar;c:/Users/Administrator/.m2/repository/io/springfox/springfox-spi/2.9.2/springfox-spi-2.9.2.jar;c:/Users/Administrator/.m2/repository/io/springfox/springfox-spring-web/2.9.2/springfox-spring-web-2.9.2.jar;c:/Users/Administrator/.m2/repository/io/springfox/springfox-swagger-common/2.9.2/springfox-swagger-common-2.9.2.jar;c:/Users/Administrator/.m2/repository/io/springfox/springfox-swagger-ui/2.9.2/springfox-swagger-ui-2.9.2.jar;c:/Users/Administrator/.m2/repository/io/springfox/springfox-swagger2/2.9.2/springfox-swagger2-2.9.2.jar;c:/Users/Administrator/.m2/repository/io/swagger/core/v3/swagger-annotations/2.0.0/swagger-annotations-2.0.0.jar;c:/Users/Administrator/.m2/repository/io/swagger/core/v3/swagger-annotations/2.2.9/swagger-annotations-2.2.9.jar;c:/Users/Administrator/.m2/repository/io/swagger/core/v3/swagger-core/2.2.9/swagger-core-2.2.9.jar;c:/Users/Administrator/.m2/repository/io/swagger/core/v3/swagger-models/2.2.9/swagger-models-2.2.9.jar;c:/Users/Administrator/.m2/repository/io/swagger/swagger-annotations/1.5.20/swagger-annotations-1.5.20.jar;c:/Users/Administrator/.m2/repository/io/swagger/swagger-models/1.5.20/swagger-models-1.5.20.jar;c:/Users/Administrator/.m2/repository/jakarta/activation/jakarta.activation-api/1.2.1/jakarta.activation-api-1.2.1.jar;c:/Users/Administrator/.m2/repository/jakarta/activation/jakarta.activation-api/1.2.2/jakarta.activation-api-1.2.2.jar;c:/Users/Administrator/.m2/repository/jakarta/annotation/jakarta.annotation-api/1.3.5/jakarta.annotation-api-1.3.5.jar;c:/Users/Administrator/.m2/repository/jakarta/persistence/jakarta.persistence-api/2.2.3/jakarta.persistence-api-2.2.3.jar;c:/Users/Administrator/.m2/repository/jakarta/transaction/jakarta.transaction-api/1.3.3/jakarta.transaction-api-1.3.3.jar;c:/Users/Administrator/.m2/repository/jakarta/validation/jakarta.validation-api/2.0.1/jakarta.validation-api-2.0.1.jar;c:/Users/Administrator/.m2/repository/jakarta/validation/jakarta.validation-api/2.0.2/jakarta.validation-api-2.0.2.jar;c:/Users/Administrator/.m2/repository/jakarta/xml/bind/jakarta.xml.bind-api/2.3.2/jakarta.xml.bind-api-2.3.2.jar;c:/Users/Administrator/.m2/repository/jakarta/xml/bind/jakarta.xml.bind-api/2.3.3/jakarta.xml.bind-api-2.3.3.jar;c:/Users/Administrator/.m2/repository/javax/activation/activation/1.1/activation-1.1.jar;c:/Users/Administrator/.m2/repository/javax/annotation/javax.annotation-api/1.2/javax.annotation-api-1.2.jar;c:/Users/Administrator/.m2/repository/javax/annotation/javax.annotation-api/1.3.2/javax.annotation-api-1.3.2.jar;c:/Users/Administrator/.m2/repository/javax/annotation/jsr250-api/1.0/jsr250-api-1.0.jar;c:/Users/Administrator/.m2/repository/javax/cache/cache-api/1.1.1/cache-api-1.1.1.jar;c:/Users/Administrator/.m2/repository/javax/el/el-api/2.2/el-api-2.2.jar;c:/Users/Administrator/.m2/repository/javax/el/javax.el-api/3.0.0/javax.el-api-3.0.0.jar;c:/Users/Administrator/.m2/repository/javax/enterprise/cdi-api/1.0/cdi-api-1.0.jar;c:/Users/Administrator/.m2/repository/javax/inject/javax.inject/1/javax.inject-1.jar;c:/Users/Administrator/.m2/repository/javax/mail/mail/1.4.5/mail-1.4.5.jar;c:/Users/Administrator/.m2/repository/javax/servlet/javax.servlet-api/3.1.0/javax.servlet-api-3.1.0.jar;c:/Users/Administrator/.m2/repository/javax/servlet/javax.servlet-api/4.0.1/javax.servlet-api-4.0.1.jar;c:/Users/Administrator/.m2/repository/javax/servlet/servlet-api/2.5/servlet-api-2.5.jar;c:/Users/Administrator/.m2/repository/javax/validation/validation-api/1.1.0.Final/validation-api-1.1.0.Final.jar;c:/Users/Administrator/.m2/repository/javax/validation/validation-api/2.0.1.Final/validation-api-2.0.1.Final.jar;c:/Users/Administrator/.m2/repository/javax/xml/bind/jaxb-api/2.2.2/jaxb-api-2.2.2.jar;c:/Users/Administrator/.m2/repository/javax/xml/stream/stax-api/1.0-2/stax-api-1.0-2.jar;c:/Users/Administrator/.m2/repository/jline/jline/2.12/jline-2.12.jar;c:/Users/Administrator/.m2/repository/jline/jline/2.9/jline-2.9.jar;c:/Users/Administrator/.m2/repository/joda-time/joda-time/2.9.9/joda-time-2.9.9.jar;c:/Users/Administrator/.m2/repository/junit/junit/3.8.1/junit-3.8.1.jar;c:/Users/Administrator/.m2/repository/junit/junit/3.8.2/junit-3.8.2.jar;c:/Users/Administrator/.m2/repository/junit/junit/4.12/junit-4.12.jar;c:/Users/Administrator/.m2/repository/junit/junit/4.13-beta-2/junit-4.13-beta-2.jar;c:/Users/Administrator/.m2/repository/log4j/log4j/1.2.12/log4j-1.2.12.jar;c:/Users/Administrator/.m2/repository/log4j/log4j/1.2.14/log4j-1.2.14.jar;c:/Users/Administrator/.m2/repository/log4j/log4j/1.2.17/log4j-1.2.17.jar;c:/Users/Administrator/.m2/repository/mysql/mysql-connector-java/5.1.46/mysql-connector-java-5.1.46.jar;c:/Users/Administrator/.m2/repository/mysql/mysql-connector-java/5.1.47/mysql-connector-java-5.1.47.jar;c:/Users/Administrator/.m2/repository/mysql/mysql-connector-java/8.0.18/mysql-connector-java-8.0.18.jar;c:/Users/Administrator/.m2/repository/mysql/mysql-connector-java/8.0.19/mysql-connector-java-8.0.19.jar;c:/Users/Administrator/.m2/repository/mysql/mysql-connector-java/8.0.20/mysql-connector-java-8.0.20.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy/1.10.1/byte-buddy-1.10.1.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy/1.10.10/byte-buddy-1.10.10.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy/1.10.4/byte-buddy-1.10.4.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy/1.10.6/byte-buddy-1.10.6.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy/1.12.23/byte-buddy-1.12.23.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy/1.6.14/byte-buddy-1.6.14.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy-agent/1.10.1/byte-buddy-agent-1.10.1.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy-agent/1.10.4/byte-buddy-agent-1.10.4.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy-agent/1.10.6/byte-buddy-agent-1.10.6.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy-agent/1.12.23/byte-buddy-agent-1.12.23.jar;c:/Users/Administrator/.m2/repository/net/bytebuddy/byte-buddy-agent/1.7.9/byte-buddy-agent-1.7.9.jar;c:/Users/Administrator/.m2/repository/net/coobird/thumbnailator/0.4.11/thumbnailator-0.4.11.jar;c:/Users/Administrator/.m2/repository/net/coobird/thumbnailator/0.4.20/thumbnailator-0.4.20.jar;c:/Users/Administrator/.m2/repository/net/java/dev/jna/jna/4.5.2/jna-4.5.2.jar;c:/Users/Administrator/.m2/repository/net/java/dev/jna/jna/5.7.0/jna-5.7.0.jar;c:/Users/Administrator/.m2/repository/net/java/dev/jna/jna-platform/5.7.0/jna-platform-5.7.0.jar;c:/Users/Administrator/.m2/repository/net/minidev/accessors-smart/1.2/accessors-smart-1.2.jar;c:/Users/Administrator/.m2/repository/net/minidev/accessors-smart/2.4.11/accessors-smart-2.4.11.jar;c:/Users/Administrator/.m2/repository/net/minidev/json-smart/2.3/json-smart-2.3.jar;c:/Users/Administrator/.m2/repository/net/minidev/json-smart/2.4.11/json-smart-2.4.11.jar;c:/Users/Administrator/.m2/repository/net/sf/ezmorph/ezmorph/1.0.6/ezmorph-1.0.6.jar;c:/Users/Administrator/.m2/repository/net/sf/json-lib/json-lib/2.4/json-lib-2.4-jdk15.jar;c:/Users/Administrator/.m2/repository/net/sf/jtidy/jtidy/r938/jtidy-r938.jar;c:/Users/Administrator/.m2/repository/net/sf/saxon/Saxon-HE/10.0/Saxon-HE-10.0.jar;c:/Users/Administrator/.m2/repository/net/shibboleth/tool/xmlsectool/2.0.0/xmlsectool-2.0.0.jar;c:/Users/Administrator/.m2/repository/net/sourceforge/cssparser/cssparser/0.9.29/cssparser-0.9.29.jar;c:/Users/Administrator/.m2/repository/net/sourceforge/jexcelapi/jxl/2.6.12/jxl-2.6.12.jar;c:/Users/Administrator/.m2/repository/net/sourceforge/jtds/jtds/1.3.1/jtds-1.3.1.jar;c:/Users/Administrator/.m2/repository/net/sourceforge/lept4j/lept4j/1.16.2/lept4j-1.16.2.jar;c:/Users/Administrator/.m2/repository/net/sourceforge/tess4j/tess4j/5.3.0/tess4j-5.3.0.jar;c:/Users/Administrator/.m2/repository/net/spy/spymemcached/2.12.2/spymemcached-2.12.2.jar;c:/Users/Administrator/.m2/repository/ognl/ognl/3.0.8/ognl-3.0.8.jar;c:/Users/Administrator/.m2/repository/ognl/ognl/3.2.6/ognl-3.2.6.jar;c:/Users/Administrator/.m2/repository/opensymphony/oscache/2.1.1/oscache-2.1.1.jar;c:/Users/Administrator/.m2/repository/org/antlr/antlr4-runtime/4.7.2/antlr4-runtime-4.7.2.jar;c:/Users/Administrator/.m2/repository/org/apache/ant/ant/1.8.1/ant-1.8.1.jar;c:/Users/Administrator/.m2/repository/org/apache/ant/ant-launcher/1.8.1/ant-launcher-1.8.1.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-collections4/4.1/commons-collections4-4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-collections4/4.3/commons-collections4-4.3.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-collections4/4.4/commons-collections4-4.4.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.16.1/commons-compress-1.16.1.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.18/commons-compress-1.18.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.19/commons-compress-1.19.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.20/commons-compress-1.20.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.21/commons-compress-1.21.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.25.0/commons-compress-1.25.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.26.1/commons-compress-1.26.1.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.28.0/commons-compress-1.28.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-compress/1.9/commons-compress-1.9.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-configuration2/2.12.0/commons-configuration2-2.12.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-configuration2/2.2/commons-configuration2-2.2.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-csv/1.8/commons-csv-1.8.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.1/commons-lang3-3.1.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.10/commons-lang3-3.10.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.18.0/commons-lang3-3.18.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.4/commons-lang3-3.4.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.7/commons-lang3-3.7.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-lang3/3.9/commons-lang3-3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-pool2/2.4.3/commons-pool2-2.4.3.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-pool2/2.5.0/commons-pool2-2.5.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-pool2/2.8.0/commons-pool2-2.8.0.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-text/1.13.1/commons-text-1.13.1.jar;c:/Users/Administrator/.m2/repository/org/apache/commons/commons-text/1.3/commons-text-1.3.jar;c:/Users/Administrator/.m2/repository/org/apache/geronimo/specs/geronimo-jta_1.1_spec/1.1.1/geronimo-jta_1.1_spec-1.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpasyncclient/4.1.5/httpasyncclient-4.1.5.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpclient/4.0.2/httpclient-4.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpclient/4.4.1/httpclient-4.4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpclient/4.5.14/httpclient-4.5.14.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpclient/4.5.6/httpclient-4.5.6.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpcore/4.0.1/httpcore-4.0.1.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpcore/4.4.1/httpcore-4.4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpcore/4.4.10/httpcore-4.4.10.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpcore/4.4.13/httpcore-4.4.13.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpcore/4.4.9/httpcore-4.4.9.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpcore-nio/4.4.1/httpcore-nio-4.4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpcore-nio/4.4.16/httpcore-nio-4.4.16.jar;c:/Users/Administrator/.m2/repository/org/apache/httpcomponents/httpmime/4.4.1/httpmime-4.4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/johnzon/johnzon-maven-plugin/1.2.2/johnzon-maven-plugin-1.2.2.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-api/2.12.1/log4j-api-2.12.1.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-api/2.13.2/log4j-api-2.13.2.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-api/2.17.1/log4j-api-2.17.1.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-api/2.17.2/log4j-api-2.17.2.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-api/2.7/log4j-api-2.7.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-core/2.12.1/log4j-core-2.12.1.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-core/2.7/log4j-core-2.7.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-jcl/2.12.1/log4j-jcl-2.12.1.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-jcl/2.7/log4j-jcl-2.7.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-slf4j-impl/2.12.1/log4j-slf4j-impl-2.12.1.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-slf4j-impl/2.7/log4j-slf4j-impl-2.7.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-to-slf4j/2.12.1/log4j-to-slf4j-2.12.1.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-to-slf4j/2.13.2/log4j-to-slf4j-2.13.2.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-to-slf4j/2.17.2/log4j-to-slf4j-2.17.2.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-web/2.12.1/log4j-web-2.12.1.jar;c:/Users/Administrator/.m2/repository/org/apache/logging/log4j/log4j-web/2.7/log4j-web-2.7.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-core/1.2/doxia-core-1.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-core/1.4/doxia-core-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-decoration-model/1.4/doxia-decoration-model-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-integration-tools/1.5/doxia-integration-tools-1.5.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-logging-api/1.1/doxia-logging-api-1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-logging-api/1.4/doxia-logging-api-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-module-apt/1.4/doxia-module-apt-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-module-fml/1.4/doxia-module-fml-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-module-markdown/1.4/doxia-module-markdown-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-module-xdoc/1.4/doxia-module-xdoc-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-module-xhtml/1.4/doxia-module-xhtml-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-sink-api/1.0/doxia-sink-api-1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-sink-api/1.0-alpha-7/doxia-sink-api-1.0-alpha-7.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-sink-api/1.1/doxia-sink-api-1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-sink-api/1.4/doxia-sink-api-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/doxia/doxia-site-renderer/1.4/doxia-site-renderer-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-aether-provider/3.0/maven-aether-provider-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-aether-provider/3.0.2/maven-aether-provider-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-aether-provider/3.1.0/maven-aether-provider-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-aether-provider/3.2.1/maven-aether-provider-3.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-aether-provider/3.3.9/maven-aether-provider-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-archiver/2.4.2/maven-archiver-2.4.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-archiver/2.5/maven-archiver-2.5.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-archiver/2.6/maven-archiver-2.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-archiver/3.4.0/maven-archiver-3.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-archiver/3.5.2/maven-archiver-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-archiver/3.6.2/maven-archiver-3.6.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-archiver/3.6.5/maven-archiver-3.6.5.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact/2.0.6/maven-artifact-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact/2.0.9/maven-artifact-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact/2.2.1/maven-artifact-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact/3.0/maven-artifact-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact/3.0.2/maven-artifact-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact/3.1.0/maven-artifact-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact/3.1.1/maven-artifact-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact/3.3.9/maven-artifact-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact-manager/2.0.6/maven-artifact-manager-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact-manager/2.0.8/maven-artifact-manager-2.0.8.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact-manager/2.0.9/maven-artifact-manager-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-artifact-manager/2.2.1/maven-artifact-manager-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-builder-support/3.3.9/maven-builder-support-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-compat/3.0/maven-compat-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-core/2.0.6/maven-core-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-core/2.0.9/maven-core-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-core/2.2.1/maven-core-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-core/3.0/maven-core-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-core/3.0.2/maven-core-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-core/3.1.0/maven-core-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-core/3.1.1/maven-core-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-core/3.3.9/maven-core-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-error-diagnostics/2.0.6/maven-error-diagnostics-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-error-diagnostics/2.0.9/maven-error-diagnostics-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-error-diagnostics/2.2.1/maven-error-diagnostics-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/2.0.6/maven-model-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/2.0.9/maven-model-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/2.2.1/maven-model-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/3.0/maven-model-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/3.0.2/maven-model-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/3.1.0/maven-model-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/3.1.1/maven-model-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/3.3.9/maven-model-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model/3.6.1/maven-model-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model-builder/3.0/maven-model-builder-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model-builder/3.0.2/maven-model-builder-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model-builder/3.1.0/maven-model-builder-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model-builder/3.1.1/maven-model-builder-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-model-builder/3.3.9/maven-model-builder-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-monitor/2.0.6/maven-monitor-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-monitor/2.0.9/maven-monitor-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-monitor/2.2.1/maven-monitor-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-api/2.0.6/maven-plugin-api-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-api/2.0.9/maven-plugin-api-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-api/2.2.1/maven-plugin-api-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-api/3.0/maven-plugin-api-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-api/3.0.2/maven-plugin-api-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-api/3.1.0/maven-plugin-api-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-api/3.1.1/maven-plugin-api-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-api/3.3.9/maven-plugin-api-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-descriptor/2.0.6/maven-plugin-descriptor-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-descriptor/2.0.9/maven-plugin-descriptor-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-descriptor/2.2.1/maven-plugin-descriptor-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-parameter-documenter/2.0.6/maven-plugin-parameter-documenter-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-parameter-documenter/2.0.9/maven-plugin-parameter-documenter-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-parameter-documenter/2.2.1/maven-plugin-parameter-documenter-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-registry/2.0.6/maven-plugin-registry-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-registry/2.0.8/maven-plugin-registry-2.0.8.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-registry/2.0.9/maven-plugin-registry-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-plugin-registry/2.2.1/maven-plugin-registry-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-profile/2.0.6/maven-profile-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-profile/2.0.8/maven-profile-2.0.8.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-profile/2.0.9/maven-profile-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-profile/2.2.1/maven-profile-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-project/2.0.6/maven-project-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-project/2.0.8/maven-project-2.0.8.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-project/2.0.9/maven-project-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-project/2.2.1/maven-project-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-repository-metadata/2.0.6/maven-repository-metadata-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-repository-metadata/2.0.9/maven-repository-metadata-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-repository-metadata/2.2.1/maven-repository-metadata-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-repository-metadata/3.0/maven-repository-metadata-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-repository-metadata/3.0.2/maven-repository-metadata-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-repository-metadata/3.1.0/maven-repository-metadata-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-repository-metadata/3.1.1/maven-repository-metadata-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-repository-metadata/3.3.9/maven-repository-metadata-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings/2.0.6/maven-settings-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings/2.0.9/maven-settings-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings/2.2.1/maven-settings-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings/3.0/maven-settings-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings/3.0.2/maven-settings-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings/3.1.0/maven-settings-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings/3.1.1/maven-settings-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings/3.3.9/maven-settings-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings-builder/3.0/maven-settings-builder-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings-builder/3.0.2/maven-settings-builder-3.0.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings-builder/3.1.0/maven-settings-builder-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings-builder/3.1.1/maven-settings-builder-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-settings-builder/3.3.9/maven-settings-builder-3.3.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-toolchain/2.0.9/maven-toolchain-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/maven-toolchain/2.2.1/maven-toolchain-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugin-testing/maven-plugin-testing-harness/2.1/maven-plugin-testing-harness-2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugin-tools/maven-plugin-annotations/3.1/maven-plugin-annotations-3.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugin-tools/maven-plugin-annotations/3.5.2/maven-plugin-annotations-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugin-tools/maven-plugin-tools-api/3.5.2/maven-plugin-tools-api-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugin-tools/maven-plugin-tools-generators/3.5.2/maven-plugin-tools-generators-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-antrun-plugin/1.8/maven-antrun-plugin-1.8.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-assembly-plugin/3.1.1/maven-assembly-plugin-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-clean-plugin/2.5/maven-clean-plugin-2.5.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-clean-plugin/3.1.0/maven-clean-plugin-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-clean-plugin/3.2.0/maven-clean-plugin-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-compiler-plugin/3.10.1/maven-compiler-plugin-3.10.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-compiler-plugin/3.13.0/maven-compiler-plugin-3.13.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-compiler-plugin/3.3/maven-compiler-plugin-3.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-compiler-plugin/3.8.1/maven-compiler-plugin-3.8.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-dependency-plugin/3.1.1/maven-dependency-plugin-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-dependency-plugin/3.3.0/maven-dependency-plugin-3.3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-deploy-plugin/2.7/maven-deploy-plugin-2.7.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-deploy-plugin/3.1.2/maven-deploy-plugin-3.1.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-deploy-plugin/3.1.3/maven-deploy-plugin-3.1.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-enforcer-plugin/3.0.0-M3/maven-enforcer-plugin-3.0.0-M3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-failsafe-plugin/2.22.2/maven-failsafe-plugin-2.22.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-help-plugin/3.2.0/maven-help-plugin-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-install-plugin/2.4/maven-install-plugin-2.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-install-plugin/2.5.2/maven-install-plugin-2.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-install-plugin/3.1.2/maven-install-plugin-3.1.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-install-plugin/3.1.4/maven-install-plugin-3.1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-invoker-plugin/3.2.1/maven-invoker-plugin-3.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-jar-plugin/2.4/maven-jar-plugin-2.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-jar-plugin/3.1.2/maven-jar-plugin-3.1.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-jar-plugin/3.2.2/maven-jar-plugin-3.2.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-jar-plugin/3.4.1/maven-jar-plugin-3.4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-jar-plugin/3.5.0/maven-jar-plugin-3.5.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-javadoc-plugin/3.1.1/maven-javadoc-plugin-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-release-plugin/3.0.1/maven-release-plugin-3.0.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-resources-plugin/2.6/maven-resources-plugin-2.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-resources-plugin/3.1.0/maven-resources-plugin-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-resources-plugin/3.2.0/maven-resources-plugin-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-resources-plugin/3.3.1/maven-resources-plugin-3.3.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-resources-plugin/3.4.0/maven-resources-plugin-3.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-shade-plugin/2.2/maven-shade-plugin-2.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-shade-plugin/3.2.1/maven-shade-plugin-3.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-shade-plugin/3.2.4/maven-shade-plugin-3.2.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-shade-plugin/3.4.1/maven-shade-plugin-3.4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-site-plugin/3.12.1/maven-site-plugin-3.12.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-site-plugin/3.3/maven-site-plugin-3.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-site-plugin/3.8.2/maven-site-plugin-3.8.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-source-plugin/3.1.0/maven-source-plugin-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-surefire-plugin/2.12.4/maven-surefire-plugin-2.12.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-surefire-plugin/2.22.2/maven-surefire-plugin-2.22.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-surefire-plugin/3.2.5/maven-surefire-plugin-3.2.5.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-surefire-plugin/3.5.2/maven-surefire-plugin-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-surefire-plugin/3.5.4/maven-surefire-plugin-3.5.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-war-plugin/2.6/maven-war-plugin-2.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/plugins/maven-war-plugin/3.2.3/maven-war-plugin-3.2.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/reporting/maven-reporting-api/2.0.6/maven-reporting-api-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/reporting/maven-reporting-api/2.0.9/maven-reporting-api-2.0.9.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/reporting/maven-reporting-api/2.2.1/maven-reporting-api-2.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/reporting/maven-reporting-api/3.0/maven-reporting-api-3.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/reporting/maven-reporting-exec/1.1/maven-reporting-exec-1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/reporting/maven-reporting-exec/1.4/maven-reporting-exec-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/reporting/maven-reporting-impl/2.3/maven-reporting-impl-2.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/resolver/maven-resolver-api/1.4.1/maven-resolver-api-1.4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/resolver/maven-resolver-api/1.9.18/maven-resolver-api-1.9.18.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/resolver/maven-resolver-api/1.9.22/maven-resolver-api-1.9.22.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/resolver/maven-resolver-util/1.4.1/maven-resolver-util-1.4.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/resolver/maven-resolver-util/1.9.18/maven-resolver-util-1.9.18.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/resolver/maven-resolver-util/1.9.22/maven-resolver-util-1.9.22.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/file-management/3.0.0/file-management-3.0.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/file-management/3.1.0/file-management-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/file-management/3.2.0/file-management-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-artifact-transfer/0.10.0/maven-artifact-transfer-0.10.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-artifact-transfer/0.12.0/maven-artifact-transfer-0.12.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-artifact-transfer/0.9.1/maven-artifact-transfer-0.9.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-common-artifact-filters/1.3/maven-common-artifact-filters-1.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-common-artifact-filters/1.4/maven-common-artifact-filters-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-common-artifact-filters/3.0.1/maven-common-artifact-filters-3.0.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-common-artifact-filters/3.1.0/maven-common-artifact-filters-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-common-artifact-filters/3.4.0/maven-common-artifact-filters-3.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-dependency-analyzer/1.10/maven-dependency-analyzer-1.10.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-dependency-tree/2.1/maven-dependency-tree-2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-dependency-tree/3.0.1/maven-dependency-tree-3.0.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-filtering/1.1/maven-filtering-1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-filtering/1.3/maven-filtering-1.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-filtering/3.1.1/maven-filtering-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-filtering/3.2.0/maven-filtering-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-filtering/3.3.1/maven-filtering-3.3.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-filtering/3.4.0/maven-filtering-3.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-mapping/1.0/maven-mapping-1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-incremental/1.1/maven-shared-incremental-1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-io/3.0.0/maven-shared-io-3.0.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/0.3/maven-shared-utils-0.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/0.4/maven-shared-utils-0.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/0.7/maven-shared-utils-0.7.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/3.0.0/maven-shared-utils-3.0.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/3.1.0/maven-shared-utils-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/3.2.0/maven-shared-utils-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/3.2.1/maven-shared-utils-3.2.1.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/3.3.3/maven-shared-utils-3.3.3.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/3.3.4/maven-shared-utils-3.3.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/shared/maven-shared-utils/3.4.2/maven-shared-utils-3.4.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/common-java5/3.5.2/common-java5-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/maven-surefire-common/2.12.4/maven-surefire-common-2.12.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/maven-surefire-common/2.22.2/maven-surefire-common-2.22.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/maven-surefire-common/3.5.2/maven-surefire-common-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/maven-surefire-common/3.5.4/maven-surefire-common-3.5.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-api/2.12.4/surefire-api-2.12.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-api/2.22.2/surefire-api-2.22.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-api/3.5.2/surefire-api-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-api/3.5.4/surefire-api-3.5.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-booter/2.12.4/surefire-booter-2.12.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-booter/2.22.2/surefire-booter-2.22.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-booter/3.5.2/surefire-booter-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-booter/3.5.4/surefire-booter-3.5.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-extensions-api/3.5.2/surefire-extensions-api-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-extensions-api/3.5.4/surefire-extensions-api-3.5.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-extensions-spi/3.5.2/surefire-extensions-spi-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-extensions-spi/3.5.4/surefire-extensions-spi-3.5.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-junit-platform/2.22.2/surefire-junit-platform-2.22.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-junit-platform/3.5.2/surefire-junit-platform-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-logger-api/2.22.2/surefire-logger-api-2.22.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-logger-api/3.5.2/surefire-logger-api-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-logger-api/3.5.4/surefire-logger-api-3.5.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-shared-utils/3.5.2/surefire-shared-utils-3.5.2.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/surefire/surefire-shared-utils/3.5.4/surefire-shared-utils-3.5.4.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/wagon/wagon-provider-api/1.0/wagon-provider-api-1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/wagon/wagon-provider-api/1.0-beta-6/wagon-provider-api-1.0-beta-6.jar;c:/Users/Administrator/.m2/repository/org/apache/maven/wagon/wagon-provider-api/2.10/wagon-provider-api-2.10.jar;c:/Users/Administrator/.m2/repository/org/apache/pdfbox/fontbox/2.0.26/fontbox-2.0.26.jar;c:/Users/Administrator/.m2/repository/org/apache/pdfbox/jbig2-imageio/3.0.4/jbig2-imageio-3.0.4.jar;c:/Users/Administrator/.m2/repository/org/apache/pdfbox/pdfbox/2.0.26/pdfbox-2.0.26.jar;c:/Users/Administrator/.m2/repository/org/apache/pdfbox/pdfbox-debugger/2.0.26/pdfbox-debugger-2.0.26.jar;c:/Users/Administrator/.m2/repository/org/apache/pdfbox/pdfbox-tools/2.0.26/pdfbox-tools-2.0.26.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/ooxml-schemas/1.4/ooxml-schemas-1.4.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi/3.14/poi-3.14.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi/3.15/poi-3.15.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi/4.1.0/poi-4.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi/4.1.2/poi-4.1.2.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi/5.2.0/poi-5.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi/5.2.3/poi-5.2.3.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi/5.2.5/poi-5.2.5.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml/3.14/poi-ooxml-3.14.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml/3.15/poi-ooxml-3.15.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml/4.1.0/poi-ooxml-4.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml/4.1.2/poi-ooxml-4.1.2.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml/5.2.0/poi-ooxml-5.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml/5.2.3/poi-ooxml-5.2.3.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml/5.2.5/poi-ooxml-5.2.5.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml-lite/5.2.0/poi-ooxml-lite-5.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml-lite/5.2.3/poi-ooxml-lite-5.2.3.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml-lite/5.2.5/poi-ooxml-lite-5.2.5.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml-schemas/3.14/poi-ooxml-schemas-3.14.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml-schemas/3.15/poi-ooxml-schemas-3.15.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml-schemas/4.1.0/poi-ooxml-schemas-4.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml-schemas/4.1.1/poi-ooxml-schemas-4.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/poi/poi-ooxml-schemas/4.1.2/poi-ooxml-schemas-4.1.2.jar;c:/Users/Administrator/.m2/repository/org/apache/santuario/xmlsec/2.0.5/xmlsec-2.0.5.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-cache/1.4.0/shiro-cache-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-config-core/1.4.0/shiro-config-core-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-config-ogdl/1.4.0/shiro-config-ogdl-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-core/1.4.0/shiro-core-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-crypto-cipher/1.4.0/shiro-crypto-cipher-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-crypto-core/1.4.0/shiro-crypto-core-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-crypto-hash/1.4.0/shiro-crypto-hash-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-event/1.4.0/shiro-event-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/shiro/shiro-lang/1.4.0/shiro-lang-1.4.0.jar;c:/Users/Administrator/.m2/repository/org/apache/struts/struts-core/1.3.8/struts-core-1.3.8.jar;c:/Users/Administrator/.m2/repository/org/apache/struts/struts-taglib/1.3.8/struts-taglib-1.3.8.jar;c:/Users/Administrator/.m2/repository/org/apache/struts/struts-tiles/1.3.8/struts-tiles-1.3.8.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/8.5.35/tomcat-embed-core-8.5.35.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/9.0.109/tomcat-embed-core-9.0.109.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/9.0.27/tomcat-embed-core-9.0.27.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/9.0.29/tomcat-embed-core-9.0.29.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/9.0.30/tomcat-embed-core-9.0.30.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/9.0.35/tomcat-embed-core-9.0.35.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/9.0.63/tomcat-embed-core-9.0.63.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/9.0.83/tomcat-embed-core-9.0.83.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/8.5.35/tomcat-embed-el-8.5.35.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/9.0.109/tomcat-embed-el-9.0.109.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/9.0.27/tomcat-embed-el-9.0.27.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/9.0.29/tomcat-embed-el-9.0.29.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/9.0.30/tomcat-embed-el-9.0.30.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/9.0.63/tomcat-embed-el-9.0.63.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/9.0.83/tomcat-embed-el-9.0.83.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/8.5.35/tomcat-embed-websocket-8.5.35.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/9.0.109/tomcat-embed-websocket-9.0.109.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/9.0.27/tomcat-embed-websocket-9.0.27.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/9.0.29/tomcat-embed-websocket-9.0.29.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/9.0.30/tomcat-embed-websocket-9.0.30.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/9.0.35/tomcat-embed-websocket-9.0.35.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/9.0.63/tomcat-embed-websocket-9.0.63.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/9.0.83/tomcat-embed-websocket-9.0.83.jar;c:/Users/Administrator/.m2/repository/org/apache/tomcat/tomcat-annotations-api/8.5.35/tomcat-annotations-api-8.5.35.jar;c:/Users/Administrator/.m2/repository/org/apache/velocity/velocity/1.5/velocity-1.5.jar;c:/Users/Administrator/.m2/repository/org/apache/velocity/velocity/1.7/velocity-1.7.jar;c:/Users/Administrator/.m2/repository/org/apache/velocity/velocity-tools/2.0/velocity-tools-2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/xbean/xbean-reflect/3.4/xbean-reflect-3.4.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlbeans/xmlbeans/2.6.0/xmlbeans-2.6.0.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlbeans/xmlbeans/3.0.1/xmlbeans-3.0.1.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlbeans/xmlbeans/3.1.0/xmlbeans-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlbeans/xmlbeans/5.0.3/xmlbeans-5.0.3.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlbeans/xmlbeans/5.1.1/xmlbeans-5.1.1.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlbeans/xmlbeans/5.2.0/xmlbeans-5.2.0.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-anim/1.14/batik-anim-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-awt-util/1.14/batik-awt-util-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-bridge/1.14/batik-bridge-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-codec/1.14/batik-codec-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-constants/1.14/batik-constants-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-css/1.14/batik-css-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-dom/1.14/batik-dom-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-ext/1.14/batik-ext-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-gvt/1.14/batik-gvt-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-i18n/1.14/batik-i18n-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-parser/1.14/batik-parser-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-script/1.14/batik-script-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-shared-resources/1.14/batik-shared-resources-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-svg-dom/1.14/batik-svg-dom-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-svggen/1.14/batik-svggen-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-transcoder/1.14/batik-transcoder-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-util/1.14/batik-util-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/batik-xml/1.14/batik-xml-1.14.jar;c:/Users/Administrator/.m2/repository/org/apache/xmlgraphics/xmlgraphics-commons/2.6/xmlgraphics-commons-2.6.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api/5.3.16/cas-server-core-api-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-audit/5.3.16/cas-server-core-api-audit-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-authentication/5.3.16/cas-server-core-api-authentication-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-configuration/5.3.16/cas-server-core-api-configuration-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-configuration-model/5.3.16/cas-server-core-api-configuration-model-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-events/5.3.16/cas-server-core-api-events-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-logout/5.3.16/cas-server-core-api-logout-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-protocol/5.3.16/cas-server-core-api-protocol-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-services/5.3.16/cas-server-core-api-services-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-throttle/5.3.16/cas-server-core-api-throttle-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-ticket/5.3.16/cas-server-core-api-ticket-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-util/5.3.16/cas-server-core-api-util-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-validation/5.3.16/cas-server-core-api-validation-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-web/5.3.16/cas-server-core-api-web-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-api-webflow/5.3.16/cas-server-core-api-webflow-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-authentication/5.3.16/cas-server-core-authentication-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-authentication-api/5.3.16/cas-server-core-authentication-api-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-authentication-attributes/5.3.16/cas-server-core-authentication-attributes-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-authentication-mfa/5.3.16/cas-server-core-authentication-mfa-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-configuration/5.3.16/cas-server-core-configuration-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-configuration-api/5.3.16/cas-server-core-configuration-api-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-cookie-api/5.3.16/cas-server-core-cookie-api-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-rest/5.3.16/cas-server-core-rest-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-services/5.3.16/cas-server-core-services-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-services-api/5.3.16/cas-server-core-services-api-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-services-authentication/5.3.16/cas-server-core-services-authentication-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-services-registry/5.3.16/cas-server-core-services-registry-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-tickets/5.3.16/cas-server-core-tickets-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-tickets-api/5.3.16/cas-server-core-tickets-api-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-util/5.3.16/cas-server-core-util-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-util-api/5.3.16/cas-server-core-util-api-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-core-web-api/5.3.16/cas-server-core-web-api-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-security-filter/2.0.10.4/cas-server-security-filter-2.0.10.4.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-support-jdbc/5.3.16/cas-server-support-jdbc-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-support-jdbc-authentication/5.3.16/cas-server-support-jdbc-authentication-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-support-jdbc-drivers/5.3.16/cas-server-support-jdbc-drivers-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-support-rest/5.3.16/cas-server-support-rest-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/cas/cas-server-support-throttle-core/5.3.16/cas-server-support-throttle-core-5.3.16.jar;c:/Users/Administrator/.m2/repository/org/apereo/inspektr/inspektr-audit/1.8.4.GA/inspektr-audit-1.8.4.GA.jar;c:/Users/Administrator/.m2/repository/org/apereo/inspektr/inspektr-common/1.8.4.GA/inspektr-common-1.8.4.GA.jar;c:/Users/Administrator/.m2/repository/org/apereo/inspektr/inspektr-error/1.8.4.GA/inspektr-error-1.8.4.GA.jar;c:/Users/Administrator/.m2/repository/org/apereo/inspektr/inspektr-support-spring/1.8.4.GA/inspektr-support-spring-1.8.4.GA.jar;c:/Users/Administrator/.m2/repository/org/apereo/service/persondir/person-directory-api/1.8.9/person-directory-api-1.8.9.jar;c:/Users/Administrator/.m2/repository/org/apereo/service/persondir/person-directory-impl/1.8.9/person-directory-impl-1.8.9.jar;c:/Users/Administrator/.m2/repository/org/apereo/spring-webflow-client-repo/1.0.3/spring-webflow-client-repo-1.0.3.jar;c:/Users/Administrator/.m2/repository/org/apiguardian/apiguardian-api/1.0.0/apiguardian-api-1.0.0.jar;c:/Users/Administrator/.m2/repository/org/apiguardian/apiguardian-api/1.1.0/apiguardian-api-1.1.0.jar;c:/Users/Administrator/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar;c:/Users/Administrator/.m2/repository/org/aspectj/aspectjrt/1.8.13/aspectjrt-1.8.13.jar;c:/Users/Administrator/.m2/repository/org/aspectj/aspectjrt/1.9.1/aspectjrt-1.9.1.jar;c:/Users/Administrator/.m2/repository/org/aspectj/aspectjweaver/1.8.13/aspectjweaver-1.8.13.jar;c:/Users/Administrator/.m2/repository/org/aspectj/aspectjweaver/1.9.1/aspectjweaver-1.9.1.jar;c:/Users/Administrator/.m2/repository/org/aspectj/aspectjweaver/1.9.4/aspectjweaver-1.9.4.jar;c:/Users/Administrator/.m2/repository/org/aspectj/aspectjweaver/1.9.5/aspectjweaver-1.9.5.jar;c:/Users/Administrator/.m2/repository/org/aspectj/aspectjweaver/1.9.7/aspectjweaver-1.9.7.jar;c:/Users/Administrator/.m2/repository/org/assertj/assertj-core/3.13.2/assertj-core-3.13.2.jar;c:/Users/Administrator/.m2/repository/org/assertj/assertj-core/3.22.0/assertj-core-3.22.0.jar;c:/Users/Administrator/.m2/repository/org/bitbucket/b_c/jose4j/0.6.3/jose4j-0.6.3.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcmail-jdk14/1.38/bcmail-jdk14-1.38.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcpkix-jdk15on/1.60/bcpkix-jdk15on-1.60.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcpkix-jdk15on/1.64/bcpkix-jdk15on-1.64.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcpkix-jdk18on/1.73/bcpkix-jdk18on-1.73.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcprov-jdk14/1.38/bcprov-jdk14-1.38.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcprov-jdk15on/1.60/bcprov-jdk15on-1.60.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcprov-jdk15on/1.64/bcprov-jdk15on-1.64.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcprov-jdk15on/1.68/bcprov-jdk15on-1.68.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcprov-jdk15on/1.70/bcprov-jdk15on-1.70.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcprov-jdk18on/1.73/bcprov-jdk18on-1.73.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bctsp-jdk14/1.38/bctsp-jdk14-1.38.jar;c:/Users/Administrator/.m2/repository/org/bouncycastle/bcutil-jdk18on/1.73/bcutil-jdk18on-1.73.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-android-arm.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-android-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-android-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-android-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus/2.3.1-1.5.9/artoolkitplus-2.3.1-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/artoolkitplus-platform/2.3.1-1.5.9/artoolkitplus-platform-2.3.1-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-android-arm.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-android-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-android-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-android-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-macosx-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg/6.0-1.5.9/ffmpeg-6.0-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/ffmpeg-platform/6.0-1.5.9/ffmpeg-platform-6.0-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/flycapture/2.13.3.31-1.5.9/flycapture-2.13.3.31-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/flycapture/2.13.3.31-1.5.9/flycapture-2.13.3.31-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/flycapture/2.13.3.31-1.5.9/flycapture-2.13.3.31-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/flycapture/2.13.3.31-1.5.9/flycapture-2.13.3.31-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/flycapture/2.13.3.31-1.5.9/flycapture-2.13.3.31-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/flycapture/2.13.3.31-1.5.9/flycapture-2.13.3.31-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/flycapture/2.13.3.31-1.5.9/flycapture-2.13.3.31-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/flycapture-platform/2.13.3.31-1.5.9/flycapture-platform-2.13.3.31-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-android-arm.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-android-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-android-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-android-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-ios-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-ios-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-macosx-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp/1.5.9/javacpp-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacpp-platform/1.5.9/javacpp-platform-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacv/1.5.9/javacv-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/javacv-platform/1.5.9/javacv-platform-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-android-arm.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-android-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-android-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-android-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-macosx-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica/1.83.0-1.5.9/leptonica-1.83.0-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/leptonica-platform/1.83.0-1.5.9/leptonica-platform-1.83.0-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394/2.2.6-1.5.9/libdc1394-2.2.6-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libdc1394-platform/2.2.6-1.5.9/libdc1394-platform-2.2.6-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect/0.5.7-1.5.9/libfreenect-0.5.7-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect-platform/0.5.7-1.5.9/libfreenect-platform-0.5.7-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect2/0.2.0-1.5.9/libfreenect2-0.2.0-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect2/0.2.0-1.5.9/libfreenect2-0.2.0-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect2/0.2.0-1.5.9/libfreenect2-0.2.0-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect2/0.2.0-1.5.9/libfreenect2-0.2.0-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect2/0.2.0-1.5.9/libfreenect2-0.2.0-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/libfreenect2-platform/0.2.0-1.5.9/libfreenect2-platform-0.2.0-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense/1.12.4-1.5.9/librealsense-1.12.4-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense/1.12.4-1.5.9/librealsense-1.12.4-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense/1.12.4-1.5.9/librealsense-1.12.4-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense/1.12.4-1.5.9/librealsense-1.12.4-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense/1.12.4-1.5.9/librealsense-1.12.4-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense/1.12.4-1.5.9/librealsense-1.12.4-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense/1.12.4-1.5.9/librealsense-1.12.4-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense/1.12.4-1.5.9/librealsense-1.12.4-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense-platform/1.12.4-1.5.9/librealsense-platform-1.12.4-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2/2.53.1-1.5.9/librealsense2-2.53.1-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2/2.53.1-1.5.9/librealsense2-2.53.1-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2/2.53.1-1.5.9/librealsense2-2.53.1-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2/2.53.1-1.5.9/librealsense2-2.53.1-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2/2.53.1-1.5.9/librealsense2-2.53.1-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2/2.53.1-1.5.9/librealsense2-2.53.1-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2/2.53.1-1.5.9/librealsense2-2.53.1-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2/2.53.1-1.5.9/librealsense2-2.53.1-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/librealsense2-platform/2.53.1-1.5.9/librealsense2-platform-2.53.1-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-android-arm.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-android-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-android-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-android-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-ios-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-ios-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-macosx-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas/0.3.23-1.5.9/openblas-0.3.23-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/openblas-platform/0.3.23-1.5.9/openblas-platform-0.3.23-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-android-arm.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-android-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-android-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-android-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-ios-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-ios-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-macosx-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv/4.7.0-1.5.9/opencv-4.7.0-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/opencv-platform/4.7.0-1.5.9/opencv-platform-4.7.0-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-android-arm.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-android-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-android-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-android-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-linux-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-linux-armhf.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-linux-ppc64le.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-linux-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-linux-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-macosx-arm64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-macosx-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract/5.3.1-1.5.9/tesseract-5.3.1-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/tesseract-platform/5.3.1-1.5.9/tesseract-platform-5.3.1-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/videoinput/0.200-1.5.9/videoinput-0.200-1.5.9-windows-x86.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/videoinput/0.200-1.5.9/videoinput-0.200-1.5.9-windows-x86_64.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/videoinput/0.200-1.5.9/videoinput-0.200-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/bytedeco/videoinput-platform/0.200-1.5.9/videoinput-platform-0.200-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/checkerframework/checker-compat-qual/2.0.0/checker-compat-qual-2.0.0.jar;c:/Users/Administrator/.m2/repository/org/checkerframework/checker-compat-qual/2.5.5/checker-compat-qual-2.5.5.jar;c:/Users/Administrator/.m2/repository/org/checkerframework/checker-qual/2.0.0/checker-qual-2.0.0.jar;c:/Users/Administrator/.m2/repository/org/checkerframework/checker-qual/3.19.0/checker-qual-3.19.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy/2.4.15/groovy-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy-console/2.4.15/groovy-console-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy-groovysh/2.4.15/groovy-groovysh-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy-json/2.4.15/groovy-json-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy-jsr223/2.4.15/groovy-jsr223-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy-sql/2.4.15/groovy-sql-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy-swing/2.4.15/groovy-swing-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy-templates/2.4.15/groovy-templates-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/groovy/groovy-xml/2.4.15/groovy-xml-2.4.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/jackson/jackson-core-asl/1.9.2/jackson-core-asl-1.9.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/jackson/jackson-jaxrs/1.9.2/jackson-jaxrs-1.9.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/jackson/jackson-mapper-asl/1.9.2/jackson-mapper-asl-1.9.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/jackson/jackson-xc/1.9.2/jackson-xc-1.9.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/jettison/jettison/1.1/jettison-1.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/mojo/animal-sniffer-annotations/1.14/animal-sniffer-annotations-1.14.jar;c:/Users/Administrator/.m2/repository/org/codehaus/mojo/build-helper-maven-plugin/3.0.0/build-helper-maven-plugin-3.0.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/mojo/exec-maven-plugin/1.6.0/exec-maven-plugin-1.6.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/mojo/flatten-maven-plugin/1.1.0/flatten-maven-plugin-1.1.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/mojo/versions-maven-plugin/2.7/versions-maven-plugin-2.7.jar;c:/Users/Administrator/.m2/repository/org/codehaus/mojo/xml-maven-plugin/1.0.2/xml-maven-plugin-1.0.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/1.0/plexus-archiver-1.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/2.1/plexus-archiver-2.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/2.2/plexus-archiver-2.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/2.8.1/plexus-archiver-2.8.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/2.9/plexus-archiver-2.9.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/3.6.0/plexus-archiver-3.6.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/3.7.0/plexus-archiver-3.7.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/4.1.0/plexus-archiver-4.1.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/4.10.4/plexus-archiver-4.10.4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/4.2.7/plexus-archiver-4.2.7.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-archiver/4.9.2/plexus-archiver-4.9.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-classworlds/2.2.2/plexus-classworlds-2.2.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-classworlds/2.2.3/plexus-classworlds-2.2.3.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-classworlds/2.4/plexus-classworlds-2.4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-classworlds/2.4.2/plexus-classworlds-2.4.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-classworlds/2.5.1/plexus-classworlds-2.5.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-classworlds/2.5.2/plexus-classworlds-2.5.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-api/2.11.1/plexus-compiler-api-2.11.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-api/2.15.0/plexus-compiler-api-2.15.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-api/2.5/plexus-compiler-api-2.5.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-api/2.8.4/plexus-compiler-api-2.8.4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-javac/2.11.1/plexus-compiler-javac-2.11.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-javac/2.15.0/plexus-compiler-javac-2.15.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-javac/2.5/plexus-compiler-javac-2.5.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-javac/2.8.4/plexus-compiler-javac-2.8.4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-manager/2.11.1/plexus-compiler-manager-2.11.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-manager/2.15.0/plexus-compiler-manager-2.15.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-manager/2.5/plexus-compiler-manager-2.5.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-compiler-manager/2.8.4/plexus-compiler-manager-2.8.4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-component-annotations/1.5.5/plexus-component-annotations-1.5.5.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-component-annotations/1.6/plexus-component-annotations-1.6.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-component-annotations/1.7.1/plexus-component-annotations-1.7.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-component-annotations/2.0.0/plexus-component-annotations-2.0.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-container-default/1.0-alpha-30/plexus-container-default-1.0-alpha-30.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-container-default/1.0-alpha-9-stable-1/plexus-container-default-1.0-alpha-9-stable-1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-container-default/1.5.5/plexus-container-default-1.5.5.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-digest/1.0/plexus-digest-1.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-i18n/1.0-beta-7/plexus-i18n-1.0-beta-7.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interactivity-api/1.0-alpha-4/plexus-interactivity-api-1.0-alpha-4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interactivity-api/1.0-alpha-6/plexus-interactivity-api-1.0-alpha-6.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.11/plexus-interpolation-1.11.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.13/plexus-interpolation-1.13.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.14/plexus-interpolation-1.14.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.15/plexus-interpolation-1.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.16/plexus-interpolation-1.16.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.21/plexus-interpolation-1.21.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.24/plexus-interpolation-1.24.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.25/plexus-interpolation-1.25.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.26/plexus-interpolation-1.26.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.27/plexus-interpolation-1.27.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-interpolation/1.28/plexus-interpolation-1.28.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/1.0/plexus-io-1.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/2.0.2/plexus-io-2.0.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/2.0.4/plexus-io-2.0.4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/2.3.2/plexus-io-2.3.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/2.4.1/plexus-io-2.4.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/3.0.0/plexus-io-3.0.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/3.1.0/plexus-io-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/3.1.1/plexus-io-3.1.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/3.2.0/plexus-io-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/3.4.2/plexus-io-3.4.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-io/3.6.0/plexus-io-3.6.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-java/0.9.10/plexus-java-0.9.10.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-java/1.1.1/plexus-java-1.1.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-java/1.2.0/plexus-java-1.2.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-java/1.3.0/plexus-java-1.3.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-java/1.5.0/plexus-java-1.5.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/1.1/plexus-utils-1.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/1.5.10/plexus-utils-1.5.10.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/1.5.15/plexus-utils-1.5.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/1.5.6/plexus-utils-1.5.6.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/1.5.8/plexus-utils-1.5.8.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/2.0.4/plexus-utils-2.0.4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/2.0.5/plexus-utils-2.0.5.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.0/plexus-utils-3.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.0.1/plexus-utils-3.0.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.0.10/plexus-utils-3.0.10.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.0.15/plexus-utils-3.0.15.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.0.20/plexus-utils-3.0.20.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.0.24/plexus-utils-3.0.24.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.0.5/plexus-utils-3.0.5.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.0.8/plexus-utils-3.0.8.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.1.0/plexus-utils-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.2.0/plexus-utils-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.3.1/plexus-utils-3.3.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.4.1/plexus-utils-3.4.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/3.5.1/plexus-utils-3.5.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/4.0.0/plexus-utils-4.0.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/4.0.1/plexus-utils-4.0.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-utils/4.0.2/plexus-utils-4.0.2.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-velocity/1.1.7/plexus-velocity-1.1.7.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-velocity/1.1.8/plexus-velocity-1.1.8.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-xml/3.0.0/plexus-xml-3.0.0.jar;c:/Users/Administrator/.m2/repository/org/codehaus/plexus/plexus-xml/3.0.1/plexus-xml-3.0.1.jar;c:/Users/Administrator/.m2/repository/org/codehaus/woodstox/stax2-api/3.1.4/stax2-api-3.1.4.jar;c:/Users/Administrator/.m2/repository/org/codehaus/woodstox/woodstox-core-asl/4.4.1/woodstox-core-asl-4.4.1.jar;c:/Users/Administrator/.m2/repository/org/commonmark/commonmark/0.21.0/commonmark-0.21.0.jar;c:/Users/Administrator/.m2/repository/org/cryptacular/cryptacular/1.2.4/cryptacular-1.2.4.jar;c:/Users/Administrator/.m2/repository/org/dom4j/dom4j/2.1.1/dom4j-2.1.1.jar;c:/Users/Administrator/.m2/repository/org/dom4j/dom4j/2.1.3/dom4j-2.1.3.jar;c:/Users/Administrator/.m2/repository/org/eclipse/aether/aether-api/0.9.0.M2/aether-api-0.9.0.M2.jar;c:/Users/Administrator/.m2/repository/org/eclipse/aether/aether-api/1.0.2.v20150114/aether-api-1.0.2.v20150114.jar;c:/Users/Administrator/.m2/repository/org/eclipse/aether/aether-impl/0.9.0.M2/aether-impl-0.9.0.M2.jar;c:/Users/Administrator/.m2/repository/org/eclipse/aether/aether-impl/1.0.2.v20150114/aether-impl-1.0.2.v20150114.jar;c:/Users/Administrator/.m2/repository/org/eclipse/aether/aether-spi/0.9.0.M2/aether-spi-0.9.0.M2.jar;c:/Users/Administrator/.m2/repository/org/eclipse/aether/aether-spi/1.0.2.v20150114/aether-spi-1.0.2.v20150114.jar;c:/Users/Administrator/.m2/repository/org/eclipse/aether/aether-util/0.9.0.M2/aether-util-0.9.0.M2.jar;c:/Users/Administrator/.m2/repository/org/eclipse/aether/aether-util/1.0.2.v20150114/aether-util-1.0.2.v20150114.jar;c:/Users/Administrator/.m2/repository/org/eclipse/sisu/org.eclipse.sisu.inject/0.0.0.M2a/org.eclipse.sisu.inject-0.0.0.M2a.jar;c:/Users/Administrator/.m2/repository/org/eclipse/sisu/org.eclipse.sisu.inject/0.0.0.M5/org.eclipse.sisu.inject-0.0.0.M5.jar;c:/Users/Administrator/.m2/repository/org/eclipse/sisu/org.eclipse.sisu.inject/0.3.2/org.eclipse.sisu.inject-0.3.2.jar;c:/Users/Administrator/.m2/repository/org/eclipse/sisu/org.eclipse.sisu.plexus/0.0.0.M2a/org.eclipse.sisu.plexus-0.0.0.M2a.jar;c:/Users/Administrator/.m2/repository/org/eclipse/sisu/org.eclipse.sisu.plexus/0.0.0.M5/org.eclipse.sisu.plexus-0.0.0.M5.jar;c:/Users/Administrator/.m2/repository/org/eclipse/sisu/org.eclipse.sisu.plexus/0.3.2/org.eclipse.sisu.plexus-0.3.2.jar;c:/Users/Administrator/.m2/repository/org/ehcache/ehcache/3.8.1/ehcache-3.8.1.jar;c:/Users/Administrator/.m2/repository/org/flywaydb/flyway-maven-plugin/6.0.8/flyway-maven-plugin-6.0.8.jar;c:/Users/Administrator/.m2/repository/org/freemarker/freemarker/2.3.28/freemarker-2.3.28.jar;c:/Users/Administrator/.m2/repository/org/freemarker/freemarker/2.3.29/freemarker-2.3.29.jar;c:/Users/Administrator/.m2/repository/org/freemarker/freemarker/2.3.30/freemarker-2.3.30.jar;c:/Users/Administrator/.m2/repository/org/freemarker/freemarker/2.3.32/freemarker-2.3.32.jar;c:/Users/Administrator/.m2/repository/org/glassfish/jakarta.el/3.0.3/jakarta.el-3.0.3.jar;c:/Users/Administrator/.m2/repository/org/glassfish/jaxb/jaxb-runtime/2.3.2/jaxb-runtime-2.3.2.jar;c:/Users/Administrator/.m2/repository/org/glassfish/jaxb/jaxb-runtime/2.3.3/jaxb-runtime-2.3.3.jar;c:/Users/Administrator/.m2/repository/org/glassfish/jaxb/jaxb-runtime/2.3.9/jaxb-runtime-2.3.9.jar;c:/Users/Administrator/.m2/repository/org/glassfish/jaxb/txw2/2.3.2/txw2-2.3.2.jar;c:/Users/Administrator/.m2/repository/org/glassfish/jaxb/txw2/2.3.3/txw2-2.3.3.jar;c:/Users/Administrator/.m2/repository/org/glassfish/jaxb/txw2/2.3.9/txw2-2.3.9.jar;c:/Users/Administrator/.m2/repository/org/glassfish/web/el-impl/2.2/el-impl-2.2.jar;c:/Users/Administrator/.m2/repository/org/hamcrest/hamcrest/2.1/hamcrest-2.1.jar;c:/Users/Administrator/.m2/repository/org/hamcrest/hamcrest/2.2/hamcrest-2.2.jar;c:/Users/Administrator/.m2/repository/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar;c:/Users/Administrator/.m2/repository/org/hamcrest/hamcrest-core/2.1/hamcrest-core-2.1.jar;c:/Users/Administrator/.m2/repository/org/hdrhistogram/HdrHistogram/2.1.11/HdrHistogram-2.1.11.jar;c:/Users/Administrator/.m2/repository/org/hibernate/common/hibernate-commons-annotations/5.0.1.Final/hibernate-commons-annotations-5.0.1.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/common/hibernate-commons-annotations/5.1.0.Final/hibernate-commons-annotations-5.1.0.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/common/hibernate-commons-annotations/5.1.2.Final/hibernate-commons-annotations-5.1.2.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-core/5.0.12.Final/hibernate-core-5.0.12.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-core/5.2.18.Final/hibernate-core-5.2.18.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-core/5.4.10.Final/hibernate-core-5.4.10.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-core/5.4.15.Final/hibernate-core-5.4.15.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-core/5.4.6.Final/hibernate-core-5.4.6.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-core/5.4.9.Final/hibernate-core-5.4.9.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-core/5.6.15.Final/hibernate-core-5.6.15.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-entitymanager/5.0.12.Final/hibernate-entitymanager-5.0.12.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-entitymanager/5.2.18.Final/hibernate-entitymanager-5.2.18.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-hikaricp/5.2.18.Final/hibernate-hikaricp-5.2.18.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-validator/5.3.6.Final/hibernate-validator-5.3.6.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/hibernate-validator/5.4.1.Final/hibernate-validator-5.4.1.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/javax/persistence/hibernate-jpa-2.1-api/1.0.0.Final/hibernate-jpa-2.1-api-1.0.0.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/validator/hibernate-validator/6.0.17.Final/hibernate-validator-6.0.17.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/validator/hibernate-validator/6.0.18.Final/hibernate-validator-6.0.18.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/validator/hibernate-validator/6.2.0.Final/hibernate-validator-6.2.0.Final.jar;c:/Users/Administrator/.m2/repository/org/hibernate/validator/hibernate-validator/6.2.5.Final/hibernate-validator-6.2.5.Final.jar;c:/Users/Administrator/.m2/repository/org/hjson/hjson/3.0.0/hjson-3.0.0.jar;c:/Users/Administrator/.m2/repository/org/hsqldb/hsqldb/2.3.6/hsqldb-2.3.6.jar;c:/Users/Administrator/.m2/repository/org/hsqldb/hsqldb/2.4.0/hsqldb-2.4.0.jar;c:/Users/Administrator/.m2/repository/org/infinispan/infinispan-protocol-parser-generator-maven-plugin/9.4.17.Final/infinispan-protocol-parser-generator-maven-plugin-9.4.17.Final.jar;c:/Users/Administrator/.m2/repository/org/iq80/snappy/snappy/0.4/snappy-0.4.jar;c:/Users/Administrator/.m2/repository/org/jasig/cas/client/cas-client-core/3.4.1/cas-client-core-3.4.1.jar;c:/Users/Administrator/.m2/repository/org/jasig/cas/client/cas-client-core/3.5.0/cas-client-core-3.5.0.jar;c:/Users/Administrator/.m2/repository/org/jasig/cas/client/cas-client-core/3.5.1/cas-client-core-3.5.1.jar;c:/Users/Administrator/.m2/repository/org/jasig/cas/client/cas-client-support-saml/3.5.0/cas-client-support-saml-3.5.0.jar;c:/Users/Administrator/.m2/repository/org/jasypt/jasypt/1.9.2/jasypt-1.9.2.jar;c:/Users/Administrator/.m2/repository/org/java-websocket/Java-WebSocket/1.3.8/Java-WebSocket-1.3.8.jar;c:/Users/Administrator/.m2/repository/org/javassist/javassist/3.20.0-GA/javassist-3.20.0-GA.jar;c:/Users/Administrator/.m2/repository/org/javassist/javassist/3.21.0-GA/javassist-3.21.0-GA.jar;c:/Users/Administrator/.m2/repository/org/javassist/javassist/3.22.0-GA/javassist-3.22.0-GA.jar;c:/Users/Administrator/.m2/repository/org/javassist/javassist/3.24.0-GA/javassist-3.24.0-GA.jar;c:/Users/Administrator/.m2/repository/org/jboss/jandex/2.0.0.Final/jandex-2.0.0.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/jandex/2.0.3.Final/jandex-2.0.3.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/jandex/2.0.5.Final/jandex-2.0.5.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/jandex/2.1.1.Final/jandex-2.1.1.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/jandex/2.1.3.Final/jandex-2.1.3.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/jandex/2.4.2.Final/jandex-2.4.2.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/jboss-vfs/3.2.17.Final/jboss-vfs-3.2.17.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/logging/jboss-logging/3.3.1.Final/jboss-logging-3.3.1.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/logging/jboss-logging/3.3.2.Final/jboss-logging-3.3.2.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/logging/jboss-logging/3.4.1.Final/jboss-logging-3.4.1.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/logging/jboss-logging/3.4.3.Final/jboss-logging-3.4.3.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/marshalling/jboss-marshalling/2.0.10.Final/jboss-marshalling-2.0.10.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/marshalling/jboss-marshalling-river/2.0.10.Final/jboss-marshalling-river-2.0.10.Final.jar;c:/Users/Administrator/.m2/repository/org/jboss/spec/javax/transaction/jboss-transaction-api_1.2_spec/1.0.1.Final/jboss-transaction-api_1.2_spec-1.0.1.Final.jar;c:/Users/Administrator/.m2/repository/org/jdom/jdom/1.1/jdom-1.1.jar;c:/Users/Administrator/.m2/repository/org/jdom/jdom2/2.0.6/jdom2-2.0.6.jar;c:/Users/Administrator/.m2/repository/org/jetbrains/annotations/13.0/annotations-13.0.jar;c:/Users/Administrator/.m2/repository/org/jetbrains/kotlin/kotlin-maven-plugin/1.3.61/kotlin-maven-plugin-1.3.61.jar;c:/Users/Administrator/.m2/repository/org/jetbrains/kotlin/kotlin-stdlib/1.3.61/kotlin-stdlib-1.3.61.jar;c:/Users/Administrator/.m2/repository/org/jetbrains/kotlin/kotlin-stdlib/1.6.21/kotlin-stdlib-1.6.21.jar;c:/Users/Administrator/.m2/repository/org/jetbrains/kotlin/kotlin-stdlib-common/1.3.61/kotlin-stdlib-common-1.3.61.jar;c:/Users/Administrator/.m2/repository/org/jetbrains/kotlin/kotlin-stdlib-common/1.6.21/kotlin-stdlib-common-1.6.21.jar;c:/Users/Administrator/.m2/repository/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.3.61/kotlin-stdlib-jdk7-1.3.61.jar;c:/Users/Administrator/.m2/repository/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.3.61/kotlin-stdlib-jdk8-1.3.61.jar;c:/Users/Administrator/.m2/repository/org/jodd/jodd-bean/5.1.6/jodd-bean-5.1.6.jar;c:/Users/Administrator/.m2/repository/org/jodd/jodd-core/5.1.6/jodd-core-5.1.6.jar;c:/Users/Administrator/.m2/repository/org/jooq/jool/0.9.12/jool-0.9.12.jar;c:/Users/Administrator/.m2/repository/org/jooq/jooq-codegen-maven/3.12.3/jooq-codegen-maven-3.12.3.jar;c:/Users/Administrator/.m2/repository/org/json/json/20140107/json-20140107.jar;c:/Users/Administrator/.m2/repository/org/json/json/20160810/json-20160810.jar;c:/Users/Administrator/.m2/repository/org/json/json/20210307/json-20210307.jar;c:/Users/Administrator/.m2/repository/org/jsoup/jsoup/1.10.1/jsoup-1.10.1.jar;c:/Users/Administrator/.m2/repository/org/jsoup/jsoup/1.11.3/jsoup-1.11.3.jar;c:/Users/Administrator/.m2/repository/org/junit/jupiter/junit-jupiter/5.5.2/junit-jupiter-5.5.2.jar;c:/Users/Administrator/.m2/repository/org/junit/jupiter/junit-jupiter/5.8.2/junit-jupiter-5.8.2.jar;c:/Users/Administrator/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.5.2/junit-jupiter-api-5.5.2.jar;c:/Users/Administrator/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.8.2/junit-jupiter-api-5.8.2.jar;c:/Users/Administrator/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.5.2/junit-jupiter-engine-5.5.2.jar;c:/Users/Administrator/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.8.2/junit-jupiter-engine-5.8.2.jar;c:/Users/Administrator/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.5.2/junit-jupiter-params-5.5.2.jar;c:/Users/Administrator/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.8.2/junit-jupiter-params-5.8.2.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-commons/1.3.1/junit-platform-commons-1.3.1.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-commons/1.5.2/junit-platform-commons-1.5.2.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-commons/1.8.2/junit-platform-commons-1.8.2.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-commons/1.9.3/junit-platform-commons-1.9.3.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-engine/1.3.1/junit-platform-engine-1.3.1.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-engine/1.5.2/junit-platform-engine-1.5.2.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-engine/1.8.2/junit-platform-engine-1.8.2.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-engine/1.9.3/junit-platform-engine-1.9.3.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-launcher/1.3.1/junit-platform-launcher-1.3.1.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-launcher/1.5.2/junit-platform-launcher-1.5.2.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-launcher/1.8.2/junit-platform-launcher-1.8.2.jar;c:/Users/Administrator/.m2/repository/org/junit/platform/junit-platform-launcher/1.9.3/junit-platform-launcher-1.9.3.jar;c:/Users/Administrator/.m2/repository/org/junit/vintage/junit-vintage-engine/5.5.2/junit-vintage-engine-5.5.2.jar;c:/Users/Administrator/.m2/repository/org/jvnet/staxex/stax-ex/1.8.1/stax-ex-1.8.1.jar;c:/Users/Administrator/.m2/repository/org/latencyutils/LatencyUtils/2.0.3/LatencyUtils-2.0.3.jar;c:/Users/Administrator/.m2/repository/org/mapstruct/mapstruct/1.2.0.Final/mapstruct-1.2.0.Final.jar;c:/Users/Administrator/.m2/repository/org/mariadb/jdbc/mariadb-java-client/1.5.9/mariadb-java-client-1.5.9.jar;c:/Users/Administrator/.m2/repository/org/mariadb/jdbc/mariadb-java-client/2.2.4/mariadb-java-client-2.2.4.jar;c:/Users/Administrator/.m2/repository/org/mockito/mockito-core/1.10.19/mockito-core-1.10.19.jar;c:/Users/Administrator/.m2/repository/org/mockito/mockito-core/2.13.0/mockito-core-2.13.0.jar;c:/Users/Administrator/.m2/repository/org/mockito/mockito-core/3.1.0/mockito-core-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/mockito/mockito-core/4.5.1/mockito-core-4.5.1.jar;c:/Users/Administrator/.m2/repository/org/mockito/mockito-junit-jupiter/3.1.0/mockito-junit-jupiter-3.1.0.jar;c:/Users/Administrator/.m2/repository/org/mockito/mockito-junit-jupiter/4.5.1/mockito-junit-jupiter-4.5.1.jar;c:/Users/Administrator/.m2/repository/org/modelmapper/modelmapper/3.2.0/modelmapper-3.2.0.jar;c:/Users/Administrator/.m2/repository/org/mongodb/mongo-java-driver/3.11.0/mongo-java-driver-3.11.0.jar;c:/Users/Administrator/.m2/repository/org/mongodb/mongo-java-driver/3.4.3/mongo-java-driver-3.4.3.jar;c:/Users/Administrator/.m2/repository/org/mortbay/jetty/jetty/6.1.25/jetty-6.1.25.jar;c:/Users/Administrator/.m2/repository/org/mortbay/jetty/jetty-util/6.1.25/jetty-util-6.1.25.jar;c:/Users/Administrator/.m2/repository/org/mortbay/jetty/servlet-api/2.5-20081211/servlet-api-2.5-20081211.jar;c:/Users/Administrator/.m2/repository/org/objenesis/objenesis/2.1/objenesis-2.1.jar;c:/Users/Administrator/.m2/repository/org/objenesis/objenesis/2.6/objenesis-2.6.jar;c:/Users/Administrator/.m2/repository/org/objenesis/objenesis/3.2/objenesis-3.2.jar;c:/Users/Administrator/.m2/repository/org/openpnp/opencv/4.7.0-0/opencv-4.7.0-0.jar;c:/Users/Administrator/.m2/repository/org/opentest4j/opentest4j/1.1.1/opentest4j-1.1.1.jar;c:/Users/Administrator/.m2/repository/org/opentest4j/opentest4j/1.2.0/opentest4j-1.2.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/4.1/asm-4.1.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/5.0.4/asm-5.0.4.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/6.1.1/asm-6.1.1.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/6.2/asm-6.2.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/7.0/asm-7.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/8.0/asm-8.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/9.2/asm-9.2.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/9.3/asm-9.3.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/9.6/asm-9.6.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/9.7/asm-9.7.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm/9.8/asm-9.8.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-analysis/4.1/asm-analysis-4.1.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-analysis/7.0/asm-analysis-7.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-analysis/8.0/asm-analysis-8.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-commons/7.0/asm-commons-7.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-commons/8.0/asm-commons-8.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-tree/4.1/asm-tree-4.1.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-tree/7.0/asm-tree-7.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-tree/8.0/asm-tree-8.0.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-util/4.1/asm-util-4.1.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-util/7.0-beta/asm-util-7.0-beta.jar;c:/Users/Administrator/.m2/repository/org/ow2/asm/asm-util/8.0/asm-util-8.0.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-cas/3.6.1/pac4j-cas-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-config/3.6.1/pac4j-config-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-core/3.6.1/pac4j-core-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-http/3.6.1/pac4j-http-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-jwt/3.6.1/pac4j-jwt-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-mongo/3.6.1/pac4j-mongo-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-oauth/3.6.1/pac4j-oauth-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-oidc/3.6.1/pac4j-oidc-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/pac4j-saml/3.6.1/pac4j-saml-3.6.1.jar;c:/Users/Administrator/.m2/repository/org/pac4j/spring-webmvc-pac4j/3.0.0/spring-webmvc-pac4j-3.0.0.jar;c:/Users/Administrator/.m2/repository/org/parboiled/parboiled-core/1.1.4/parboiled-core-1.1.4.jar;c:/Users/Administrator/.m2/repository/org/parboiled/parboiled-java/1.1.4/parboiled-java-1.1.4.jar;c:/Users/Administrator/.m2/repository/org/pegdown/pegdown/1.2.1/pegdown-1.2.1.jar;c:/Users/Administrator/.m2/repository/org/postgresql/postgresql/42.2.5/postgresql-42.2.5.jar;c:/Users/Administrator/.m2/repository/org/postgresql/postgresql/9.4.1212.jre7/postgresql-9.4.1212.jre7.jar;c:/Users/Administrator/.m2/repository/org/projectlombok/lombok/1.18.10/lombok-1.18.10.jar;c:/Users/Administrator/.m2/repository/org/projectlombok/lombok/1.18.12/lombok-1.18.12.jar;c:/Users/Administrator/.m2/repository/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar;c:/Users/Administrator/.m2/repository/org/quartz-scheduler/quartz/2.3.0/quartz-2.3.0.jar;c:/Users/Administrator/.m2/repository/org/reactivestreams/reactive-streams/1.0.3/reactive-streams-1.0.3.jar;c:/Users/Administrator/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar;c:/Users/Administrator/.m2/repository/org/redisson/redisson/3.13.6/redisson-3.13.6.jar;c:/Users/Administrator/.m2/repository/org/reflections/reflections/0.9.11/reflections-0.9.11.jar;c:/Users/Administrator/.m2/repository/org/scilab/forge/jlatexmath/1.0.6/jlatexmath-1.0.6.jar;c:/Users/Administrator/.m2/repository/org/scilab/forge/jlatexmath-font-cyrillic/1.0.6/jlatexmath-font-cyrillic-1.0.6.jar;c:/Users/Administrator/.m2/repository/org/scilab/forge/jlatexmath-font-greek/1.0.6/jlatexmath-font-greek-1.0.6.jar;c:/Users/Administrator/.m2/repository/org/skyscreamer/jsonassert/1.5.0/jsonassert-1.5.0.jar;c:/Users/Administrator/.m2/repository/org/skyscreamer/jsonassert/1.5.1/jsonassert-1.5.1.jar;c:/Users/Administrator/.m2/repository/org/slf4j/jcl-over-slf4j/1.5.6/jcl-over-slf4j-1.5.6.jar;c:/Users/Administrator/.m2/repository/org/slf4j/jcl-over-slf4j/1.7.25/jcl-over-slf4j-1.7.25.jar;c:/Users/Administrator/.m2/repository/org/slf4j/jul-to-slf4j/1.7.25/jul-to-slf4j-1.7.25.jar;c:/Users/Administrator/.m2/repository/org/slf4j/jul-to-slf4j/1.7.28/jul-to-slf4j-1.7.28.jar;c:/Users/Administrator/.m2/repository/org/slf4j/jul-to-slf4j/1.7.29/jul-to-slf4j-1.7.29.jar;c:/Users/Administrator/.m2/repository/org/slf4j/jul-to-slf4j/1.7.30/jul-to-slf4j-1.7.30.jar;c:/Users/Administrator/.m2/repository/org/slf4j/jul-to-slf4j/1.7.36/jul-to-slf4j-1.7.36.jar;c:/Users/Administrator/.m2/repository/org/slf4j/log4j-over-slf4j/1.7.25/log4j-over-slf4j-1.7.25.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-api/1.5.6/slf4j-api-1.5.6.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-api/1.7.25/slf4j-api-1.7.25.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-api/1.7.28/slf4j-api-1.7.28.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-api/1.7.29/slf4j-api-1.7.29.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-api/1.7.30/slf4j-api-1.7.30.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-api/1.7.5/slf4j-api-1.7.5.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-jdk14/1.5.6/slf4j-jdk14-1.5.6.jar;c:/Users/Administrator/.m2/repository/org/slf4j/slf4j-simple/1.7.30/slf4j-simple-1.7.30.jar;c:/Users/Administrator/.m2/repository/org/sonatype/aether/aether-api/1.7/aether-api-1.7.jar;c:/Users/Administrator/.m2/repository/org/sonatype/aether/aether-api/1.9/aether-api-1.9.jar;c:/Users/Administrator/.m2/repository/org/sonatype/aether/aether-impl/1.7/aether-impl-1.7.jar;c:/Users/Administrator/.m2/repository/org/sonatype/aether/aether-impl/1.9/aether-impl-1.9.jar;c:/Users/Administrator/.m2/repository/org/sonatype/aether/aether-spi/1.7/aether-spi-1.7.jar;c:/Users/Administrator/.m2/repository/org/sonatype/aether/aether-spi/1.9/aether-spi-1.9.jar;c:/Users/Administrator/.m2/repository/org/sonatype/aether/aether-util/1.7/aether-util-1.7.jar;c:/Users/Administrator/.m2/repository/org/sonatype/aether/aether-util/1.9/aether-util-1.9.jar;c:/Users/Administrator/.m2/repository/org/sonatype/plexus/plexus-build-api/0.0.4/plexus-build-api-0.0.4.jar;c:/Users/Administrator/.m2/repository/org/sonatype/plexus/plexus-build-api/0.0.7/plexus-build-api-0.0.7.jar;c:/Users/Administrator/.m2/repository/org/sonatype/plexus/plexus-cipher/1.4/plexus-cipher-1.4.jar;c:/Users/Administrator/.m2/repository/org/sonatype/plexus/plexus-sec-dispatcher/1.3/plexus-sec-dispatcher-1.3.jar;c:/Users/Administrator/.m2/repository/org/sonatype/sisu/sisu-guice/2.1.7/sisu-guice-2.1.7-noaop.jar;c:/Users/Administrator/.m2/repository/org/sonatype/sisu/sisu-guice/2.9.1/sisu-guice-2.9.1-noaop.jar;c:/Users/Administrator/.m2/repository/org/sonatype/sisu/sisu-guice/3.1.0/sisu-guice-3.1.0-no_aop.jar;c:/Users/Administrator/.m2/repository/org/sonatype/sisu/sisu-inject-bean/1.4.2/sisu-inject-bean-1.4.2.jar;c:/Users/Administrator/.m2/repository/org/sonatype/sisu/sisu-inject-bean/1.4.3.1/sisu-inject-bean-1.4.3.1.jar;c:/Users/Administrator/.m2/repository/org/sonatype/sisu/sisu-inject-plexus/1.4.2/sisu-inject-plexus-1.4.2.jar;c:/Users/Administrator/.m2/repository/org/sonatype/sisu/sisu-inject-plexus/1.4.3.1/sisu-inject-plexus-1.4.3.1.jar;c:/Users/Administrator/.m2/repository/org/springdoc/springdoc-openapi-common/1.7.0/springdoc-openapi-common-1.7.0.jar;c:/Users/Administrator/.m2/repository/org/springdoc/springdoc-openapi-ui/1.7.0/springdoc-openapi-ui-1.7.0.jar;c:/Users/Administrator/.m2/repository/org/springdoc/springdoc-openapi-webmvc-core/1.7.0/springdoc-openapi-webmvc-core-1.7.0.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-amqp/2.2.0.RELEASE/spring-amqp-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-amqp/2.2.2.RELEASE/spring-amqp-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-amqp/2.2.3.RELEASE/spring-amqp-2.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-amqp/2.2.6.RELEASE/spring-amqp-2.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-amqp/2.4.17/spring-amqp-2.4.17.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-rabbit/2.2.0.RELEASE/spring-rabbit-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-rabbit/2.2.2.RELEASE/spring-rabbit-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-rabbit/2.2.3.RELEASE/spring-rabbit-2.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/amqp/spring-rabbit/2.2.6.RELEASE/spring-rabbit-2.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot/1.5.18.RELEASE/spring-boot-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot/2.2.0.RELEASE/spring-boot-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot/2.2.2.RELEASE/spring-boot-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot/2.2.4.RELEASE/spring-boot-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot/2.3.0.RELEASE/spring-boot-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot/2.7.18/spring-boot-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-actuator/1.5.18.RELEASE/spring-boot-actuator-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-actuator/2.2.4.RELEASE/spring-boot-actuator-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-actuator-autoconfigure/2.2.4.RELEASE/spring-boot-actuator-autoconfigure-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/1.5.18.RELEASE/spring-boot-autoconfigure-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/2.2.0.RELEASE/spring-boot-autoconfigure-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/2.2.2.RELEASE/spring-boot-autoconfigure-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/2.2.4.RELEASE/spring-boot-autoconfigure-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/2.3.0.RELEASE/spring-boot-autoconfigure-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/2.7.18/spring-boot-autoconfigure-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-buildpack-platform/2.7.18/spring-boot-buildpack-platform-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-configuration-metadata/1.5.18.RELEASE/spring-boot-configuration-metadata-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-configuration-processor/1.5.18.RELEASE/spring-boot-configuration-processor-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-configuration-processor/2.2.0.RELEASE/spring-boot-configuration-processor-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-configuration-processor/2.2.2.RELEASE/spring-boot-configuration-processor-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-configuration-processor/2.2.4.RELEASE/spring-boot-configuration-processor-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-configuration-processor/2.7.18/spring-boot-configuration-processor-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-devtools/1.5.18.RELEASE/spring-boot-devtools-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-devtools/2.2.2.RELEASE/spring-boot-devtools-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-devtools/2.2.4.RELEASE/spring-boot-devtools-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-devtools/2.3.0.RELEASE/spring-boot-devtools-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-devtools/2.7.18/spring-boot-devtools-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-loader-tools/1.5.18.RELEASE/spring-boot-loader-tools-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-loader-tools/2.2.0.RELEASE/spring-boot-loader-tools-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-loader-tools/2.2.4.RELEASE/spring-boot-loader-tools-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-loader-tools/2.7.18/spring-boot-loader-tools-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-maven-plugin/1.5.18.RELEASE/spring-boot-maven-plugin-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-maven-plugin/2.2.0.RELEASE/spring-boot-maven-plugin-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-maven-plugin/2.2.2.RELEASE/spring-boot-maven-plugin-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-maven-plugin/2.2.4.RELEASE/spring-boot-maven-plugin-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-maven-plugin/2.7.18/spring-boot-maven-plugin-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter/1.5.18.RELEASE/spring-boot-starter-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter/2.2.0.RELEASE/spring-boot-starter-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter/2.2.2.RELEASE/spring-boot-starter-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter/2.2.4.RELEASE/spring-boot-starter-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter/2.3.0.RELEASE/spring-boot-starter-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter/2.7.18/spring-boot-starter-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-actuator/1.5.18.RELEASE/spring-boot-starter-actuator-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-actuator/2.2.4.RELEASE/spring-boot-starter-actuator-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-amqp/2.2.0.RELEASE/spring-boot-starter-amqp-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-amqp/2.2.2.RELEASE/spring-boot-starter-amqp-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-amqp/2.2.4.RELEASE/spring-boot-starter-amqp-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-amqp/2.3.0.RELEASE/spring-boot-starter-amqp-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-aop/2.2.0.RELEASE/spring-boot-starter-aop-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-aop/2.2.2.RELEASE/spring-boot-starter-aop-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-aop/2.2.4.RELEASE/spring-boot-starter-aop-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-aop/2.3.0.RELEASE/spring-boot-starter-aop-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-aop/2.7.18/spring-boot-starter-aop-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-cache/2.2.4.RELEASE/spring-boot-starter-cache-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-cache/2.7.18/spring-boot-starter-cache-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-jpa/2.2.0.RELEASE/spring-boot-starter-data-jpa-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-jpa/2.2.2.RELEASE/spring-boot-starter-data-jpa-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-jpa/2.2.4.RELEASE/spring-boot-starter-data-jpa-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-jpa/2.3.0.RELEASE/spring-boot-starter-data-jpa-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-jpa/2.7.18/spring-boot-starter-data-jpa-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-redis/1.5.18.RELEASE/spring-boot-starter-data-redis-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-redis/2.2.2.RELEASE/spring-boot-starter-data-redis-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-redis/2.2.4.RELEASE/spring-boot-starter-data-redis-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-redis/2.3.0.RELEASE/spring-boot-starter-data-redis-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-data-redis/2.7.18/spring-boot-starter-data-redis-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/2.2.0.RELEASE/spring-boot-starter-jdbc-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/2.2.2.RELEASE/spring-boot-starter-jdbc-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/2.2.4.RELEASE/spring-boot-starter-jdbc-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/2.3.0.RELEASE/spring-boot-starter-jdbc-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-jdbc/2.7.18/spring-boot-starter-jdbc-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-json/2.2.0.RELEASE/spring-boot-starter-json-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-json/2.2.2.RELEASE/spring-boot-starter-json-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-json/2.2.4.RELEASE/spring-boot-starter-json-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-json/2.3.0.RELEASE/spring-boot-starter-json-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-json/2.7.18/spring-boot-starter-json-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-log4j2/1.5.18.RELEASE/spring-boot-starter-log4j2-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-logging/1.5.18.RELEASE/spring-boot-starter-logging-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-logging/2.2.0.RELEASE/spring-boot-starter-logging-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-logging/2.2.2.RELEASE/spring-boot-starter-logging-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-logging/2.2.4.RELEASE/spring-boot-starter-logging-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-logging/2.3.0.RELEASE/spring-boot-starter-logging-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-logging/2.7.18/spring-boot-starter-logging-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-mail/1.5.18.RELEASE/spring-boot-starter-mail-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-security/1.5.18.RELEASE/spring-boot-starter-security-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-test/2.2.0.RELEASE/spring-boot-starter-test-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-test/2.2.2.RELEASE/spring-boot-starter-test-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-test/2.2.4.RELEASE/spring-boot-starter-test-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-test/2.7.18/spring-boot-starter-test-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-thymeleaf/1.5.18.RELEASE/spring-boot-starter-thymeleaf-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/1.5.18.RELEASE/spring-boot-starter-tomcat-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/2.2.0.RELEASE/spring-boot-starter-tomcat-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/2.2.2.RELEASE/spring-boot-starter-tomcat-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/2.2.4.RELEASE/spring-boot-starter-tomcat-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/2.3.0.RELEASE/spring-boot-starter-tomcat-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/2.7.18/spring-boot-starter-tomcat-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-validation/2.2.0.RELEASE/spring-boot-starter-validation-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-validation/2.2.2.RELEASE/spring-boot-starter-validation-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-validation/2.2.4.RELEASE/spring-boot-starter-validation-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-validation/2.7.18/spring-boot-starter-validation-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-web/1.5.18.RELEASE/spring-boot-starter-web-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-web/2.2.0.RELEASE/spring-boot-starter-web-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-web/2.2.2.RELEASE/spring-boot-starter-web-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-web/2.2.4.RELEASE/spring-boot-starter-web-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-web/2.3.0.RELEASE/spring-boot-starter-web-2.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-web/2.7.18/spring-boot-starter-web-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-starter-websocket/1.5.18.RELEASE/spring-boot-starter-websocket-1.5.18.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-test/2.2.0.RELEASE/spring-boot-test-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-test/2.2.2.RELEASE/spring-boot-test-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-test/2.2.4.RELEASE/spring-boot-test-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-test/2.7.18/spring-boot-test-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-test-autoconfigure/2.2.0.RELEASE/spring-boot-test-autoconfigure-2.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-test-autoconfigure/2.2.2.RELEASE/spring-boot-test-autoconfigure-2.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-test-autoconfigure/2.2.4.RELEASE/spring-boot-test-autoconfigure-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/boot/spring-boot-test-autoconfigure/2.7.18/spring-boot-test-autoconfigure-2.7.18.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-commons/1.3.0.RELEASE/spring-cloud-commons-1.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-commons/2.1.1.RELEASE/spring-cloud-commons-2.1.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-commons/2.2.1.RELEASE/spring-cloud-commons-2.2.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-commons/3.1.5/spring-cloud-commons-3.1.5.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-commons/3.1.8/spring-cloud-commons-3.1.8.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-config-client/2.1.1.RELEASE/spring-cloud-config-client-2.1.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-config-client/2.2.1.RELEASE/spring-cloud-config-client-2.2.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-context/1.3.0.RELEASE/spring-cloud-context-1.3.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-context/2.1.1.RELEASE/spring-cloud-context-2.1.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-context/2.2.1.RELEASE/spring-cloud-context-2.2.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-context/3.1.5/spring-cloud-context-3.1.5.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-context/3.1.8/spring-cloud-context-3.1.8.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-starter/2.1.1.RELEASE/spring-cloud-starter-2.1.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-starter/2.2.1.RELEASE/spring-cloud-starter-2.2.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-starter/3.1.8/spring-cloud-starter-3.1.8.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-starter-bootstrap/3.1.8/spring-cloud-starter-bootstrap-3.1.8.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-starter-config/2.1.1.RELEASE/spring-cloud-starter-config-2.1.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/cloud/spring-cloud-starter-config/2.2.1.RELEASE/spring-cloud-starter-config-2.2.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/data/spring-data-commons/2.2.4.RELEASE/spring-data-commons-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/data/spring-data-jpa/2.2.4.RELEASE/spring-data-jpa-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/data/spring-data-keyvalue/2.2.4.RELEASE/spring-data-keyvalue-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/data/spring-data-redis/2.2.4.RELEASE/spring-data-redis-2.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/plugin/spring-plugin-core/1.2.0.RELEASE/spring-plugin-core-1.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/plugin/spring-plugin-metadata/1.2.0.RELEASE/spring-plugin-metadata-1.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/retry/spring-retry/1.2.4.RELEASE/spring-retry-1.2.4.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/retry/spring-retry/1.2.5.RELEASE/spring-retry-1.2.5.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-config/4.2.10.RELEASE/spring-security-config-4.2.10.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-core/4.2.10.RELEASE/spring-security-core-4.2.10.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-core/4.2.8.RELEASE/spring-security-core-4.2.8.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-crypto/4.2.10.RELEASE/spring-security-crypto-4.2.10.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-crypto/4.2.13.RELEASE/spring-security-crypto-4.2.13.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-crypto/4.2.3.RELEASE/spring-security-crypto-4.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-crypto/5.2.0.RELEASE/spring-security-crypto-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-crypto/5.2.1.RELEASE/spring-security-crypto-5.2.1.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-crypto/5.3.2.RELEASE/spring-security-crypto-5.3.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-crypto/5.7.11/spring-security-crypto-5.7.11.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-rsa/1.0.12.RELEASE/spring-security-rsa-1.0.12.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-rsa/1.0.7.RELEASE/spring-security-rsa-1.0.7.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-rsa/1.0.9.RELEASE/spring-security-rsa-1.0.9.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/security/spring-security-web/4.2.10.RELEASE/spring-security-web-4.2.10.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/shell/spring-shell/1.2.0.RELEASE/spring-shell-1.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aop/4.3.21.RELEASE/spring-aop-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aop/4.3.25.RELEASE/spring-aop-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aop/5.2.0.RELEASE/spring-aop-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aop/5.2.2.RELEASE/spring-aop-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aop/5.2.3.RELEASE/spring-aop-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aop/5.2.6.RELEASE/spring-aop-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aop/5.3.31/spring-aop-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aspects/5.2.0.RELEASE/spring-aspects-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aspects/5.2.2.RELEASE/spring-aspects-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aspects/5.2.3.RELEASE/spring-aspects-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aspects/5.2.6.RELEASE/spring-aspects-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-aspects/5.3.31/spring-aspects-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-beans/4.3.21.RELEASE/spring-beans-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-beans/4.3.25.RELEASE/spring-beans-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-beans/5.2.0.RELEASE/spring-beans-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-beans/5.2.2.RELEASE/spring-beans-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-beans/5.2.3.RELEASE/spring-beans-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-beans/5.2.6.RELEASE/spring-beans-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-beans/5.3.31/spring-beans-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context/4.3.21.RELEASE/spring-context-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context/4.3.25.RELEASE/spring-context-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context/5.2.0.RELEASE/spring-context-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context/5.2.2.RELEASE/spring-context-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context/5.2.3.RELEASE/spring-context-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context/5.2.6.RELEASE/spring-context-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context/5.3.31/spring-context-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context-support/4.3.21.RELEASE/spring-context-support-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context-support/4.3.25.RELEASE/spring-context-support-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context-support/5.2.2.RELEASE/spring-context-support-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context-support/5.2.3.RELEASE/spring-context-support-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context-support/5.2.6.RELEASE/spring-context-support-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-context-support/5.3.31/spring-context-support-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-core/4.3.21.RELEASE/spring-core-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-core/4.3.25.RELEASE/spring-core-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-core/5.2.0.RELEASE/spring-core-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-core/5.2.2.RELEASE/spring-core-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-core/5.2.3.RELEASE/spring-core-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-core/5.2.6.RELEASE/spring-core-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-core/5.3.31/spring-core-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-expression/4.3.21.RELEASE/spring-expression-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-expression/4.3.25.RELEASE/spring-expression-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-expression/5.2.0.RELEASE/spring-expression-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-expression/5.2.2.RELEASE/spring-expression-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-expression/5.2.3.RELEASE/spring-expression-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-expression/5.2.6.RELEASE/spring-expression-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-expression/5.3.31/spring-expression-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jcl/5.2.0.RELEASE/spring-jcl-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jcl/5.2.2.RELEASE/spring-jcl-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jcl/5.2.3.RELEASE/spring-jcl-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jcl/5.2.6.RELEASE/spring-jcl-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jcl/5.3.31/spring-jcl-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jdbc/4.3.21.RELEASE/spring-jdbc-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jdbc/4.3.25.RELEASE/spring-jdbc-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jdbc/5.2.0.RELEASE/spring-jdbc-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jdbc/5.2.2.RELEASE/spring-jdbc-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jdbc/5.2.3.RELEASE/spring-jdbc-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jdbc/5.2.6.RELEASE/spring-jdbc-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jdbc/5.3.31/spring-jdbc-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jms/4.3.21.RELEASE/spring-jms-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-jms/4.3.25.RELEASE/spring-jms-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-messaging/4.3.21.RELEASE/spring-messaging-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-messaging/4.3.25.RELEASE/spring-messaging-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-messaging/5.2.0.RELEASE/spring-messaging-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-messaging/5.2.2.RELEASE/spring-messaging-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-messaging/5.2.3.RELEASE/spring-messaging-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-messaging/5.2.6.RELEASE/spring-messaging-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-orm/4.3.21.RELEASE/spring-orm-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-orm/4.3.25.RELEASE/spring-orm-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-orm/5.2.0.RELEASE/spring-orm-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-orm/5.2.2.RELEASE/spring-orm-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-orm/5.2.3.RELEASE/spring-orm-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-orm/5.2.6.RELEASE/spring-orm-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-orm/5.3.31/spring-orm-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-oxm/4.3.21.RELEASE/spring-oxm-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-oxm/5.2.2.RELEASE/spring-oxm-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-oxm/5.2.3.RELEASE/spring-oxm-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-oxm/5.2.6.RELEASE/spring-oxm-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-oxm/5.3.31/spring-oxm-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-test/5.2.0.RELEASE/spring-test-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-test/5.2.2.RELEASE/spring-test-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-test/5.2.3.RELEASE/spring-test-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-test/5.3.31/spring-test-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-test/6.0.8/spring-test-6.0.8.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-tx/4.3.21.RELEASE/spring-tx-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-tx/4.3.25.RELEASE/spring-tx-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-tx/5.2.0.RELEASE/spring-tx-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-tx/5.2.2.RELEASE/spring-tx-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-tx/5.2.3.RELEASE/spring-tx-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-tx/5.2.6.RELEASE/spring-tx-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-tx/5.3.31/spring-tx-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-web/4.3.21.RELEASE/spring-web-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-web/4.3.25.RELEASE/spring-web-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-web/5.2.0.RELEASE/spring-web-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-web/5.2.2.RELEASE/spring-web-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-web/5.2.3.RELEASE/spring-web-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-web/5.2.6.RELEASE/spring-web-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-web/5.3.31/spring-web-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-webmvc/4.3.21.RELEASE/spring-webmvc-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-webmvc/4.3.25.RELEASE/spring-webmvc-4.3.25.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-webmvc/5.2.0.RELEASE/spring-webmvc-5.2.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-webmvc/5.2.2.RELEASE/spring-webmvc-5.2.2.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-webmvc/5.2.3.RELEASE/spring-webmvc-5.2.3.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-webmvc/5.2.6.RELEASE/spring-webmvc-5.2.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-webmvc/5.3.31/spring-webmvc-5.3.31.jar;c:/Users/Administrator/.m2/repository/org/springframework/spring-websocket/4.3.21.RELEASE/spring-websocket-4.3.21.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/springloaded/1.2.8.RELEASE/springloaded-1.2.8.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/webflow/spring-binding/2.5.0.RELEASE/spring-binding-2.5.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springframework/webflow/spring-webflow/2.5.0.RELEASE/spring-webflow-2.5.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/springmodules/spring-modules-cache/0.8/spring-modules-cache-0.8.jar;c:/Users/Administrator/.m2/repository/org/threeten/threetenbp/1.3.5/threetenbp-1.3.5.jar;c:/Users/Administrator/.m2/repository/org/threeten/threetenbp/1.3.6/threetenbp-1.3.6.jar;c:/Users/Administrator/.m2/repository/org/thymeleaf/thymeleaf/2.1.6.RELEASE/thymeleaf-2.1.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/thymeleaf/thymeleaf-spring4/2.1.6.RELEASE/thymeleaf-spring4-2.1.6.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/tomlj/tomlj/1.0.0/tomlj-1.0.0.jar;c:/Users/Administrator/.m2/repository/org/tukaani/xz/1.10/xz-1.10.jar;c:/Users/Administrator/.m2/repository/org/tukaani/xz/1.8/xz-1.8.jar;c:/Users/Administrator/.m2/repository/org/tukaani/xz/1.9/xz-1.9.jar;c:/Users/Administrator/.m2/repository/org/unbescape/unbescape/1.1.0.RELEASE/unbescape-1.1.0.RELEASE.jar;c:/Users/Administrator/.m2/repository/org/vafer/jdependency/0.7/jdependency-0.7.jar;c:/Users/Administrator/.m2/repository/org/vafer/jdependency/2.1.1/jdependency-2.1.1.jar;c:/Users/Administrator/.m2/repository/org/vafer/jdependency/2.4.0/jdependency-2.4.0.jar;c:/Users/Administrator/.m2/repository/org/w3c/css/sac/1.3/sac-1.3.jar;c:/Users/Administrator/.m2/repository/org/webjars/swagger-ui/4.18.2/swagger-ui-4.18.2.jar;c:/Users/Administrator/.m2/repository/org/xhtmlrenderer/flying-saucer-core/9.1.19/flying-saucer-core-9.1.19.jar;c:/Users/Administrator/.m2/repository/org/xhtmlrenderer/flying-saucer-pdf/9.1.19/flying-saucer-pdf-9.1.19.jar;c:/Users/Administrator/.m2/repository/org/xmlunit/xmlunit-core/2.6.3/xmlunit-core-2.6.3.jar;c:/Users/Administrator/.m2/repository/org/xmlunit/xmlunit-core/2.9.1/xmlunit-core-2.9.1.jar;c:/Users/Administrator/.m2/repository/org/yaml/snakeyaml/1.17/snakeyaml-1.17.jar;c:/Users/Administrator/.m2/repository/org/yaml/snakeyaml/1.25/snakeyaml-1.25.jar;c:/Users/Administrator/.m2/repository/org/yaml/snakeyaml/1.26/snakeyaml-1.26.jar;c:/Users/Administrator/.m2/repository/org/yaml/snakeyaml/1.30/snakeyaml-1.30.jar;c:/Users/Administrator/.m2/repository/oro/oro/2.0.8/oro-2.0.8.jar;c:/Users/Administrator/.m2/repository/pl/project13/maven/git-commit-id-plugin/3.0.1/git-commit-id-plugin-3.0.1.jar;c:/Users/Administrator/.m2/repository/qdox/qdox/1.5/qdox-1.5.jar;c:/Users/Administrator/.m2/repository/redis/clients/jedis/2.9.0/jedis-2.9.0.jar;c:/Users/Administrator/.m2/repository/sslext/sslext/1.2-0/sslext-1.2-0.jar;c:/Users/Administrator/.m2/repository/stax/stax-api/1.0.1/stax-api-1.0.1.jar;c:/Users/Administrator/.m2/repository/ws/schild/jave-all-deps/2.5.1/jave-all-deps-2.5.1.jar;c:/Users/Administrator/.m2/repository/ws/schild/jave-core/2.4.6/jave-core-2.4.6.jar;c:/Users/Administrator/.m2/repository/ws/schild/jave-core/2.5.1/jave-core-2.5.1.jar;c:/Users/Administrator/.m2/repository/ws/schild/jave-native-linux64/2.4.4/jave-native-linux64-2.4.4.jar;c:/Users/Administrator/.m2/repository/ws/schild/jave-nativebin-linux64/2.5.1/jave-nativebin-linux64-2.5.1.jar;c:/Users/Administrator/.m2/repository/ws/schild/jave-nativebin-win64/2.5.1/jave-nativebin-win64-2.5.1.jar;c:/Users/Administrator/.m2/repository/xalan/serializer/2.7.2/serializer-2.7.2.jar;c:/Users/Administrator/.m2/repository/xalan/xalan/2.7.2/xalan-2.7.2.jar;c:/Users/Administrator/.m2/repository/xerces/xercesImpl/2.9.1/xercesImpl-2.9.1.jar;c:/Users/Administrator/.m2/repository/xml-apis/xml-apis/1.3.04/xml-apis-1.3.04.jar;c:/Users/Administrator/.m2/repository/xml-apis/xml-apis/1.4.01/xml-apis-1.4.01.jar;c:/Users/Administrator/.m2/repository/xml-apis/xml-apis-ext/1.3.04/xml-apis-ext-1.3.04.jar;c:/Users/Administrator/.m2/repository/xmlpull/xmlpull/1.1.3.1/xmlpull-1.1.3.1.jar;c:/Users/Administrator/.m2/repository/xpp3/xpp3_min/1.1.4c/xpp3_min-1.1.4c.jar;D:/SoftProject/yunhai/UserBase/service/target/classes +-d +D:/SoftProject/yunhai/UserBase/service/tmpcheck +src/main/java/com/yunhai/userbase/repository/SysUserRepository.java +src/main/java/com/yunhai/userbase/service/impl/SysUserService.java From 767ad3a7dd3417a21e83fe600a047393407f7a76 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 13 Aug 2026 21:39:42 +0800 Subject: [PATCH 34/58] =?UTF-8?q?=E6=8E=A8=E9=80=81ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 6 ++++++ docker/application-prod.yml | 2 ++ 2 files changed, 8 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 795cf34..c6b15c7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -110,6 +110,9 @@ jobs: # namespace/凭据仍用顶层 SPRING_CLOUD_NACOS_* env(bootstrap 的 ${...} 占位符才能解析到) docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \ --add-host sso.yunhaixingtong.com:192.168.10.160 \ + --memory=1.5g --memory-swap=1.5g \ + -v /data/logs/userbase:/app/logs \ + -e JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m" \ -e SPRING_PROFILES_ACTIVE=prod \ -e NACOS_CONFIG_ENABLED=true \ -e NACOS_ADDR=nacos:8848 \ @@ -133,6 +136,9 @@ jobs: # 回滚也必须注入同样的 env,否则 bootstrap 默认 enabled=false 会连不上 nacos 拉不到线上配置 docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \ --add-host sso.yunhaixingtong.com:192.168.10.160 \ + --memory=1.5g --memory-swap=1.5g \ + -v /data/logs/userbase:/app/logs \ + -e JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m" \ -e SPRING_PROFILES_ACTIVE=prod \ -e NACOS_CONFIG_ENABLED=true \ -e NACOS_ADDR=nacos:8848 \ diff --git a/docker/application-prod.yml b/docker/application-prod.yml index 51bda44..80bb6d3 100644 --- a/docker/application-prod.yml +++ b/docker/application-prod.yml @@ -84,3 +84,5 @@ UPLOAD_WEB_PATH_HTTP: http://base.yunhaixingtong.com/userbase/upload/file/ logging: level: root: INFO + file: + path: /app/logs From 0233ecb525ad8a60fe3c5aa2b8d133107d71e3a1 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 13 Aug 2026 22:17:35 +0800 Subject: [PATCH 35/58] =?UTF-8?q?=E6=B5=8B=E8=AF=95ci=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yunhai/userbase/controller/api/AppRoleController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java index 887a313..9e963c5 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java @@ -42,7 +42,7 @@ public class AppRoleController { private IAppRoleService appRoleService; /** - * 角色分页列表:name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称。 + * 角色分页列表 :name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称。 */ @GetMapping("/RoleList") public BasePageResult roleList(SysAppRoleQuery query, BasePageQuery pageQuery, From e55c2cb209488f43d156969d8106f3969d66c38a Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 14 Aug 2026 09:03:17 +0800 Subject: [PATCH 36/58] =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 6 + .../controller/api/AppSettingsController.java | 13 + .../api/PushSubscriptionController.java | 60 ++++ .../userbase/entity/ApplicationInfo.java | 39 +++ .../repository/ApplicationInfoRepository.java | 3 + .../service/IApplicationInfoService.java | 19 + .../service/impl/ApplicationInfoService.java | 50 +++ .../service/impl/SysGroupService.java | 34 +- .../userbase/service/impl/SysUserService.java | 39 ++- .../service/push/GroupScopeHelper.java | 60 ++++ .../userbase/service/push/PushConsumer.java | 159 +++++++++ .../userbase/service/push/PushDispatcher.java | 328 ++++++++++++++++++ .../service/push/PushPayloadAssembler.java | 186 ++++++++++ .../service/push/PushRecordService.java | 110 ++++++ .../userbase/service/push/PushSigner.java | 50 +++ 15 files changed, 1154 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java create mode 100644 src/main/java/com/yunhai/userbase/service/push/GroupScopeHelper.java create mode 100644 src/main/java/com/yunhai/userbase/service/push/PushConsumer.java create mode 100644 src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java create mode 100644 src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java create mode 100644 src/main/java/com/yunhai/userbase/service/push/PushRecordService.java create mode 100644 src/main/java/com/yunhai/userbase/service/push/PushSigner.java diff --git a/pom.xml b/pom.xml index 83cce41..2c11623 100644 --- a/pom.xml +++ b/pom.xml @@ -93,6 +93,12 @@ spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-amqp + + org.springframework.boot diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java index b693763..cc3e9d6 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java @@ -14,6 +14,8 @@ 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.application.PushSettingRequest; +import com.yunhai.userbase.model.application.PushSettingVo; import com.yunhai.userbase.model.appmodule.AppModuleQuery; import com.yunhai.userbase.model.appmodule.AppModuleRequest; import com.yunhai.userbase.model.appmodule.AppModuleVo; @@ -162,4 +164,15 @@ public class AppSettingsController { public List moduleList(AppModuleQuery query) { return appModuleService.list(query); } + + @OperationLog("保存应用推送设置") + @PostMapping("SavePushSetting/{id}") + public void savePushSetting(@PathVariable Integer id, @RequestBody PushSettingRequest request) { + applicationInfoService.savePushSetting(id, request); + } + + @GetMapping("GetPushSetting/{id}") + public PushSettingVo getPushSetting(@PathVariable Integer id) { + return applicationInfoService.getPushSetting(id); + } } diff --git a/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java b/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java new file mode 100644 index 0000000..3999d75 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java @@ -0,0 +1,60 @@ +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.push.PushRecordEntity; +import com.yunhai.userbase.model.push.PushRecordQuery; +import com.yunhai.userbase.model.push.PushTestRequest; +import com.yunhai.userbase.service.push.PushDispatcher; +import com.yunhai.userbase.service.push.PushRecordService; +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.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 推送订阅控制器:测试推送 + 推送日志记录 / 重试。 + * + * @author chenx + * @date 2026/8/14 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/push/") +public class PushSubscriptionController { + + @Autowired + private PushDispatcher pushDispatcher; + @Autowired + private PushRecordService pushRecordService; + + /** 测试推送:手动触发一次推送(投递 MQ),并返回请求预览(URL/头/体,密钥不外露)。 */ + @PostMapping("Test") + public Map test(@RequestBody PushTestRequest request) { + pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds()); + Map preview = pushDispatcher.previewManual( + request.getAppId(), request.getInterfacePath(), request.getTargetIds()); + Map result = new LinkedHashMap<>(); + result.put("sent", true); + result.put("message", "已投递推送队列,可在「推送日志」查看实际响应与耗时"); + result.put("preview", preview); + return result; + } + + /** 推送日志列表。 */ + @GetMapping("RecordList") + public BasePageResult recordList(PushRecordQuery query, BasePageQuery pageQuery, + @SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) { + return PageUtils.toResult(pushRecordService.query(query, PageUtils.toPageable(pageQuery, sort))); + } + + /** 手动重试一条失败的推送记录。 */ + @PostMapping("Retry/{id}") + public void retry(@PathVariable Long id) { + pushRecordService.retry(id); + } +} diff --git a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java index da2fa88..6d17c06 100644 --- a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java +++ b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java @@ -104,4 +104,43 @@ public class ApplicationInfo extends BaseEntity { @Column(name = "dingtalk_url") private String dingtalkUrl; + // ===================== 推送订阅配置(文档《弘育基础平台数据同步接口 2》) ===================== + + /** + * 推送外部接口地址(不含固定后缀),如 http://host/notice/api/base/ 。 + * 完整调用地址 = pushBaseUrl + 接口路径(如 group/AddGroup)。 + */ + @Column(name = "push_base_url") + private String pushBaseUrl; + + /** + * 推送总开关:0关 1开(默认 0,自动推送默认不触发)。 + */ + @Column(name = "push_enabled", nullable = false) + private Integer pushEnabled = 0; + + /** + * 启用的接口路径,逗号分隔(如 group/AddGroup,user/AddUser);空=不推送任何接口。 + */ + @Column(name = "push_event_types") + private String pushEventTypes; + + /** + * 关联学校组 id,逗号分隔;空=全局(监听所有学校变化)。 + */ + @Column(name = "push_school_ids") + private String pushSchoolIds; + + /** + * 推送角色 key,逗号分隔;空=全部角色。 + */ + @Column(name = "push_role_ids") + private String pushRoleIds; + + /** + * 推送签名密钥;空则复用 appSecret。 + */ + @Column(name = "push_secret") + private String pushSecret; + } \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java index 8bde07a..a34acb3 100644 --- a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java @@ -42,6 +42,9 @@ public interface ApplicationInfoRepository extends JpaRepository findByPushEnabled(Integer pushEnabled); + /** * 按 id 更新排序号(同一应用类型内排序使用)。
* diff --git a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java index b91c55f..08d31c0 100644 --- a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java @@ -5,6 +5,8 @@ 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.application.PushSettingRequest; +import com.yunhai.userbase.model.application.PushSettingVo; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -82,4 +84,21 @@ public interface IApplicationInfoService { * @param ids 同一应用类型下的应用ID有序列表 */ void sort(List ids); + + /** + * 保存应用推送设置(外部接口地址 / 总开关 / 启用的接口 / 学校范围 / 角色范围 / 推送密钥)。
+ * 仅更新推送相关字段,写后同步更新 appInfo 缓存。 + * + * @param id 应用ID + * @param request 推送设置(各字段可选,null 表示不修改) + */ + ApplicationInfo savePushSetting(Integer id, PushSettingRequest request); + + /** + * 获取应用推送设置回显 VO。 + * + * @param id 应用ID + * @return 推送设置 VO(仅含推送相关字段) + */ + PushSettingVo getPushSetting(Integer id); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index 0bedc80..a24150a 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -22,6 +22,8 @@ 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.application.PushSettingRequest; +import com.yunhai.userbase.model.application.PushSettingVo; import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeanUtils; @@ -266,4 +268,52 @@ public class ApplicationInfoService implements IApplicationInfoService { public List findListByCategoryId(Integer categoryId) { return applicationInfoRepository.findSortVoByCategoryId(categoryId); } + + @Override + @Transactional + @CachePut(value = "appInfo", key = "#id") + public ApplicationInfo savePushSetting(Integer id, PushSettingRequest request) { + Objects.requireNonNull(id, "应用ID不能为空"); + Objects.requireNonNull(request, "推送设置入参不能为空"); + ApplicationInfo entity = applicationInfoRepository.findById(id) + .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); + if (request.getPushBaseUrl() != null) { + entity.setPushBaseUrl(request.getPushBaseUrl()); + } + if (request.getPushEnabled() != null) { + entity.setPushEnabled(request.getPushEnabled()); + } + if (request.getPushEventTypes() != null) { + entity.setPushEventTypes(request.getPushEventTypes()); + } + if (request.getPushSchoolIds() != null) { + entity.setPushSchoolIds(request.getPushSchoolIds()); + } + if (request.getPushRoleIds() != null) { + entity.setPushRoleIds(request.getPushRoleIds()); + } + // pushSecret:null=不修改;空串=清空(后续复用 appSecret) + if (request.getPushSecret() != null) { + entity.setPushSecret(request.getPushSecret()); + } + return applicationInfoRepository.save(entity); + } + + @Override + public PushSettingVo getPushSetting(Integer id) { + Objects.requireNonNull(id, "应用ID不能为空"); + ApplicationInfo entity = applicationInfoRepository.findById(id) + .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); + PushSettingVo vo = new PushSettingVo(); + vo.setId(entity.getId()); + vo.setAppKey(entity.getAppKey()); + vo.setName(entity.getName()); + vo.setPushBaseUrl(entity.getPushBaseUrl()); + vo.setPushEnabled(entity.getPushEnabled()); + vo.setPushEventTypes(entity.getPushEventTypes()); + vo.setPushSchoolIds(entity.getPushSchoolIds()); + vo.setPushRoleIds(entity.getPushRoleIds()); + vo.setPushSecret(entity.getPushSecret()); + return vo; + } } \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index f357502..33044a0 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -5,6 +5,9 @@ import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.userbase.enums.IEnum; +import com.yunhai.userbase.event.DataSyncEvent; +import com.yunhai.userbase.model.push.PushInterfaceType; +import org.springframework.context.ApplicationEventPublisher; import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest; import com.yunhai.userbase.model.sysgroup.SysGroupQuery; @@ -23,6 +26,7 @@ import org.springframework.util.StringUtils; import javax.transaction.Transactional; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; @@ -55,6 +59,9 @@ public class SysGroupService implements ISysGroupService { @Autowired private SysGroupModulePermRepository sysGroupModulePermRepository; + @Autowired + private ApplicationEventPublisher eventPublisher; + // ===================== 基础增删改(含根节点保护) ===================== @Override @@ -75,7 +82,8 @@ public class SysGroupService implements ISysGroupService { entity.setParentId(parentId); entity.setType(typeEnum); entity.setSort(nextSort(parentId)); - sysGroupRepository.save(entity); + SysGroup saved = sysGroupRepository.save(entity); + publishGroupEvent(PushInterfaceType.ADD_GROUP, saved.getId()); } @Override @@ -96,6 +104,7 @@ public class SysGroupService implements ISysGroupService { } // 注:移动请走 move 接口,edit 不改 parentId,避免与移动逻辑混淆 sysGroupRepository.save(entity); + publishGroupEvent(PushInterfaceType.UPDATE_GROUP, id); } @Override @@ -117,6 +126,7 @@ public class SysGroupService implements ISysGroupService { sysGroupAppRelRepository.deleteByGroupIdBulk(id); sysGroupModulePermRepository.deleteByGroupIdBulk(id); sysGroupRepository.deleteById(id); + publishGroupEvent(PushInterfaceType.DELETE_GROUP, id); } @Override @@ -326,6 +336,8 @@ public class SysGroupService implements ISysGroupService { batch.add(g); } sysGroupRepository.saveAll(batch); + publishGroupEvent(PushInterfaceType.ADD_GROUP, + batch.stream().map(SysGroup::getId).collect(Collectors.toList())); } // ===================== 子组排序 ===================== @@ -392,6 +404,7 @@ public class SysGroupService implements ISysGroupService { moveEntity.setParentId(pid); moveEntity.setSort(nextSort(pid)); sysGroupRepository.save(moveEntity); + publishGroupEvent(PushInterfaceType.UPDATE_GROUP, moveId); } /** @@ -522,4 +535,23 @@ public class SysGroupService implements ISysGroupService { vo.setType(entity.getType() == null ? null : entity.getType().getKey()); return vo; } + + // ===================== 推送事件(AFTER_COMMIT 由 PushDispatcher 消费,push_enabled 默认 0 不触发) ===================== + + private void publishGroupEvent(PushInterfaceType type, Integer groupId) { + DataSyncEvent e = new DataSyncEvent(); + e.setType(type); + e.setGroupIds(Collections.singletonList(groupId)); + eventPublisher.publishEvent(e); + } + + private void publishGroupEvent(PushInterfaceType type, List groupIds) { + if (groupIds == null || groupIds.isEmpty()) { + return; + } + DataSyncEvent e = new DataSyncEvent(); + e.setType(type); + e.setGroupIds(groupIds); + eventPublisher.publishEvent(e); + } } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index a3ef3f5..638ebdd 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -18,6 +18,9 @@ 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.event.DataSyncEvent; +import com.yunhai.userbase.model.push.PushInterfaceType; +import org.springframework.context.ApplicationEventPublisher; import cn.afterturn.easypoi.excel.ExcelExportUtil; import cn.afterturn.easypoi.excel.entity.ExportParams; import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; @@ -79,6 +82,9 @@ public class SysUserService implements ISysUserService { @Autowired private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + @Autowired + private ApplicationEventPublisher eventPublisher; + @Autowired private SysGroupRepository sysGroupRepository; @@ -130,7 +136,8 @@ public class SysUserService implements ISysUserService { // disabled 用枚举存储:不传时默认 NORMAL(正常/未禁用) entity.setDisabled(request.getDisabled() != null ? resolveStatus(request.getDisabled()) : StatusEnum.NORMAL); entity.setExpired(request.getExpired() != null ? request.getExpired() : false); - sysUserRepository.save(entity); + SysUser saved = sysUserRepository.save(entity); + publishUserEvent(PushInterfaceType.ADD_USER, saved.getId()); // 写入组用户关联 SysUserGroupRel rel = new SysUserGroupRel(); @@ -175,6 +182,7 @@ public class SysUserService implements ISysUserService { entity.setExpired(request.getExpired()); } sysUserRepository.save(entity); + publishUserEvent(PushInterfaceType.UPDATE_USER, id); } @Override @@ -188,6 +196,7 @@ public class SysUserService implements ISysUserService { // AI-FIX(角色授权): 删除用户时级联清理用户-业务角色绑定(逻辑外键无物理 FK,避免残留孤儿数据) sysUserAppRoleRelRepository.deleteByUserIdBulk(id); sysUserRepository.deleteById(id); + publishUserEvent(PushInterfaceType.DELETE_USER, id); } @Override @@ -211,6 +220,7 @@ public class SysUserService implements ISysUserService { throw new DException("存在无效用户"); } + List deletedUserIds = new ArrayList<>(); for (Integer userId : userIds) { // 删除当前组关联 sysUserGroupRelRepository.deleteByUserIdAndGroupId(userId, groupId); @@ -218,8 +228,12 @@ public class SysUserService implements ISysUserService { if (sysUserGroupRelRepository.countByUserId(userId) == 0) { sysUserAppRoleRelRepository.deleteByUserIdBulk(userId); sysUserRepository.deleteById(userId); + deletedUserIds.add(userId); } } + if (!deletedUserIds.isEmpty()) { + publishUserEvent(PushInterfaceType.DELETE_USER, deletedUserIds); + } } @Override @@ -230,6 +244,7 @@ public class SysUserService implements ISysUserService { // 实体 disabled 字段为 StatusEnum(0=正常/未禁用,1=禁用):boolean 直接映射枚举 entity.setDisabled(disabled ? StatusEnum.DISABLED : StatusEnum.NORMAL); sysUserRepository.save(entity); + publishUserEvent(PushInterfaceType.UPDATE_USER, id); } @Override @@ -247,6 +262,8 @@ public class SysUserService implements ISysUserService { user.setDisabled(targetStatus); } sysUserRepository.saveAll(users); + publishUserEvent(PushInterfaceType.UPDATE_USER, + users.stream().map(SysUser::getId).collect(Collectors.toList())); } @Override @@ -362,6 +379,7 @@ public class SysUserService implements ISysUserService { } sysUserGroupRelRepository.saveAll(rels); } + publishUserEvent(PushInterfaceType.UPDATE_USER, userIds); } @Override @@ -936,4 +954,23 @@ public class SysUserService implements ISysUserService { return null; } } + + // ===================== 推送事件 ===================== + + private void publishUserEvent(PushInterfaceType type, Integer userId) { + DataSyncEvent e = new DataSyncEvent(); + e.setType(type); + e.setUserIds(Collections.singletonList(userId)); + eventPublisher.publishEvent(e); + } + + private void publishUserEvent(PushInterfaceType type, List userIds) { + if (userIds == null || userIds.isEmpty()) { + return; + } + DataSyncEvent e = new DataSyncEvent(); + e.setType(type); + e.setUserIds(userIds); + eventPublisher.publishEvent(e); + } } diff --git a/src/main/java/com/yunhai/userbase/service/push/GroupScopeHelper.java b/src/main/java/com/yunhai/userbase/service/push/GroupScopeHelper.java new file mode 100644 index 0000000..a708f08 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/push/GroupScopeHelper.java @@ -0,0 +1,60 @@ +package com.yunhai.userbase.service.push; + +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.service.ISysGroupService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +/** + * 学校范围辅助:把「学校组」语义落地为可计算的范围。

+ * 语义(已与用户确认):一个组属于学校 S ⇔ 该组是学校组 S 本身,或 S 的任意后代子节点。 + * + * @author chenx + * @date 2026/8/14 + */ +@Service +public class GroupScopeHelper { + + private static final int ROOT_PARENT_ID = 0; + + @Autowired + private SysGroupRepository sysGroupRepository; + @Autowired + private ISysGroupService sysGroupService; + + /** + * 计算组所属学校范围 id:自身为学校组则取自身,否则沿 parentId 向上找最近的 SCHOOL_GROUP。 + * 无学校祖先返回 null(属于教育云中心直属,全局)。 + */ + public Integer resolveSchoolId(Integer groupId) { + if (groupId == null) { + return null; + } + SysGroup g = sysGroupRepository.findById(groupId).orElse(null); + while (g != null) { + if (g.getType() == GroupTypeEnum.SCHOOL_GROUP) { + return g.getId(); + } + if (g.getParentId() == null || g.getParentId() == ROOT_PARENT_ID) { + break; + } + g = sysGroupRepository.findById(g.getParentId()).orElse(null); + } + return null; + } + + /** + * 学校范围 = 学校组自身 + 全部子孙(复用 SysGroupService.listSelfAndDescendantIds)。 + */ + public List getSchoolScopeGroupIds(Integer schoolId) { + if (schoolId == null) { + return Collections.emptyList(); + } + return sysGroupService.listSelfAndDescendantIds(schoolId); + } +} diff --git a/src/main/java/com/yunhai/userbase/service/push/PushConsumer.java b/src/main/java/com/yunhai/userbase/service/push/PushConsumer.java new file mode 100644 index 0000000..bb84f7f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/push/PushConsumer.java @@ -0,0 +1,159 @@ +package com.yunhai.userbase.service.push; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmq.client.Channel; +import com.yunhai.userbase.config.PushMqConfig; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.push.PushRecordEntity; +import com.yunhai.userbase.model.push.PushTaskMessage; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.PushRecordRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.AmqpHeaders; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +/** + * 推送消费者(订阅方调用方):
+ * 从 push.main 串行消费,手动 ack;POST 给订阅方 Webhook,按文档契约带 t/sign 头;
+ * 每次尝试均落 push_record;失败应用层自管重试(共 3 次),耗尽进死信队列。 + * + * @author chenx + * @date 2026/8/14 + */ +@Slf4j +@Service +public class PushConsumer { + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + @Autowired + private PushRecordRepository pushRecordRepository; + @Autowired + @Qualifier("pushRabbitTemplate") + private RabbitTemplate pushRabbitTemplate; + @Autowired + private ObjectMapper objectMapper; + + @RabbitListener(queues = PushMqConfig.QUEUE_MAIN, containerFactory = "pushListenerContainerFactory") + public void onMessage(PushTaskMessage msg, Channel channel, + @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) { + long start = System.currentTimeMillis(); + String url = null; + Integer httpStatus = null; + Integer respCode = null; + String respMsg = null; + boolean ok = false; + String appName = null; + try { + ApplicationInfo app = applicationInfoRepository.findByAppKey(msg.getAppKey()); + if (app == null) { + respMsg = "订阅方不存在 appKey=" + msg.getAppKey(); + } else { + appName = app.getName(); + url = joinUrl(app.getPushBaseUrl(), msg.getInterfacePath()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("t", msg.getT()); + headers.set("sign", msg.getSign()); + int readTimeout = msg.getInterfacePath().contains("Sync") ? 60000 : 10000; + SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory(); + rf.setConnectTimeout(5000); + rf.setReadTimeout(readTimeout); + RestTemplate rt = new RestTemplate(rf); + ResponseEntity resp = rt.postForEntity(url, new HttpEntity<>(msg.getBody(), headers), String.class); + httpStatus = resp.getStatusCodeValue(); + String body = resp.getBody(); + try { + JsonNode node = objectMapper.readTree(body); + respCode = node.path("code").asInt(); + respMsg = node.path("msg").asText(); + } catch (Exception e) { + respMsg = body; + } + ok = (httpStatus != null && httpStatus == 200 && respCode != null && respCode == 0); + } + } catch (Exception e) { + respMsg = e.getMessage(); + } finally { + long cost = System.currentTimeMillis() - start; + try { + PushRecordEntity rec = new PushRecordEntity(); + rec.setSubscriberAppKey(msg.getAppKey()); + rec.setAppName(appName); + rec.setInterfacePath(msg.getInterfacePath()); + rec.setInterfaceLabel(msg.getInterfaceLabel()); + rec.setRequestUrl(url == null ? "" : url); + rec.setPayload(msg.getBody()); + rec.setSignT(msg.getT()); + rec.setHttpStatus(httpStatus); + rec.setRespCode(respCode); + rec.setRespMsg(truncate(respMsg, 500)); + rec.setSuccess(ok ? 1 : 0); + rec.setAttemptNo(msg.getAttemptNo()); + rec.setCostMs((int) cost); + rec.setTriggerType(msg.getTriggerType()); + pushRecordRepository.save(rec); + } catch (Exception dbErr) { + log.error("[推送] 落库失败", dbErr); + } + try { + if (ok) { + channel.basicAck(deliveryTag, false); + } else if (msg.getAttemptNo() < 3) { + PushTaskMessage retry = new PushTaskMessage(msg.getAppKey(), msg.getInterfacePath(), + msg.getInterfaceLabel(), msg.getBody(), msg.getT(), msg.getSign(), + msg.getAttemptNo() + 1, msg.getTriggerType()); + pushRabbitTemplate.convertAndSend(PushMqConfig.EXCHANGE_TOPIC, msg.getInterfacePath(), retry); + channel.basicAck(deliveryTag, false); + } else { + // 重试耗尽:进死信队列归档 + channel.basicNack(deliveryTag, false, false); + } + } catch (IOException io) { + log.error("[推送] ACK 失败", io); + } + } + } + + private static String truncate(String s, int max) { + if (s == null) { + return null; + } + return s.length() > max ? s.substring(0, max) : s; + } + + private static String joinUrl(String base, String path) { + if (base == null) { + return path; + } + String b = base.endsWith("/") ? base : base + "/"; + String p = path.startsWith("/") ? path.substring(1) : path; + return b + p; + } + + /** 供 {@link PushDispatcher} 共享的 URL 拼接(保持语义一致)。 */ + static String joinUrlForTest(String base, String path) { + return joinUrl(base, path); + } + + @SuppressWarnings("unused") + private static Map unused() { + return Collections.emptyMap(); + } +} diff --git a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java new file mode 100644 index 0000000..2e4bc9a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java @@ -0,0 +1,328 @@ +package com.yunhai.userbase.service.push; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.config.PushMqConfig; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.event.DataSyncEvent; +import com.yunhai.userbase.model.push.PushInterfaceType; +import com.yunhai.userbase.model.push.PushTaskMessage; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.event.TransactionPhase; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 推送分发器(生产方):
+ * ① 自动触发:监听 {@link DataSyncEvent}(AFTER_COMMIT),遍历所有开启推送的订阅方,按配置过滤后投递 MQ;
+ * ② 手动测试:{@link #dispatchManual} 供测试推送页调用。

+ * 签名在此处由基础平台计算一次(HMAC-SHA256(业务JSON+t, secret)),消息携带 t/sign,消费方原样 POST。 + * + * @author chenx + * @date 2026/8/14 + */ +@Slf4j +@Service +public class PushDispatcher { + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + @Autowired + private SysGroupRepository sysGroupRepository; + @Autowired + private SysUserRepository sysUserRepository; + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + @Autowired + private GroupScopeHelper groupScopeHelper; + @Autowired + private PushPayloadAssembler pushPayloadAssembler; + @Autowired + private ObjectMapper objectMapper; + @Autowired + @Qualifier("pushRabbitTemplate") + private RabbitTemplate pushRabbitTemplate; + + // ===================== 自动触发(AFTER_COMMIT,推送失败不影响主业务) ===================== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onDataSync(DataSyncEvent event) { + try { + PushInterfaceType type = event.getType(); + if (type == null) { + return; + } + List groups = (event.getGroupIds() != null && !event.getGroupIds().isEmpty()) + ? sysGroupRepository.findAllById(event.getGroupIds()) : Collections.emptyList(); + List users = (event.getUserIds() != null && !event.getUserIds().isEmpty()) + ? sysUserRepository.findAllById(event.getUserIds()) : Collections.emptyList(); + // 全量同步需要全部数据 + if (type == PushInterfaceType.SYNC_GROUP) { + groups = sysGroupRepository.findAll(); + } + if (type == PushInterfaceType.SYNC_USER) { + users = sysUserRepository.findAll(); + } + for (ApplicationInfo app : applicationInfoRepository.findByPushEnabled(1)) { + dispatchToOne(app, type, groups, users, "auto"); + } + } catch (Exception e) { + log.error("[推送] 分发失败(不影响主业务)", e); + } + } + + // ===================== 手动测试 ===================== + + public void dispatchManual(Integer appId, String interfacePath, List targetIds) { + ApplicationInfo app = applicationInfoRepository.findById(appId).orElse(null); + if (app == null) { + throw new DException("应用不存在"); + } + PushInterfaceType type = PushInterfaceType.getByPath(interfacePath); + if (type == null) { + throw new DException("不支持的接口路径:" + interfacePath); + } + List groups = Collections.emptyList(); + List users = Collections.emptyList(); + if (type == PushInterfaceType.SYNC_GROUP) { + groups = sysGroupRepository.findAll(); + } else if (type == PushInterfaceType.SYNC_USER) { + users = sysUserRepository.findAll(); + } else if (type.isGroup()) { + groups = (targetIds == null || targetIds.isEmpty()) + ? sysGroupRepository.findAll() : sysGroupRepository.findAllById(targetIds); + } else { + users = (targetIds == null || targetIds.isEmpty()) + ? sysUserRepository.findAll() : sysUserRepository.findAllById(targetIds); + } + dispatchToOne(app, type, groups, users, "manual"); + } + + /** + * 生成一次推送请求的预览(URL / 头 / 体),供测试推送页展示。
+ * 签名在服务端用 secret 计算,前端不持有密钥;实际投递仍走 {@link #dispatchManual} → MQ。 + */ + public Map previewManual(Integer appId, String interfacePath, List targetIds) { + ApplicationInfo app = applicationInfoRepository.findById(appId).orElse(null); + if (app == null) { + throw new DException("应用不存在"); + } + PushInterfaceType type = PushInterfaceType.getByPath(interfacePath); + if (type == null) { + throw new DException("不支持的接口路径:" + interfacePath); + } + List groups = Collections.emptyList(); + List users = Collections.emptyList(); + if (type == PushInterfaceType.SYNC_GROUP) { + groups = sysGroupRepository.findAll(); + } else if (type == PushInterfaceType.SYNC_USER) { + users = sysUserRepository.findAll(); + } else if (type.isGroup()) { + groups = (targetIds == null || targetIds.isEmpty()) + ? sysGroupRepository.findAll() : sysGroupRepository.findAllById(targetIds); + } else { + users = (targetIds == null || targetIds.isEmpty()) + ? sysUserRepository.findAll() : sysUserRepository.findAllById(targetIds); + } + List bodies = buildBodies(type, groups, users); + if (bodies.isEmpty()) { + Map empty = new LinkedHashMap<>(); + empty.put("url", joinUrl(app.getPushBaseUrl(), type.getPath())); + empty.put("method", "POST"); + empty.put("headers", buildHeaders("", "")); + empty.put("body", "{}"); + empty.put("batch", 0); + return empty; + } + try { + String body = objectMapper.writeValueAsString(bodies.get(0)); + String t = String.valueOf(System.currentTimeMillis()); + String secret = (app.getPushSecret() != null && !app.getPushSecret().isEmpty()) + ? app.getPushSecret() : app.getAppSecret(); + String sign = PushSigner.sign(body, secret, t); + Map preview = new LinkedHashMap<>(); + preview.put("url", joinUrl(app.getPushBaseUrl(), type.getPath())); + preview.put("method", "POST"); + preview.put("headers", buildHeaders(t, sign)); + preview.put("body", body); + preview.put("batch", bodies.size()); + return preview; + } catch (Exception e) { + throw new DException("预览生成失败:" + e.getMessage()); + } + } + + private static Map buildHeaders(String t, String sign) { + Map headers = new LinkedHashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("t", t); + headers.put("sign", sign); + return headers; + } + + // ===================== 核心 ===================== + + private void dispatchToOne(ApplicationInfo app, PushInterfaceType type, + List groups, List users, String triggerType) { + if (app.getPushEnabled() == null || app.getPushEnabled() != 1) { + return; + } + if (!containsEventType(app.getPushEventTypes(), type.getPath())) { + return; + } + boolean isFull = (type == PushInterfaceType.SYNC_GROUP || type == PushInterfaceType.SYNC_USER); + List fGroups = (type.isGroup() && !isFull) ? filterGroups(groups, app) : groups; + List fUsers = (type.isUser() && !isFull) ? filterUsers(users, app) : users; + + // 增量事件无匹配则跳过(全量即使为空也照常投递,供订阅方清空) + if (!isFull) { + if (type.isGroup() && fGroups.isEmpty()) { + return; + } + if (type.isUser() && fUsers.isEmpty()) { + return; + } + } + + for (Object bodyObj : buildBodies(type, fGroups, fUsers)) { + sendMessage(app, type, bodyObj, triggerType); + } + } + + /** 按接口形状构建待发送 body(删除类接口 body 形态与增改不同)。 */ + private List buildBodies(PushInterfaceType type, List groups, List users) { + switch (type) { + case SYNC_GROUP: + return Collections.singletonList(pushPayloadAssembler.buildFullGroupTree()); + case ADD_GROUP: + case UPDATE_GROUP: + return Collections.singletonList(pushPayloadAssembler.toGroupDtoList(groups)); + case DELETE_GROUP: + // 文档 3.5:json 对象 {id};每个组独立一条消息 + return groups.stream() + .map(g -> Collections.singletonMap("id", g.getId())) + .collect(Collectors.toList()); + case SYNC_USER: + return Collections.singletonList(pushPayloadAssembler.toUserDtoList(users)); + case ADD_USER: + case UPDATE_USER: + return Collections.singletonList(pushPayloadAssembler.toUserDtoList(users)); + case DELETE_USER: + // 文档 3.8:json 数组 [id,...] + return Collections.singletonList(users.stream().map(SysUser::getId).collect(Collectors.toList())); + default: + return Collections.emptyList(); + } + } + + private void sendMessage(ApplicationInfo app, PushInterfaceType type, Object bodyObj, String triggerType) { + try { + String body = objectMapper.writeValueAsString(bodyObj); + String t = String.valueOf(System.currentTimeMillis()); + String secret = (app.getPushSecret() != null && !app.getPushSecret().isEmpty()) + ? app.getPushSecret() : app.getAppSecret(); + String sign = PushSigner.sign(body, secret, t); + PushTaskMessage msg = new PushTaskMessage(app.getAppKey(), type.getPath(), type.getLabel(), + body, t, sign, 1, triggerType); + pushRabbitTemplate.convertAndSend(PushMqConfig.EXCHANGE_TOPIC, type.getPath(), msg); + log.info("[推送] 投递 appKey={} type={} trigger={}", app.getAppKey(), type.getPath(), triggerType); + } catch (Exception e) { + log.error("[推送] 消息投递失败 appKey={} type={}", app.getAppKey(), type.getPath(), e); + } + } + + // ===================== 过滤 ===================== + + private List filterGroups(List groups, ApplicationInfo app) { + Set schoolSet = parseIds(app.getPushSchoolIds()); + if (schoolSet.isEmpty()) { + return groups; + } + return groups.stream() + .filter(g -> { + Integer sid = groupScopeHelper.resolveSchoolId(g.getId()); + return sid != null && schoolSet.contains(sid); + }) + .collect(Collectors.toList()); + } + + private List filterUsers(List users, ApplicationInfo app) { + Set schoolSet = parseIds(app.getPushSchoolIds()); + Set roleSet = parseIds(app.getPushRoleIds()); + if (schoolSet.isEmpty() && roleSet.isEmpty()) { + return users; + } + return users.stream().filter(u -> { + if (!schoolSet.isEmpty()) { + List rels = sysUserGroupRelRepository.findByUserId(u.getId()); + boolean schoolMatch = rels.stream().anyMatch(r -> { + Integer sid = groupScopeHelper.resolveSchoolId(r.getGroupId()); + return sid != null && schoolSet.contains(sid); + }); + if (!schoolMatch) { + return false; + } + } + if (!roleSet.isEmpty()) { + if (u.getRoles() == null || u.getRoles().stream().noneMatch(roleSet::contains)) { + return false; + } + } + return true; + }).collect(Collectors.toList()); + } + + // ===================== 工具 ===================== + + private boolean containsEventType(String eventTypes, String path) { + if (eventTypes == null || eventTypes.trim().isEmpty()) { + return false; + } + for (String s : eventTypes.split(",")) { + if (s.trim().equals(path)) { + return true; + } + } + return false; + } + + private Set parseIds(String csv) { + Set set = new HashSet<>(); + if (csv == null || csv.trim().isEmpty()) { + return set; + } + for (String s : csv.split(",")) { + s = s.trim(); + if (!s.isEmpty()) { + try { + set.add(Integer.valueOf(s)); + } catch (NumberFormatException ignored) { + // 跳过非数字片段 + } + } + } + return set; + } + + private static String joinUrl(String base, String path) { + if (base == null) { + return path; + } + String b = base.endsWith("/") ? base : base + "/"; + String p = path.startsWith("/") ? path.substring(1) : path; + return b + p; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java b/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java new file mode 100644 index 0000000..4596df5 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java @@ -0,0 +1,186 @@ +package com.yunhai.userbase.service.push; + +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.model.push.dto.PushGroupDTO; +import com.yunhai.userbase.model.push.dto.PushUserDTO; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 领域模型 → 文档契约 DTO 的装配器。

+ * 关键映射(方案 v5 §2 / 文档 3.x): + *
    + *
  • 组 type:GroupTypeEnum.key 直接透传(1-8 零映射)
  • + *
  • 用户 status:NORMAL(0)→0正常 / DISABLED(1)→2禁用
  • + *
  • groups[].schoolId:组所属学校范围 id(沿 parentId 向上最近 SCHOOL_GROUP)
  • + *
  • roleList[].schId:用户主组所属学校范围 id(userbase 无角色-学校关联,统一取主校)
  • + *
+ * + * @author chenx + * @date 2026/8/14 + */ +@Service +public class PushPayloadAssembler { + + private static final int ROOT_PARENT_ID = 0; + private static final int IS_MAIN = 1; + + @Autowired + private SysGroupRepository sysGroupRepository; + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + @Autowired + private GroupScopeHelper groupScopeHelper; + + // ===================== 组 ===================== + + /** 组节点(不含 children)。AddGroup / UpdateGroup 使用。 */ + public PushGroupDTO toGroupDto(SysGroup g) { + if (g == null) { + return null; + } + PushGroupDTO d = new PushGroupDTO(); + d.setId(g.getId()); + d.setName(g.getName()); + // 文档 code 为「机构代码」,userbase 的 sys_group 无此属性,按可选项留空(序列化后为 null) + d.setType(g.getType() == null ? null : g.getType().getKey()); + d.setPid(g.getParentId()); + d.setNo(g.getSort()); + return d; + } + + /** 组节点 + 递归 children(SyncGroup 使用)。 */ + public PushGroupDTO toGroupDtoWithChildren(SysGroup g, Map> childrenMap) { + PushGroupDTO d = toGroupDto(g); + List children = childrenMap.get(g.getId()); + if (children != null) { + d.setChildren(children.stream() + .map(c -> toGroupDtoWithChildren(c, childrenMap)) + .collect(Collectors.toList())); + } + return d; + } + + /** 多个组(AddGroup / UpdateGroup),无 children,带 pid。 */ + public List toGroupDtoList(List groups) { + if (groups == null) { + return Collections.emptyList(); + } + return groups.stream().map(this::toGroupDto).collect(Collectors.toList()); + } + + // ===================== 用户 ===================== + + /** 单个用户 → PushUserDTO(含 groups / roleList,学校 id 已解析)。 */ + public PushUserDTO toUserDto(SysUser u) { + if (u == null) { + return null; + } + PushUserDTO d = new PushUserDTO(); + d.setId(u.getId()); + d.setAccount(u.getAccount()); + d.setName(u.getRealName()); + d.setIdcard(u.getIdcard()); + d.setPhone(u.getPhone()); + d.setEmail(u.getEmail()); + d.setGender(u.getGender()); + d.setBirthday(u.getBirthday() == null ? null : u.getBirthday().toString()); + // status:NORMAL(0)→0正常 / DISABLED(1)→2禁用 + d.setStatus(u.getDisabled() == StatusEnum.NORMAL ? 0 : 2); + d.setProfilePicture(u.getProfilePicture()); + + List rels = sysUserGroupRelRepository.findByUserId(u.getId()); + Integer mainSchoolId = resolveMainSchoolId(rels); + + // groups + List groupRefs = new ArrayList<>(); + for (SysUserGroupRel rel : rels) { + PushUserDTO.PushUserGroupRef ref = new PushUserDTO.PushUserGroupRef(); + ref.setGroupId(rel.getGroupId()); + ref.setNo(rel.getSortNo()); + ref.setSchoolId(groupScopeHelper.resolveSchoolId(rel.getGroupId())); + groupRefs.add(ref); + } + d.setGroups(groupRefs); + + // roleList:每个 roleId 一行,schId 取主组学校(userbase 无角色-学校关联) + List roleRefs = new ArrayList<>(); + if (u.getRoles() != null) { + for (Integer roleId : u.getRoles()) { + PushUserDTO.PushUserRoleRef ref = new PushUserDTO.PushUserRoleRef(); + ref.setRoleId(roleId); + ref.setSchId(mainSchoolId); + roleRefs.add(ref); + } + } + d.setRoleList(roleRefs); + return d; + } + + /** 多个用户(AddUser / UpdateUser / SyncUser)。 */ + public List toUserDtoList(List users) { + if (users == null) { + return Collections.emptyList(); + } + return users.stream().map(this::toUserDto).collect(Collectors.toList()); + } + + /** 取用户主组(isMain=1,否则首个关联)所属学校范围 id。 */ + private Integer resolveMainSchoolId(List rels) { + if (rels == null || rels.isEmpty()) { + return null; + } + Integer mainGroupId = rels.stream() + .filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN) + .map(SysUserGroupRel::getGroupId) + .findFirst() + .orElse(rels.get(0).getGroupId()); + return groupScopeHelper.resolveSchoolId(mainGroupId); + } + + // ===================== 公共工具 ===================== + + /** 构建 parentId → 直接子节点列表(null 视为根 0),同层按 sort / id 升序。 */ + public Map> buildChildrenMap(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 order = (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(order); + } + return childrenMap; + } + + /** 构建整棵组树(从根 pid=0 起),SyncGroup 全量同步入口。 */ + public List buildFullGroupTree() { + List all = sysGroupRepository.findAll(); + if (all.isEmpty()) { + return Collections.emptyList(); + } + Map> childrenMap = buildChildrenMap(all); + List roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); + return roots.stream() + .map(r -> toGroupDtoWithChildren(r, childrenMap)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java b/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java new file mode 100644 index 0000000..6388aec --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java @@ -0,0 +1,110 @@ +package com.yunhai.userbase.service.push; + +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.config.PushMqConfig; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.push.PushRecordEntity; +import com.yunhai.userbase.model.push.PushRecordQuery; +import com.yunhai.userbase.model.push.PushTaskMessage; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.PushRecordRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.criteria.Predicate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * 推送记录服务:日志查询 / 手动重试 / 定期清理(与操作日志同范式)。 + * + * @author chenx + * @date 2026/8/14 + */ +@Slf4j +@Service +public class PushRecordService { + + @Autowired + private PushRecordRepository pushRecordRepository; + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + @Autowired + @Qualifier("pushRabbitTemplate") + private RabbitTemplate pushRabbitTemplate; + + /** 分页查询(按条件过滤,默认按创建时间倒序在 Controller 层设置)。 */ + public Page query(PushRecordQuery q, Pageable pageable) { + Specification spec = (root, cq, cb) -> { + List ps = new ArrayList<>(); + if (q.getAppKey() != null) { + ps.add(cb.equal(root.get("subscriberAppKey"), q.getAppKey())); + } + if (q.getInterfacePath() != null && !q.getInterfacePath().isEmpty()) { + ps.add(cb.equal(root.get("interfacePath"), q.getInterfacePath())); + } + if (q.getSuccess() != null) { + ps.add(cb.equal(root.get("success"), q.getSuccess())); + } + if (q.getStartTime() != null && !q.getStartTime().isEmpty()) { + ps.add(cb.greaterThanOrEqualTo(root.get("createdAt"), parseStart(q.getStartTime()))); + } + if (q.getEndTime() != null && !q.getEndTime().isEmpty()) { + ps.add(cb.lessThanOrEqualTo(root.get("createdAt"), parseEnd(q.getEndTime()))); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + return pushRecordRepository.findAll(spec, pageable); + } + + /** 手动重试:用原始 payload 重新签名(新时间戳)投递 MQ,由消费者再次落库。 */ + @Transactional + public void retry(Long recordId) { + PushRecordEntity rec = pushRecordRepository.findById(recordId) + .orElseThrow(() -> new DException("推送记录不存在")); + if (rec.getSuccess() != null && rec.getSuccess() == 1) { + return; // 已成功无需重试 + } + ApplicationInfo app = applicationInfoRepository.findByAppKey(rec.getSubscriberAppKey()); + if (app == null) { + throw new DException("订阅方不存在"); + } + String secret = (app.getPushSecret() != null && !app.getPushSecret().isEmpty()) + ? app.getPushSecret() : app.getAppSecret(); + String t = String.valueOf(System.currentTimeMillis()); + String sign = PushSigner.sign(rec.getPayload(), secret, t); + PushTaskMessage msg = new PushTaskMessage(app.getAppKey(), rec.getInterfacePath(), + rec.getInterfaceLabel(), rec.getPayload(), t, sign, 1, "retry"); + pushRabbitTemplate.convertAndSend(PushMqConfig.EXCHANGE_TOPIC, rec.getInterfacePath(), msg); + } + + /** 清理 1 年前的记录(每日 3 点由 PushRecordCleanupTask 调用)。 */ + @Transactional + public long cleanupExpired() { + LocalDateTime cutoff = LocalDateTime.now().minusYears(1); + List expired = pushRecordRepository.findAll( + (root, cq, cb) -> cb.lessThan(root.get("createdAt"), cutoff)); + long n = expired.size(); + if (n > 0) { + pushRecordRepository.deleteAll(expired); + } + return n; + } + + private LocalDateTime parseStart(String s) { + return LocalDateTime.parse(s + "T00:00:00", DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + private LocalDateTime parseEnd(String s) { + return LocalDateTime.parse(s + "T23:59:59", DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } +} diff --git a/src/main/java/com/yunhai/userbase/service/push/PushSigner.java b/src/main/java/com/yunhai/userbase/service/push/PushSigner.java new file mode 100644 index 0000000..338956c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/push/PushSigner.java @@ -0,0 +1,50 @@ +package com.yunhai.userbase.service.push; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; + +/** + * 推送签名器:复刻文档 2.x 规范。

+ * 签名串 DATA2 = 业务JSON(原始字符串) + 时间戳t;
+ * sign = HMAC-SHA256(DATA2, secret),UTF-8,hex 小写。

+ * 关键:签名与发送均基于「发送方实际写出的 body 原始字符串字节」, + * 订阅方须用接收到的原始 body 验签(不得反序列化后重新序列化,否则字节不一致导致验签失败)。 + * + * @author chenx + * @date 2026/8/14 + */ +public final class PushSigner { + + private PushSigner() { + } + + /** + * 计算签名。 + * + * @param body 业务数据原始 JSON 字符串(与发送 body 完全一致) + * @param secret 签名密钥(push_secret 优先,否则 app_secret) + * @param t 时间戳(毫秒或秒,与订阅方约定一致即可;本实现直接拼接字符串) + * @return hex 小写签名 + */ + public static String sign(String body, String secret, String t) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(keySpec); + String data2 = body + t; + byte[] raw = mac.doFinal(data2.getBytes(StandardCharsets.UTF_8)); + return toHex(raw); + } catch (Exception e) { + throw new RuntimeException("推送签名失败", e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} From 8ff6e7ac0c57228fe8eb36693c4365a682f3298e Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 14 Aug 2026 09:05:34 +0800 Subject: [PATCH 37/58] =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/config/PushMqConfig.java | 102 ++++++++++++++++++ .../userbase/config/PushTaskClassMapper.java | 32 ++++++ .../yunhai/userbase/event/DataSyncEvent.java | 25 +++++ .../model/application/PushSettingRequest.java | 30 ++++++ .../model/application/PushSettingVo.java | 24 +++++ .../model/push/PushInterfaceType.java | 49 +++++++++ .../userbase/model/push/PushRecordEntity.java | 74 +++++++++++++ .../userbase/model/push/PushRecordQuery.java | 24 +++++ .../userbase/model/push/PushTaskMessage.java | 38 +++++++ .../userbase/model/push/PushTestRequest.java | 22 ++++ .../userbase/model/push/dto/PushGroupDTO.java | 32 ++++++ .../userbase/model/push/dto/PushUserDTO.java | 53 +++++++++ .../repository/PushRecordRepository.java | 16 +++ .../userbase/task/PushRecordCleanupTask.java | 32 ++++++ 14 files changed, 553 insertions(+) create mode 100644 src/main/java/com/yunhai/userbase/config/PushMqConfig.java create mode 100644 src/main/java/com/yunhai/userbase/config/PushTaskClassMapper.java create mode 100644 src/main/java/com/yunhai/userbase/event/DataSyncEvent.java create mode 100644 src/main/java/com/yunhai/userbase/model/application/PushSettingRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/application/PushSettingVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/push/PushInterfaceType.java create mode 100644 src/main/java/com/yunhai/userbase/model/push/PushRecordEntity.java create mode 100644 src/main/java/com/yunhai/userbase/model/push/PushRecordQuery.java create mode 100644 src/main/java/com/yunhai/userbase/model/push/PushTaskMessage.java create mode 100644 src/main/java/com/yunhai/userbase/model/push/PushTestRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/push/dto/PushGroupDTO.java create mode 100644 src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java create mode 100644 src/main/java/com/yunhai/userbase/repository/PushRecordRepository.java create mode 100644 src/main/java/com/yunhai/userbase/task/PushRecordCleanupTask.java diff --git a/src/main/java/com/yunhai/userbase/config/PushMqConfig.java b/src/main/java/com/yunhai/userbase/config/PushMqConfig.java new file mode 100644 index 0000000..2be5b9a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/PushMqConfig.java @@ -0,0 +1,102 @@ +package com.yunhai.userbase.config; + +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.yunhai.userbase.model.push.PushTaskMessage; + +import java.util.HashMap; +import java.util.Map; + +/** + * 推送订阅 MQ 配置。

+ * 拓扑: + *
+ *   push.topic (topic, durable)
+ *     └─ push.main (durable, concurrency=1, x-dead-letter-exchange=push.dlx)   ← 串行消费,可观测
+ *   push.dlx (fanout, durable)
+ *     └─ push.dlq (durable)                                                    ← 死信归档(重试耗尽)
+ * 
+ * 连接信息(host/port/user/pwd/vhost)由 Nacos {@code application-infra.yaml} 的 {@code spring.rabbitmq.*} + * 公共配置注入,本类不写死(也不改 bootstrap.yml / application.yml)。
+ * 可靠性:消费端手动 ack,失败由 {@code PushConsumer} 应用层自管重试(共 3 次),耗尽进死信队列。 + * + * @author chenx + * @date 2026/8/14 + */ +@Configuration +public class PushMqConfig { + + public static final String EXCHANGE_TOPIC = "push.topic"; + public static final String EXCHANGE_DLX = "push.dlx"; + public static final String QUEUE_MAIN = "push.main"; + public static final String QUEUE_DLQ = "push.dlq"; + + /** 统一 JSON 消息转换器(固定反序列化为 PushTaskMessage,绕过 trusted-packages 版本差异)。 */ + @Bean + public MessageConverter pushMessageConverter() { + Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(); + converter.setClassMapper(new PushTaskClassMapper(PushTaskMessage.class)); + return converter; + } + + @Bean + public TopicExchange pushTopicExchange() { + return new TopicExchange(EXCHANGE_TOPIC, true, false); + } + + @Bean + public FanoutExchange pushDlxExchange() { + return new FanoutExchange(EXCHANGE_DLX, true, false); + } + + /** 主队列:单消费者串行 + 死信路由到 push.dlx。 */ + @Bean + public Queue pushMainQueue() { + Map args = new HashMap<>(); + args.put("x-dead-letter-exchange", EXCHANGE_DLX); + return new Queue(QUEUE_MAIN, true, false, false, args); + } + + /** 死信队列:归档重试耗尽的任务。 */ + @Bean + public Queue pushDlqQueue() { + return new Queue(QUEUE_DLQ, true, false, false); + } + + @Bean + public Binding pushMainBinding() { + return BindingBuilder.bind(pushMainQueue()).to(pushTopicExchange()).with("#"); + } + + @Bean + public Binding pushDlqBinding() { + return BindingBuilder.bind(pushDlqQueue()).to(pushDlxExchange()); + } + + @Bean + public RabbitTemplate pushRabbitTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(pushMessageConverter()); + return template; + } + + /** 消费者容器:手动 ack + 单并发 + prefetch=1,保证串行可观测。 */ + @Bean(name = "pushListenerContainerFactory") + public SimpleRabbitListenerContainerFactory pushListenerContainerFactory(ConnectionFactory connectionFactory) { + SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); + factory.setConnectionFactory(connectionFactory); + factory.setMessageConverter(pushMessageConverter()); + factory.setAcknowledgeMode(org.springframework.amqp.core.AcknowledgeMode.MANUAL); + factory.setConcurrentConsumers(1); + factory.setMaxConcurrentConsumers(1); + factory.setPrefetchCount(1); + return factory; + } +} diff --git a/src/main/java/com/yunhai/userbase/config/PushTaskClassMapper.java b/src/main/java/com/yunhai/userbase/config/PushTaskClassMapper.java new file mode 100644 index 0000000..fce04f8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/PushTaskClassMapper.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.config; + +import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.support.converter.ClassMapper; + +/** + * 推送消息固定反序列化为 {@link PushTaskMessage},绕过 Jackson2JsonMessageConverter 的 + * trusted-packages 安全限制(不同 spring-amqp 版本该方法签名/默认值不一致,编译期不可靠)。
+ * 生产者端序列化仍由 Jackson2JsonMessageConverter 完成(写入 __TypeId__),消费者端忽略 __TypeId__ + * 直接按本映射器返回 PushTaskMessage,类型单一且可控。 + * + * @author chenx + * @date 2026/8/14 + */ +public class PushTaskClassMapper implements ClassMapper { + + private final Class payloadClass; + + public PushTaskClassMapper(Class payloadClass) { + this.payloadClass = payloadClass; + } + + @Override + public Class toClass(MessageProperties properties) { + return payloadClass; + } + + @Override + public void fromClass(Class clazz, MessageProperties properties) { + properties.setHeader("__TypeId__", clazz.getName()); + } +} diff --git a/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java b/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java new file mode 100644 index 0000000..8e415cb --- /dev/null +++ b/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.event; + +import com.yunhai.userbase.model.push.PushInterfaceType; +import lombok.Data; + +import java.util.List; + +/** + * 数据同步事件:组 / 用户变更后由 {@code SysGroupService}/{@code SysUserService} 在事务内 publish, + * {@code PushDispatcher} 用 {@code @TransactionalEventListener(AFTER_COMMIT)} 消费(事务提交后才推送, + * 推送失败不影响主业务)。 + * + * @author chenx + * @date 2026/8/14 + */ +@Data +public class DataSyncEvent { + + /** 接口类型,如 group/AddGroup */ + private PushInterfaceType type; + /** 组事件携带的受影响组 id 列表 */ + private List groupIds; + /** 用户事件携带的受影响用户 id 列表 */ + private List userIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/application/PushSettingRequest.java b/src/main/java/com/yunhai/userbase/model/application/PushSettingRequest.java new file mode 100644 index 0000000..a6d58a4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/application/PushSettingRequest.java @@ -0,0 +1,30 @@ +package com.yunhai.userbase.model.application; + +import lombok.Data; + +/** + * 保存应用推送设置请求(应用列表「推送设置」按钮提交)。
+ * 所有字段均为可选更新:传 null 表示不修改该项。 + * + * @author chenx + * @date 2026/8/14 + */ +@Data +public class PushSettingRequest { + + /** 推送外部接口地址(不含固定后缀) */ + private String pushBaseUrl; + /** 推送总开关:0关 1开 */ + private Integer pushEnabled; + /** 启用的接口路径,逗号分隔 */ + private String pushEventTypes; + /** 关联学校组 id,逗号分隔;空=全局 */ + private String pushSchoolIds; + /** 推送角色 key,逗号分隔;空=全部角色 */ + private String pushRoleIds; + /** + * 推送签名密钥;传 null 表示不修改(保留现有值), + * 传空串 "" 表示清空(后续复用 appSecret)。 + */ + private String pushSecret; +} diff --git a/src/main/java/com/yunhai/userbase/model/application/PushSettingVo.java b/src/main/java/com/yunhai/userbase/model/application/PushSettingVo.java new file mode 100644 index 0000000..38ce68f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/application/PushSettingVo.java @@ -0,0 +1,24 @@ +package com.yunhai.userbase.model.application; + +import lombok.Data; + +/** + * 应用推送设置回显 VO(GetPushSetting 返回,仅含推送相关字段,不含 appSecret 等无关大字段)。 + * + * @author chenx + * @date 2026/8/14 + */ +@Data +public class PushSettingVo { + + private Integer id; + private Integer appKey; + private String name; + private String pushBaseUrl; + private Integer pushEnabled; + private String pushEventTypes; + private String pushSchoolIds; + private String pushRoleIds; + /** 推送签名密钥(可展示供复制;空=复用 appSecret) */ + private String pushSecret; +} diff --git a/src/main/java/com/yunhai/userbase/model/push/PushInterfaceType.java b/src/main/java/com/yunhai/userbase/model/push/PushInterfaceType.java new file mode 100644 index 0000000..61b45f7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/push/PushInterfaceType.java @@ -0,0 +1,49 @@ +package com.yunhai.userbase.model.push; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文档约定的 8 个订阅接口:path(固定后缀,拼在订阅方 base URL 后)↔ 友好名 label。 + * + * @author chenx + * @date 2026/8/14 + */ +@Getter +@AllArgsConstructor +public enum PushInterfaceType { + + SYNC_GROUP("group/SyncGroup", "组全量同步"), + SYNC_USER("user/SyncUser", "用户全量同步"), + ADD_GROUP("group/AddGroup", "添加组"), + UPDATE_GROUP("group/UpdateGroup", "更新组"), + DELETE_GROUP("group/DeleteGroup", "删除组"), + ADD_USER("user/AddUser", "添加用户"), + UPDATE_USER("user/UpdateUser", "更新用户"), + DELETE_USER("user/DeleteUser", "删除用户"); + + private final String path; + private final String label; + + public static PushInterfaceType getByPath(String path) { + if (path == null) { + return null; + } + for (PushInterfaceType type : values()) { + if (type.path.equals(path)) { + return type; + } + } + return null; + } + + /** 是否为组相关接口。 */ + public boolean isGroup() { + return path.startsWith("group/"); + } + + /** 是否为用户相关接口。 */ + public boolean isUser() { + return path.startsWith("user/"); + } +} diff --git a/src/main/java/com/yunhai/userbase/model/push/PushRecordEntity.java b/src/main/java/com/yunhai/userbase/model/push/PushRecordEntity.java new file mode 100644 index 0000000..9017d6a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/push/PushRecordEntity.java @@ -0,0 +1,74 @@ +package com.yunhai.userbase.model.push; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import javax.persistence.*; +import java.time.LocalDateTime; + +/** + * 推送订阅记录实体(订阅方维度,落库用于日志 / 重试 / 排查)。
+ * 与 DDL(push_subscription_ddl.sql §2)的 push_record 表一一对应。 + * + * @author chenx + * @date 2026/8/14 + */ +@Data +@NoArgsConstructor +@Entity +@Table(name = "push_record") +public class PushRecordEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "subscriber_app_key", nullable = false) + private Integer subscriberAppKey; + + @Column(name = "app_name") + private String appName; + + @Column(name = "interface_path", nullable = false) + private String interfacePath; + + @Column(name = "interface_label") + private String interfaceLabel; + + @Column(name = "request_url", nullable = false) + private String requestUrl; + + @Column(name = "payload", columnDefinition = "MEDIUMTEXT") + private String payload; + + @Column(name = "sign_t") + private String signT; + + @Column(name = "http_status") + private Integer httpStatus; + + @Column(name = "resp_code") + private Integer respCode; + + @Column(name = "resp_msg") + private String respMsg; + + /** 是否成功:0失败 1成功 */ + @Column(name = "success", nullable = false) + private Integer success = 0; + + @Column(name = "attempt_no", nullable = false) + private Integer attemptNo = 1; + + @Column(name = "cost_ms") + private Integer costMs; + + /** 触发类型:manual / auto / retry */ + @Column(name = "trigger_type") + private String triggerType; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/yunhai/userbase/model/push/PushRecordQuery.java b/src/main/java/com/yunhai/userbase/model/push/PushRecordQuery.java new file mode 100644 index 0000000..ffa1240 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/push/PushRecordQuery.java @@ -0,0 +1,24 @@ +package com.yunhai.userbase.model.push; + +import lombok.Data; + +/** + * 推送记录查询条件(前端日志页搜索用)。 + * + * @author chenx + * @date 2026/8/14 + */ +@Data +public class PushRecordQuery { + + /** 订阅方 appKey */ + private Integer appKey; + /** 接口路径,如 group/AddGroup */ + private String interfacePath; + /** 是否成功:0失败 1成功 */ + private Integer success; + /** 起始时间 yyyy-MM-dd(含当天 00:00:00) */ + private String startTime; + /** 结束时间 yyyy-MM-dd(含当天 23:59:59) */ + private String endTime; +} diff --git a/src/main/java/com/yunhai/userbase/model/push/PushTaskMessage.java b/src/main/java/com/yunhai/userbase/model/push/PushTaskMessage.java new file mode 100644 index 0000000..744d641 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/push/PushTaskMessage.java @@ -0,0 +1,38 @@ +package com.yunhai.userbase.model.push; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * MQ 推送任务消息。
+ * 由 {@code PushDispatcher}(生产方)投递,{@code PushConsumer}(消费方)取出后 POST 给订阅方。
+ * 签名(t + sign)在投递时由基础平台计算一次;自动重试保持原 t/sign,手动重试重新生成。 + * + * @author chenx + * @date 2026/8/14 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PushTaskMessage implements Serializable { + + /** 订阅方 appKey */ + private Integer appKey; + /** 接口路径,如 group/AddGroup */ + private String interfacePath; + /** 接口友好名 */ + private String interfaceLabel; + /** 业务数据原始 JSON 字符串(签名与发送均基于该原始字符串字节) */ + private String body; + /** 签名时间戳(字符串) */ + private String t; + /** HMAC-SHA256(业务JSON + t, secret) hex 小写 */ + private String sign; + /** 第几次尝试:1=首次 */ + private int attemptNo; + /** 触发类型:manual / auto / retry */ + private String triggerType; +} diff --git a/src/main/java/com/yunhai/userbase/model/push/PushTestRequest.java b/src/main/java/com/yunhai/userbase/model/push/PushTestRequest.java new file mode 100644 index 0000000..4859229 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/push/PushTestRequest.java @@ -0,0 +1,22 @@ +package com.yunhai.userbase.model.push; + +import lombok.Data; + +import java.util.List; + +/** + * 测试推送请求(测试推送页调用)。 + * + * @author chenx + * @date 2026/8/14 + */ +@Data +public class PushTestRequest { + + /** 订阅方应用 id */ + private Integer appId; + /** 接口路径,如 group/AddGroup */ + private String interfacePath; + /** 目标 id 列表(可选):不传则按接口类型取全量/全部 */ + private List targetIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/push/dto/PushGroupDTO.java b/src/main/java/com/yunhai/userbase/model/push/dto/PushGroupDTO.java new file mode 100644 index 0000000..06cd21a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/push/dto/PushGroupDTO.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.model.push.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 文档「组」契约 DTO(group/SyncGroup、AddGroup、UpdateGroup 共用)。
+ * 字段严格对齐《弘育基础平台数据同步接口 2》: + *
    + *
  • SyncGroup:children 递归树(可空)
  • + *
  • AddGroup:pid 必填(上级组 id,0 为顶级)
  • + *
  • UpdateGroup:仅 id 必填,其余可选
  • + *
+ * + * @author chenx + * @date 2026/8/14 + */ +@Data +public class PushGroupDTO { + + private Integer id; + private String name; + private String code; + /** 组类别 1-8,与 GroupTypeEnum 1:1 零映射 */ + private Integer type; + /** 上级组 id(AddGroup 必填) */ + private Integer pid; + private Integer no; + /** 子组列表(SyncGroup 用) */ + private List children; +} diff --git a/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java b/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java new file mode 100644 index 0000000..697ca4b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java @@ -0,0 +1,53 @@ +package com.yunhai.userbase.model.push.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 文档「用户」契约 DTO(user/SyncUser、AddUser、UpdateUser 共用)。
+ * 字段严格对齐《弘育基础平台数据同步接口 2》: + *
    + *
  • status:0正常 / 2禁用(由 userbase StatusEnum 映射)
  • + *
  • groups:用户所属组,含 groupId / no / schoolId(schoolId = 组所属学校范围 id)
  • + *
  • roleList:用户身份,含 roleId / schId(schId = 用户主组所属学校范围 id)
  • + *
+ * + * @author chenx + * @date 2026/8/14 + */ +@Data +public class PushUserDTO { + + private Integer id; + private String account; + private String name; + private String idcard; + private String phone; + private String email; + /** 性别 0女 1男 */ + private Integer gender; + private String birthday; + /** 用户状态 0正常 2禁用 */ + private Integer status; + private String profilePicture; + private List groups; + private List roleList; + + /** 用户所属组引用。 */ + @Data + public static class PushUserGroupRef { + private Integer groupId; + private Integer no; + /** 组所属学校范围 id(沿 parentId 向上最近的 SCHOOL_GROUP) */ + private Integer schoolId; + } + + /** 用户身份引用。 */ + @Data + public static class PushUserRoleRef { + private Integer roleId; + /** 所属学校 id(userbase 无角色-学校关联,统一取用户主组所属学校) */ + private Integer schId; + } +} diff --git a/src/main/java/com/yunhai/userbase/repository/PushRecordRepository.java b/src/main/java/com/yunhai/userbase/repository/PushRecordRepository.java new file mode 100644 index 0000000..65a4eaa --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/PushRecordRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.model.push.PushRecordEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +/** + * 推送记录仓储。 + * + * @author chenx + * @date 2026/8/14 + */ +@Repository +public interface PushRecordRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/src/main/java/com/yunhai/userbase/task/PushRecordCleanupTask.java b/src/main/java/com/yunhai/userbase/task/PushRecordCleanupTask.java new file mode 100644 index 0000000..d523fdd --- /dev/null +++ b/src/main/java/com/yunhai/userbase/task/PushRecordCleanupTask.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.task; + +import com.yunhai.userbase.service.push.PushRecordService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 推送日志定时清理任务:每日凌晨 3 点清理 1 年前的推送记录。
+ * 与 {@code OperationLogCleanupTask} 同范式(@EnableScheduling 已在 UserBaseApplication 开启)。 + * + * @author chenx + * @date 2026/8/14 + */ +@Slf4j +@Component +public class PushRecordCleanupTask { + + @Autowired + private PushRecordService pushRecordService; + + @Scheduled(cron = "0 0 3 * * ?") + public void run() { + try { + long deleted = pushRecordService.cleanupExpired(); + log.info("推送日志定时清理完成, 删除{}条", deleted); + } catch (Exception e) { + log.error("推送日志定时清理失败", e); + } + } +} From 236c9545f91a32ce75d705157a00c3c5095d6b1e Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 14 Aug 2026 09:21:53 +0800 Subject: [PATCH 38/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/config/CacheConfig.java | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/config/CacheConfig.java b/src/main/java/com/yunhai/userbase/config/CacheConfig.java index 4399a87..444784a 100644 --- a/src/main/java/com/yunhai/userbase/config/CacheConfig.java +++ b/src/main/java/com/yunhai/userbase/config/CacheConfig.java @@ -4,7 +4,9 @@ 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.SerializationFeature; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.github.benmanes.caffeine.cache.Caffeine; import com.yunhai.userbase.utils.ConstantUtil; import org.springframework.cache.Cache; @@ -40,13 +42,7 @@ public class CacheConfig { @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); + jacksonSerializer.setObjectMapper(newCacheObjectMapper()); RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(ConstantUtil.CACHE_TTL) // 全局 5 分钟过期 @@ -173,11 +169,21 @@ public class CacheConfig { /** 统一构建带类型信息的 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); + jackson.setObjectMapper(newCacheObjectMapper()); return jackson; } + + /** 构建缓存专用 ObjectMapper:类型信息 + 可见性 + Java 8 时间类型支持。 */ + private ObjectMapper newCacheObjectMapper() { + 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); + // 支持 Java 8 时间类型(LocalDateTime 等),否则缓存含时间字段的实体(如 ApplicationInfo.createTime)会序列化失败 + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } } From 87163ba9447cec78d0142a13cc773fc93aaf430f Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 14 Aug 2026 15:20:21 +0800 Subject: [PATCH 39/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9modules=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/oapi/UserController.java | 99 ++++--- .../userbase/service/IGroupAuthzService.java | 17 ++ .../service/impl/GroupAuthzService.java | 245 ++++++++++++++++-- 3 files changed, 303 insertions(+), 58 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index dacc9b4..52e265d 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -1,22 +1,24 @@ package com.yunhai.userbase.controller.oapi; +import com.yunhai.platform.appauth.AppAuthFilter; +import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.config.Config; -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.entity.ApplicationInfo; +import com.yunhai.userbase.repository.ApplicationInfoRepository; import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.impl.UserCenterService; -import org.springframework.beans.BeanUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; 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; -import java.util.ArrayList; +import javax.servlet.http.HttpServletRequest; import java.util.Collections; import java.util.List; @@ -34,12 +36,17 @@ import java.util.List; @RequestMapping(Config.OPEN_API_PATH + "user/") public class UserController { + private static final Logger log = LoggerFactory.getLogger(UserController.class); + @Autowired private UserCenterService userCenterService; @Autowired private IGroupAuthzService groupAuthzService; + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + @GetMapping("byId") public BoaUser byId(@RequestParam("id") Integer id) { return userCenterService.getUserInfoWithDeptById(id); @@ -62,60 +69,72 @@ public class UserController { } /** - * 查询用户在指定学校下的已授权模块列表。
- * 若 {@code schId} 为 null 时,自动取用户的主学校;若仍无主学校信息,退化为取用户主归属组(groups 第 0 个)。 + * 查询用户在调用方应用下、指定学校(上下文)里的已授权模块列表。
+ *
+ * 与管理端「权限一览」返回全应用聚合不同,本方法严格按调用方应用维度过滤:
+ * ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter + * 已保证非空);
+ * ② 查 application_info 映射到具体 appId;
+ * ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。
+ *
+ * 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。
* - * @param userId 用户ID(必填) - * @param schId 学校ID/组ID(可选;为 null 时按上述规则推断) - * @return 扁平化的已授权模块列表(含基础平台统一 BoaModule 模型);无权限时返回空列表 + * @param request 用于取 AppAuthFilter 设置的 appKey + * @param userId 用户ID(必填) + * @param schId 学校ID/组ID(可选;为 null 时按"主学校→主归属组"规则推断) + * @return 该应用下用户已授权模块(扁平化;无权限时返回空列表) */ @GetMapping("modules") - public List modules(@RequestParam("userId") Integer userId, + public List modules(HttpServletRequest request, + @RequestParam("userId") Integer userId, @RequestParam(value = "schId", required = false) Integer schId) { if (userId == null) { return Collections.emptyList(); } - Integer effectiveGroupId = resolveEffectiveGroupId(userId, schId); - // 注意:超管即使没有学校/归属组,service.findUserPermissionTree 内部也会走超管短路返回全部权限, - // 因此这里不能因 effectiveGroupId 为 null 直接返回空,否则超管在 oapi/user/modules 永远拿不到权限; - // 普通用户无组/无学校时,findUserPermissionTree 同样会返回空列表,行为一致。 - List tree = groupAuthzService.findUserPermissionTree(effectiveGroupId, userId); - if (tree == null || tree.isEmpty()) { + Integer appId = resolveCallerAppId(request); + if (appId == null) { return Collections.emptyList(); } - List result = new ArrayList<>(); - for (UserPermissionCategoryVo category : tree) { - if (category.getApps() == null) - continue; - for (UserPermissionAppVo appVo : category.getApps()) { - String icon = appVo.getPcIcon(); - collectModules(appVo.getModules(), icon, result); - } - } - return result; + return groupAuthzService.findUserModulesForApp(appId, schId, userId); } /** - * 递归收集已授权模块树到扁平列表,并把应用 PC 图标作为模块的 icon 兜底(BoaModule.icon)。 + * 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id。
+ * 映射失败返回 null(Controller 层统一返回空列表;异常只打 warn 日志,不给前端 500)。 */ - private void collectModules(List modules, - String appIcon, - List collector) { - if (modules == null || modules.isEmpty()) { - return; + private Integer resolveCallerAppId(HttpServletRequest request) { + Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + if (attr == null) { + log.warn("[oapi:user:modules] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}", + request.getRequestURI()); + return null; } - for (UserPermissionModuleVo m : modules) { - BoaModule bm = new BoaModule(); - BeanUtils.copyProperties(m, bm); - bm.setIcon(appIcon); - collector.add(bm); - collectModules(m.getChildren(), appIcon, collector); + String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr); + if (appKeyStr.trim().isEmpty()) { + log.warn("[oapi:user:modules] AppAuth attr is blank, uri={}", request.getRequestURI()); + return null; } + Integer appKey; + try { + appKey = Integer.parseInt(appKeyStr.trim()); + } catch (NumberFormatException e) { + log.warn("[oapi:user:modules] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI()); + return null; + } + ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey); + if (app == null) { + log.warn("[oapi:user:modules] appKey={} not found in application_info, uri={}", appKey, + request.getRequestURI()); + return null; + } + return app.getId(); } /** - * 按「schId 为空时」解析出用户主学校/主组作为模块查询的上下文组ID。 + * 按「schId 为空时」解析出用户主学校/主组作为模块查询的上下文组ID。
+ * 保留方法(逻辑已下沉到 Service.resolveEffectiveGroupId 复用),不再被 modules() 使用。 */ + @Deprecated private Integer resolveEffectiveGroupId(Integer userId, Integer schId) { if (schId != null) { return schId; diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java index d12a66c..09086d7 100644 --- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -115,6 +115,23 @@ public interface IGroupAuthzService { */ List findUserGroupTree(Integer userId); + /** + * 开放接口专用:查询用户在指定调用方应用下的已授权模块(扁平化 BoaModule 列表)。
+ * 与 {@link #findUserPermissionTree(Integer, Integer)} 的核心差别:
+ * - 后者返回用户在全部已开通应用下的权限聚合(供管理端权限一览使用);
+ * - 本方法只返回 {@code appId} 对应这一个应用下的模块,所有 SQL/集合构建均限定该应用(数据库层面过滤)。
+ *
+ * 上下文组(schId)语义与开放接口一致:schId 非空=只认该组链路;schId 为空=取用户主学校组(若仍空=用户主归属组 id)。
+ * 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。
+ * 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。 + * + * @param appId 调用方应用ID(必填;查不到或该应用无开通=返回空列表) + * @param schId 学校/组上下文(可选;为 null 时按"主学校→主归属组"规则推断) + * @param userId 用户ID(必填) + * @return 该应用下用户有权限的模块(扁平化;无权限时返回空列表) + */ + List findUserModulesForApp(Integer appId, Integer schId, Integer userId); + // ===================== 新增:树节点式增量授权(供左侧树 + 右侧 appAuthzList 的勾选交互) ===================== /** diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index fc4c51d..471df81 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -1,6 +1,9 @@ package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.entity.*; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.userbase.enums.IEnum; @@ -32,6 +35,7 @@ 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 com.yunhai.userbase.service.impl.UserCenterService; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.jpa.domain.Specification; @@ -100,6 +104,9 @@ public class GroupAuthzService implements IGroupAuthzService { @Autowired private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + @Autowired + private UserCenterService userCenterService; + // ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId) // ===================== @@ -493,6 +500,200 @@ public class GroupAuthzService implements IGroupAuthzService { return Collections.singletonList(moduleId); } + // ===================== 开放接口专用:单应用维度模块过滤 ===================== + + @Override + public List findUserModulesForApp(Integer appId, Integer schId, Integer userId) { + Objects.requireNonNull(appId, "应用ID不能为空"); + Objects.requireNonNull(userId, "用户ID不能为空"); + // 1. 调用方应用本身存在性校验(数据库过滤第一层:appId 必须真实存在,否则直接空) + ApplicationInfo app = applicationInfoRepository.findById(appId).orElse(null); + if (app == null) { + return Collections.emptyList(); + } + String appIcon = app.getPcIcon(); + SysUser user = sysUserRepository.findById(userId).orElse(null); + if (user == null) { + return Collections.emptyList(); + } + // 2. 超级管理员短路:不再返回全应用模块=仅返回该应用下全部模块(数据库过滤第二层:appId 限定) + if (isSuperAdmin(user.getType(), userId)) { + List allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); + return flattenToBoaModules(allModules, appIcon); + } + // 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用) + Set roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId); + // 4. 上下文组解析(schId 非空=只认这一条链路;schId 空=按"主学校→主归属组"规则推断) + Integer effectiveGroupId = resolveEffectiveGroupId(userId, schId); + // 5. 若上下文组有效=构建其祖先链,并判定:该祖先链开通池里是否包含目标 appId(应用开通闸门) + // 上下文组无效(用户无学校/无组)时,仍可能因角色授权而返回模块,不能直接返回空 + Set selfGroupIds = new LinkedHashSet<>(); + Map allGroupMap = buildAllGroupMap(); + Map> chainMap = new HashMap<>(); + Set allChainIds = new LinkedHashSet<>(); + Map> groupToApps = new HashMap<>(); + boolean appOpened = false; + if (effectiveGroupId != null) { + selfGroupIds.add(effectiveGroupId); + List chain = ancestorChain(effectiveGroupId, allGroupMap); + chainMap.put(effectiveGroupId, chain); + allChainIds.addAll(chain); + Set openedApps = collectAppIdsForAuthzScope(effectiveGroupId); + groupToApps.put(effectiveGroupId, openedApps); + if (openedApps.contains(appId)) { + appOpened = true; + } + } + // 6. 数据库过滤第三层:只加载目标应用下的模块 + List appModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); + if (appModules.isEmpty()) { + return (roleModuleIdsInApp.isEmpty()) ? Collections.emptyList() + : flattenToBoaModulesById(roleModuleIdsInApp, appIcon, buildAppModuleMap(appModules)); + } + Set appModuleIds = new LinkedHashSet<>(); + for (AppModule m : appModules) { + appModuleIds.add(m.getId()); + } + // 7. 数据库过滤第四层:只查「目标应用模块 × 祖先链」上的组授权记录 + Map> modulePermMap = new HashMap<>(); + Map> groupConfiguredApps = new HashMap<>(); + if (appOpened && !appModuleIds.isEmpty() && !allChainIds.isEmpty()) { + List perms = sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn( + new ArrayList<>(appModuleIds), 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); + groupConfiguredApps.computeIfAbsent(perm.getGroupId(), k -> new HashSet<>()).add(appId); + } + } + // 8. 权限判定合并:组授权 ∪ 角色授权(均属于该应用) + Set permittedModuleIds = new LinkedHashSet<>(); + for (Integer mid : roleModuleIdsInApp) { + if (appModuleIds.contains(mid)) { + permittedModuleIds.add(mid); + } + } + if (appOpened) { + for (AppModule m : appModules) { + if (permittedModuleIds.contains(m.getId())) { + continue; + } + if (hasUserModulePermission(m.getId(), m.getAppId(), selfGroupIds, chainMap, groupToApps, + modulePermMap, allGroupMap, groupConfiguredApps)) { + permittedModuleIds.add(m.getId()); + } + } + } + if (permittedModuleIds.isEmpty()) { + return Collections.emptyList(); + } + return flattenToBoaModulesById(permittedModuleIds, appIcon, buildAppModuleMap(appModules)); + } + + /** + * 收集用户启用角色下=且属于目标应用 appId 的模块ID集合(角色授权并集专用的"应用过滤版")。
+ * 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块; + * 本方法在其之上再按模块.appId == appId 过滤一次(不额外发 SQL,遍历两次即可)。 + */ + private Set collectEnabledRoleModuleIdsForApp(Integer userId, Integer appId) { + Set allRoleModuleIds = collectEnabledRoleModuleIds(userId); + if (allRoleModuleIds.isEmpty() || appId == null) { + return Collections.emptySet(); + } + Set result = new LinkedHashSet<>(); + for (AppModule m : appModuleRepository.findAllById(allRoleModuleIds)) { + if (Objects.equals(m.getAppId(), appId) && m.getId() != null) { + result.add(m.getId()); + } + } + return result; + } + + /** + * 上下文组解析(与开放接口 modules 语义严格一致): + * - schId 非空:直接用其作为唯一 selfGroupId; + * - schId 为空:取用户主学校组(BoaUser.school.id); + * - 仍空:取用户主归属组 id(groups 第 0 个); + * - 全空:返回 null(此时只能靠角色授权拿模块)。 + */ + private Integer resolveEffectiveGroupId(Integer userId, Integer schId) { + if (schId != null) { + return schId; + } + BoaUser boa = userCenterService.getUserInfoWithDeptById(userId); + if (boa == null) { + return null; + } + BoaGroup school = boa.getSchool(); + if (school != null && school.getId() != null) { + return school.getId(); + } + List groups = boa.getGroups(); + if (groups != null && !groups.isEmpty()) { + BoaGroup main = groups.get(0); + return main == null ? null : main.getId(); + } + return null; + } + + private static Map buildAppModuleMap(List appModules) { + Map map = new HashMap<>(); + for (AppModule m : appModules) { + if (m.getId() != null) { + map.put(m.getId(), m); + } + } + return map; + } + + private static List flattenToBoaModules(List modules, String icon) { + if (modules == null || modules.isEmpty()) { + return Collections.emptyList(); + } + modules.sort(moduleOrder()); + List result = new ArrayList<>(modules.size()); + for (AppModule m : modules) { + result.add(toBoaModule(m, icon)); + } + return result; + } + + private static List flattenToBoaModulesById(Set moduleIds, String icon, + Map moduleMap) { + if (moduleIds == null || moduleIds.isEmpty()) { + return Collections.emptyList(); + } + List list = new ArrayList<>(moduleIds.size()); + for (Integer mid : moduleIds) { + AppModule m = moduleMap.get(mid); + if (m != null) { + list.add(m); + } + } + return flattenToBoaModules(list, icon); + } + + private static BoaModule toBoaModule(AppModule m, String icon) { + BoaModule bm = new BoaModule(); + BeanUtils.copyProperties(m, bm); + bm.setIcon(icon); + return bm; + } + + private static Comparator moduleOrder() { + 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 @@ -605,14 +806,14 @@ public class GroupAuthzService implements IGroupAuthzService { Set roleModuleIds = collectEnabledRoleModuleIds(userId); // 1. 归属组集合:区分「组上下文模式」和「用户全局模式」 - // A) 接口显式传了 groupId(= 管理员在"某个组的授权面板"点用户权限一览,是典型的组视角查询): - // 以传入的 groupId 作为唯一 selfGroupId,仅沿「该组 -> 祖先 -> 第一个学校组(含)」链路判定; - // 【关键】不再把用户真实加入的其他组(例如用户还属于学校组)并入判断。 - // 原因:如果允许用户归属的"学校组"作为独立 selfGroupId 参与本组显式判定, - // 则子组未设置的模块会直接命中学校组自己的显式记录(permMap.get(学校组)!=null), - // 导致"子组设置的覆盖限制"完全失效=父级所有模块都拿到,即你观察到的"本组继承没生效/还是父级全量"。 - // B) 未传 groupId(全局查用户权限/开放接口 modules 未传schId以外的其他上下文): - // 正常取用户所有真实归属组并集。 + // A) 接口显式传了 groupId(= 管理员在"某个组的授权面板"点用户权限一览,是典型的组视角查询): + // 以传入的 groupId 作为唯一 selfGroupId,仅沿「该组 -> 祖先 -> 第一个学校组(含)」链路判定; + // 【关键】不再把用户真实加入的其他组(例如用户还属于学校组)并入判断。 + // 原因:如果允许用户归属的"学校组"作为独立 selfGroupId 参与本组显式判定, + // 则子组未设置的模块会直接命中学校组自己的显式记录(permMap.get(学校组)!=null), + // 导致"子组设置的覆盖限制"完全失效=父级所有模块都拿到,即你观察到的"本组继承没生效/还是父级全量"。 + // B) 未传 groupId(全局查用户权限/开放接口 modules 未传schId以外的其他上下文): + // 正常取用户所有真实归属组并集。 List userRels = sysUserGroupRelRepository.findByUserId(userId); Set selfGroupIds = new LinkedHashSet<>(); if (groupId != null) { @@ -884,12 +1085,15 @@ public class GroupAuthzService implements IGroupAuthzService { /** * 就近原则:在 groupId 的祖先链上,找到离它最近的、对目标应用有显式模块配置的组(含自身)。 * - *

链首即自身({@link #ancestorChain} 由自身向根排列),天然「就近优先」;遍历时第一个 + *

+ * 链首即自身({@link #ancestorChain} 由自身向根排列),天然「就近优先」;遍历时第一个 * 「groupConfiguredApps 中包含该应用」的组即作为该应用在本组范围内的唯一权限来源返回。 * - *

学校截止:遇到学校组(无论是否已配置)即作为继承上限,不再向教育局/根越级,避免跨学校继承到未开通的应用。 + *

+ * 学校截止:遇到学校组(无论是否已配置)即作为继承上限,不再向教育局/根越级,避免跨学校继承到未开通的应用。 * - *

找不到返回 null(该应用在此范围内没有任何组的显式配置,对应模块不许可)。 + *

+ * 找不到返回 null(该应用在此范围内没有任何组的显式配置,对应模块不许可)。 */ private Integer findNearestAuthorityGroup(Integer groupId, List chain, Integer appId, Map> groupConfiguredApps, @@ -913,7 +1117,8 @@ public class GroupAuthzService implements IGroupAuthzService { /** * 子组继承闸门:判定「来源组 authority 对 moduleId 的记录」是否对本组 selfGroupId 生效。 * - *

规则(修复「父级未勾子组继承却仍被继承」的 bug): + *

+ * 规则(修复「父级未勾子组继承却仍被继承」的 bug): * ① 来源组 == 本组:本组自身勾选的模块直接生效——{@code inheritFlag} 只约束是否下放到子组,不影响本组自己; * ② 来源组为祖先(往上回溯得来):必须勾选了「子组继承」({@code inheritFlag=true}) 才会下放到本组, * 否则该模块不继承。即「只过滤勾选了子组继承的模块;若来源组对该应用所有模块都没勾子组继承,则本组对该应用返回空」。 @@ -933,7 +1138,8 @@ public class GroupAuthzService implements IGroupAuthzService { /** * 用户维度权限判定:用户对指定模块是否有权限(就近原则 + 学校截止 + 角色授权并集 + 超管兜底)。 * - *

就近原则(统一了「本组覆盖」与「最近祖先继承」两种情形,修复两类合并 bug): + *

+ * 就近原则(统一了「本组覆盖」与「最近祖先继承」两种情形,修复两类合并 bug): * 沿 selfGroupId 的祖先链(含自身)向上,找到「离它最近、且对 appId 有任意显式模块配置」的组作为 * 该应用的唯一权限来源;该模块被许可,当且仅当这个来源组对它自身有显式 {@code sys_group_module_perm} 记录。 * 这样: @@ -941,13 +1147,16 @@ public class GroupAuthzService implements IGroupAuthzService { * ② 本组无配置(如英语组为空)→ 取最近的有配置祖先(如教师组)为来源,不再越级到更远的学校组; * ③ 来源组的模块集合即最终集合,更上层同应用模块(如学校组多勾的模块8)一律不合并。 * - *

子组继承闸门(修复「父级未勾子组继承仍被继承」的 bug):来源组若是本组,本组勾选的模块直接生效; + *

+ * 子组继承闸门(修复「父级未勾子组继承仍被继承」的 bug):来源组若是本组,本组勾选的模块直接生效; * 来源组若是祖先,必须该模块记录 {@code inheritFlag=true}(勾选子组继承)才下放到本组,否则不继承—— * 即「只过滤勾选了子组继承的模块;若来源组对该应用所有模块都没勾子组继承,则本组对该应用返回空」。 * - *

学校截止:祖先链只查到第一个学校组(含)为止,不跨学校到教育局/根。 + *

+ * 学校截止:祖先链只查到第一个学校组(含)为止,不跨学校到教育局/根。 * - *

其余:归属组需已开通该模块所属应用(双保险,防越权)。 + *

+ * 其余:归属组需已开通该模块所属应用(双保险,防越权)。 */ private boolean hasUserModulePermission(Integer moduleId, Integer appId, Set selfGroupIds, @@ -1235,7 +1444,7 @@ public class GroupAuthzService implements IGroupAuthzService { return false; } // 4. 一次批量查询「该应用下所有模块」在全部祖先链节点上的权限记录 - // (要判定就近来源,需要知道链上哪些组对该应用有显式配置,不能只看单模块) + // (要判定就近来源,需要知道链上哪些组对该应用有显式配置,不能只看单模块) List appModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); Set appModuleIds = new LinkedHashSet<>(); for (AppModule m : appModules) { @@ -1256,7 +1465,7 @@ public class GroupAuthzService implements IGroupAuthzService { } } // 5. 就近原则判定:对每个归属组,取离它最近的、对该应用有显式配置的组为唯一权限来源; - // 该来源组对目标模块有记录即通过(多个归属组取并集)。 + // 该来源组对目标模块有记录即通过(多个归属组取并集)。 for (Integer selfGroupId : selfGroupIds) { List chain = chainMap.get(selfGroupId); Integer authority = findNearestAuthorityGroup(selfGroupId, chain, appId, groupConfiguredApps, allGroupMap); From 165d46b156acbaa1c98a256f1a2825d18804605d Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 14 Aug 2026 16:14:15 +0800 Subject: [PATCH 40/58] ci --- .../com/yunhai/userbase/controller/api/AppRoleController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java index 9e963c5..f25da78 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java @@ -42,7 +42,7 @@ public class AppRoleController { private IAppRoleService appRoleService; /** - * 角色分页列表 :name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称。 + * 角色分页列表 :name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称 */ @GetMapping("/RoleList") public BasePageResult roleList(SysAppRoleQuery query, BasePageQuery pageQuery, From 05c7741954cb327e29d1cbc51f053a20e489742a Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 17 Aug 2026 16:39:00 +0800 Subject: [PATCH 41/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=83=A8=E5=88=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/controller/api/SysUserController.java | 4 ++-- .../java/com/yunhai/userbase/service/ISysUserService.java | 4 ++-- .../com/yunhai/userbase/service/impl/AppRoleService.java | 3 ++- .../com/yunhai/userbase/service/impl/SysUserService.java | 8 +++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java index 186d5d7..ac44a3e 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -41,10 +41,10 @@ public class SysUserController { @OperationLog("新增用户") @PostMapping("Add") - public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { + public Integer add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { List data=new ArrayList<>(); - sysUserService.add(request); + return sysUserService.add(request); } @OperationLog("编辑用户") diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index ff40624..05c85f8 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -19,8 +19,8 @@ import java.util.List; */ public interface ISysUserService { - /** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。 */ - void add(SysUserRequest request); + /** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。返回创建后的用户ID。 */ + Integer add(SysUserRequest request); /** 编辑用户;密码为空时不修改密码。 */ void edit(Integer id, SysUserRequest request); diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java index 46cc59b..f1023d9 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java @@ -182,7 +182,8 @@ public class AppRoleService implements IAppRoleService { } else { ApplicationInfo app = applicationInfoRepository.findById(role.getAppId()).orElse(null); if (app == null) { - throw new DException("角色所属应用不存在: " + role.getAppId()); + // 应用已删除的孤儿角色:不再抛错导致整页 500,返回空授权树(数据侧请用清理 SQL 修复) + return Collections.emptyList(); } apps = Collections.singletonList(app); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 638ebdd..0a720b6 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -96,9 +96,9 @@ public class SysUserService implements ISysUserService { @Override @Transactional - public void add(SysUserRequest request) { + public Integer add(SysUserRequest request) { // 单条新增走完整校验(含密码强度) - createUser(request, true); + return createUser(request, true); } /** @@ -108,7 +108,7 @@ public class SysUserService implements ISysUserService { * - 单条新增 = true(前端已交互式校验); * - 批量导入 = false(默认密码 Vzuk4Dp3 不含符号,按业务约定直接放行)。 */ - private void createUser(SysUserRequest request, boolean enforcePasswordStrength) { + private Integer createUser(SysUserRequest request, boolean enforcePasswordStrength) { if (sysUserRepository.countByAccount(request.getAccount()) > 0) { throw new DException("账号已存在"); } @@ -146,6 +146,8 @@ public class SysUserService implements ISysUserService { rel.setIsMain(IS_MAIN); rel.setCreateTime(LocalDateTime.now()); sysUserGroupRelRepository.save(rel); + + return saved.getId(); } @Override From 6543203bc68d73f6a22c8cc63836f6ee4d7a0b1d Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 17 Aug 2026 18:54:44 +0800 Subject: [PATCH 42/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E9=94=99=E4=B9=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yunhai/userbase/service/impl/SysUserService.java | 6 +++--- src/main/java/com/yunhai/userbase/utils/OtherUtil.java | 8 +++++--- src/main/resources/sql/sys_user_init.sql | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 0a720b6..374512b 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -445,7 +445,7 @@ public class SysUserService implements ISysUserService { new ImportColumnDef("phone", "手机号(可选)", 16, false, null), new ImportColumnDef("email", "email(可选)", 24, false, null), new ImportColumnDef("nature", "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", 24, false, "0"), - new ImportColumnDef("identity", "用户身份\n(1教师 2教职工 3学生 4家长)", 24, false, null), + new ImportColumnDef("identity", "用户身份\n(1教师 2校领导 3学生 4家长)", 24, false, null), new ImportColumnDef("gender", "性别(可选)", 12, false, null), new ImportColumnDef("birthday", "生日(yyyy-MM-dd)(可选)", 22, false, null) ); @@ -516,7 +516,7 @@ public class SysUserService implements ISysUserService { * - 组名 / 组id:取每个用户实际所属的主组(isMain=1),与导入模板的「参考」列保持一致;
* - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);
* - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);
- * - 用户身份 identity:取 roles 数组第一个元素(系统角色枚举 key:1管理员 2老师 3校领导 4局领导 5学生 6家长 7工作号)。 + * - 用户身份 identity:取 roles 数组第一个元素(系统角色枚举 SysRoleEnum key:1老师 2校领导 3学生 4家长 5局领导 6工作号 7管理员)。 */ @Override public void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException { @@ -839,7 +839,7 @@ public class SysUserService implements ISysUserService { /** * 根据模板中的用户性质与用户身份构建 roles 列表。 * - 用户性质:0普通用户(不加管理员),2组管理员(加 ADMIN=7);其他值视为非法。 - * - 用户身份:1教师->TEACHER(1),2教职工->SCHOOL_LEADER(2),3学生->STUDENT(3),4家长->PARENT(4)。 + * - 用户身份:1教师->TEACHER(1),2校领导->SCHOOL_LEADER(2),3学生->STUDENT(3),4家长->PARENT(4)。 */ private List buildImportRoles(Integer nature, Integer identity) { Set roleSet = new HashSet<>(); diff --git a/src/main/java/com/yunhai/userbase/utils/OtherUtil.java b/src/main/java/com/yunhai/userbase/utils/OtherUtil.java index f5b1598..0945eb2 100644 --- a/src/main/java/com/yunhai/userbase/utils/OtherUtil.java +++ b/src/main/java/com/yunhai/userbase/utils/OtherUtil.java @@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONException; import com.alibaba.fastjson.JSONObject; import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.enums.SysRoleEnum; import java.util.Arrays; import java.util.List; @@ -81,10 +82,11 @@ public class OtherUtil { } /** - * 判断是否为校领导 - */ + * 判断角色是否为管理员(SysRoleEnum.ADMIN = 7)。 + * 注:方法名历史遗留为 isAdminById,语义即「是否具备管理员权限」。 + */ public final static Boolean isAdminById(Integer roleId){ - return Arrays.asList(2,5,7).contains(roleId); + return roleId != null && SysRoleEnum.ADMIN.getKey().equals(roleId); } diff --git a/src/main/resources/sql/sys_user_init.sql b/src/main/resources/sql/sys_user_init.sql index daf4e27..62499db 100644 --- a/src/main/resources/sql/sys_user_init.sql +++ b/src/main/resources/sql/sys_user_init.sql @@ -10,7 +10,7 @@ -- 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] 即具备管理员权限。 +-- 应用内 isAdminById 仅对 SysRoleEnum.ADMIN(7) 返回 true,故赋予 [7] 即具备管理员权限。 -- 若需“全角色”超级权限,可将 '[7]' 改为 '[1,2,3,4,5,6,7]'。 -- 4. type 列(用户类型枚举 UserTypeEnum):0=普通用户,1=超级管理员,2=学校管理员。 -- 本脚本写入的 3 个种子账号均为 type=1(超级管理员);应用层在新增/编辑用户时会自动推导: From ac95b2463bb561398905e1b14a5f16cb7171d18d Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 17 Aug 2026 19:13:25 +0800 Subject: [PATCH 43/58] =?UTF-8?q?jack=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/config/JacksonConfig.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/main/java/com/yunhai/userbase/config/JacksonConfig.java diff --git a/src/main/java/com/yunhai/userbase/config/JacksonConfig.java b/src/main/java/com/yunhai/userbase/config/JacksonConfig.java new file mode 100644 index 0000000..7f6d788 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/JacksonConfig.java @@ -0,0 +1,44 @@ +package com.yunhai.userbase.config; + +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 全局 Jackson 日期格式统一。 + * + *

Spring Boot 的 {@code spring.jackson.date-format} 只作用于 {@link java.util.Date}, + * 对 {@code LocalDateTime}/{@code LocalDate} 无效;且 {@code spring.jackson.local-date-time-format} + * 并非 {@code JacksonProperties} 的合法绑定项(会被静默忽略)。本配置把 Nacos 中已配置的 + * {@code spring.jackson.date-format} 同时应用到 JSR-310 时间类型,做到全局统一。 + * + *

仅用 {@code serializerByType}/{@code deserializerByType} 覆盖 JavaTimeModule 默认序列化器, + * 不 new 整个 ObjectMapper,因此不会禁用 Spring Boot 的 Jackson 自动配置; + * 字段上的 {@code @JsonFormat} 优先级更高,不受影响。 + */ +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer( + @Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}") String dateFormat) { + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(dateFormat); + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + return (Jackson2ObjectMapperBuilder builder) -> { + builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter)); + builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(dateTimeFormatter)); + builder.serializerByType(LocalDate.class, new LocalDateSerializer(dateFormatter)); + builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(dateFormatter)); + }; + } +} From 60360bfb6bd56d42a4e4ea313730ddb29b3807a1 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 17 Aug 2026 20:13:17 +0800 Subject: [PATCH 44/58] =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/service/push/PushDispatcher.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java index 2e4bc9a..c69e0cd 100644 --- a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java +++ b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java @@ -77,7 +77,7 @@ public class PushDispatcher { users = sysUserRepository.findAll(); } for (ApplicationInfo app : applicationInfoRepository.findByPushEnabled(1)) { - dispatchToOne(app, type, groups, users, "auto"); + dispatchToOne(app, type, groups, users, "auto", false); } } catch (Exception e) { log.error("[推送] 分发失败(不影响主业务)", e); @@ -108,7 +108,9 @@ public class PushDispatcher { users = (targetIds == null || targetIds.isEmpty()) ? sysUserRepository.findAll() : sysUserRepository.findAllById(targetIds); } - dispatchToOne(app, type, groups, users, "manual"); + // 手动全量同步(SyncGroup/SyncUser)属于管理员主动触发,绕过订阅接口开关,确保一定能投递 + boolean force = (type == PushInterfaceType.SYNC_GROUP || type == PushInterfaceType.SYNC_USER); + dispatchToOne(app, type, groups, users, "manual", force); } /** @@ -176,11 +178,13 @@ public class PushDispatcher { // ===================== 核心 ===================== private void dispatchToOne(ApplicationInfo app, PushInterfaceType type, - List groups, List users, String triggerType) { + List groups, List users, String triggerType, boolean force) { if (app.getPushEnabled() == null || app.getPushEnabled() != 1) { return; } - if (!containsEventType(app.getPushEventTypes(), type.getPath())) { + // 手动全量同步为管理员主动触发,绕过订阅接口开关(force=true); + // 自动/增量事件仍要求订阅方在 pushEventTypes 中勾选对应接口。 + if (!force && !containsEventType(app.getPushEventTypes(), type.getPath())) { return; } boolean isFull = (type == PushInterfaceType.SYNC_GROUP || type == PushInterfaceType.SYNC_USER); From 7a1dc29ff7f806ca3ba35d3d7610d09f3dd93cc2 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 18 Aug 2026 00:04:07 +0800 Subject: [PATCH 45/58] =?UTF-8?q?=E6=8E=A8=E9=80=81=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8E=BB=E9=99=A4payload=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E4=B8=8B=E8=BD=BD=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/PushSubscriptionController.java | 43 ++++++- .../userbase/model/push/PushRecordListVo.java | 74 ++++++++++++ .../service/push/PushRecordService.java | 109 ++++++++++++++---- 3 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/model/push/PushRecordListVo.java diff --git a/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java b/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java index 3999d75..b16850d 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java @@ -3,17 +3,25 @@ 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.push.PushRecordEntity; +import com.yunhai.userbase.model.push.PushRecordListVo; import com.yunhai.userbase.model.push.PushRecordQuery; import com.yunhai.userbase.model.push.PushTestRequest; import com.yunhai.userbase.service.push.PushDispatcher; import com.yunhai.userbase.service.push.PushRecordService; import com.yunhai.userbase.utils.PageUtils; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Sort; import org.springframework.data.web.SortDefault; +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.*; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -24,6 +32,7 @@ import java.util.Map; * @date 2026/8/14 */ @RestController +@Slf4j @RequestMapping(value = Config.API_PATH + "/push/") public class PushSubscriptionController { @@ -45,9 +54,9 @@ public class PushSubscriptionController { return result; } - /** 推送日志列表。 */ + /** 推送日志列表(不含 payload,避免全量推送数据量过大;payload 经 Download 接口单独获取)。 */ @GetMapping("RecordList") - public BasePageResult recordList(PushRecordQuery query, BasePageQuery pageQuery, + public BasePageResult recordList(PushRecordQuery query, BasePageQuery pageQuery, @SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) { return PageUtils.toResult(pushRecordService.query(query, PageUtils.toPageable(pageQuery, sort))); } @@ -57,4 +66,32 @@ public class PushSubscriptionController { public void retry(@PathVariable Long id) { pushRecordService.retry(id); } + + /** + * 下载某条推送记录的请求体原文(payload 文本文件)。
+ * 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。
+ * 若该记录未记录 payload,返回一段说明文本文件而非 404,便于排查。 + */ + @GetMapping("/Download/{id}") + public ResponseEntity download(@PathVariable Long id) { + String data = pushRecordService.getPayload(id); + if (data == null) { + data = "该条推送记录未记录请求体(payload 为空),无法下载原始内容。"; + } + 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(("payload-" + 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/model/push/PushRecordListVo.java b/src/main/java/com/yunhai/userbase/model/push/PushRecordListVo.java new file mode 100644 index 0000000..41e13db --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/push/PushRecordListVo.java @@ -0,0 +1,74 @@ +package com.yunhai.userbase.model.push; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 推送记录列表项视图。
+ * 不含 payload(请求体原文),列表不展示,需通过下载接口单独获取,避免全量推送时数据量过大。
+ * + * @author chenx + * @date 2026/8/17 + */ +@Data +public class PushRecordListVo { + + /** 主键 */ + private Long id; + /** 订阅方 appKey */ + private Integer subscriberAppKey; + /** 应用名称 */ + private String appName; + /** 接口路径,如 group/SyncGroup */ + private String interfacePath; + /** 接口中文标签 */ + private String interfaceLabel; + /** 请求地址 */ + private String requestUrl; + /** 签名时间 t */ + private String signT; + /** HTTP 状态码 */ + private Integer httpStatus; + /** 业务码 */ + private Integer respCode; + /** 业务消息 */ + private String respMsg; + /** 是否成功:0失败 1成功 */ + private Integer success; + /** 重试次数 */ + private Integer attemptNo; + /** 耗时(ms) */ + private Integer costMs; + /** 触发类型:manual / auto / retry */ + private String triggerType; + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + /** + * 全参构造,顺序需与 {@code PushRecordEntity} 的列表投影字段一致。
+ * 用于 JPA Criteria {@code cb.construct} 投影查询。 + */ + public PushRecordListVo(Long id, Integer subscriberAppKey, String appName, String interfacePath, + String interfaceLabel, String requestUrl, String signT, Integer httpStatus, + Integer respCode, String respMsg, Integer success, Integer attemptNo, + Integer costMs, String triggerType, LocalDateTime createdAt) { + this.id = id; + this.subscriberAppKey = subscriberAppKey; + this.appName = appName; + this.interfacePath = interfacePath; + this.interfaceLabel = interfaceLabel; + this.requestUrl = requestUrl; + this.signT = signT; + this.httpStatus = httpStatus; + this.respCode = respCode; + this.respMsg = respMsg; + this.success = success; + this.attemptNo = attemptNo; + this.costMs = costMs; + this.triggerType = triggerType; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java b/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java index 6388aec..c54ce9e 100644 --- a/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java +++ b/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java @@ -4,6 +4,7 @@ import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.config.PushMqConfig; import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.model.push.PushRecordEntity; +import com.yunhai.userbase.model.push.PushRecordListVo; import com.yunhai.userbase.model.push.PushRecordQuery; import com.yunhai.userbase.model.push.PushTaskMessage; import com.yunhai.userbase.repository.ApplicationInfoRepository; @@ -14,11 +15,18 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.domain.Sort; 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.Order; import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -42,28 +50,46 @@ public class PushRecordService { @Qualifier("pushRabbitTemplate") private RabbitTemplate pushRabbitTemplate; - /** 分页查询(按条件过滤,默认按创建时间倒序在 Controller 层设置)。 */ - public Page query(PushRecordQuery q, Pageable pageable) { - Specification spec = (root, cq, cb) -> { - List ps = new ArrayList<>(); - if (q.getAppKey() != null) { - ps.add(cb.equal(root.get("subscriberAppKey"), q.getAppKey())); - } - if (q.getInterfacePath() != null && !q.getInterfacePath().isEmpty()) { - ps.add(cb.equal(root.get("interfacePath"), q.getInterfacePath())); - } - if (q.getSuccess() != null) { - ps.add(cb.equal(root.get("success"), q.getSuccess())); - } - if (q.getStartTime() != null && !q.getStartTime().isEmpty()) { - ps.add(cb.greaterThanOrEqualTo(root.get("createdAt"), parseStart(q.getStartTime()))); - } - if (q.getEndTime() != null && !q.getEndTime().isEmpty()) { - ps.add(cb.lessThanOrEqualTo(root.get("createdAt"), parseEnd(q.getEndTime()))); - } - return cb.and(ps.toArray(new Predicate[0])); - }; - return pushRecordRepository.findAll(spec, pageable); + @PersistenceContext + private EntityManager em; + + /** 分页查询(列表投影排除 payload,避免全量推送时数据量过大;默认按创建时间倒序)。 */ + public Page query(PushRecordQuery q, Pageable pageable) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + // 列表投影:显式选择字段,不包含 payload(MEDIUMTEXT,全量同步可能非常大) + CriteriaQuery cq = cb.createQuery(PushRecordListVo.class); + Root root = cq.from(PushRecordEntity.class); + cq.select(cb.construct(PushRecordListVo.class, + root.get("id"), root.get("subscriberAppKey"), root.get("appName"), + root.get("interfacePath"), root.get("interfaceLabel"), root.get("requestUrl"), + root.get("signT"), root.get("httpStatus"), root.get("respCode"), root.get("respMsg"), + root.get("success"), root.get("attemptNo"), root.get("costMs"), + root.get("triggerType"), root.get("createdAt"))); + List predicates = buildPredicates(cb, root, q); + cq.where(predicates.toArray(new Predicate[0])); + cq.orderBy(buildOrders(cb, root, pageable.getSort())); + + // 总数查询 + CriteriaQuery countCq = cb.createQuery(Long.class); + Root countRoot = countCq.from(PushRecordEntity.class); + countCq.select(cb.count(countRoot)); + countCq.where(buildPredicates(cb, countRoot, q).toArray(new Predicate[0])); + long total = em.createQuery(countCq).getSingleResult(); + + TypedQuery typed = em.createQuery(cq); + int pageIndex = pageable.getPageNumber(); + int pageSize = pageable.getPageSize(); + typed.setFirstResult(pageIndex * pageSize); + typed.setMaxResults(pageSize); + List content = typed.getResultList(); + + return new org.springframework.data.domain.PageImpl<>(content, pageable, total); + } + + /** 获取某条推送记录的请求体原文(payload)。无记录返回 null。 */ + public String getPayload(Long id) { + PushRecordEntity rec = pushRecordRepository.findById(id).orElse(null); + return rec != null ? rec.getPayload() : null; } /** 手动重试:用原始 payload 重新签名(新时间戳)投递 MQ,由消费者再次落库。 */ @@ -87,6 +113,43 @@ public class PushRecordService { pushRabbitTemplate.convertAndSend(PushMqConfig.EXCHANGE_TOPIC, rec.getInterfacePath(), msg); } + private List buildPredicates(CriteriaBuilder cb, Root root, PushRecordQuery q) { + List ps = new ArrayList<>(); + if (q == null) { + return ps; + } + if (q.getAppKey() != null) { + ps.add(cb.equal(root.get("subscriberAppKey"), q.getAppKey())); + } + if (q.getInterfacePath() != null && !q.getInterfacePath().isEmpty()) { + ps.add(cb.equal(root.get("interfacePath"), q.getInterfacePath())); + } + if (q.getSuccess() != null) { + ps.add(cb.equal(root.get("success"), q.getSuccess())); + } + if (q.getStartTime() != null && !q.getStartTime().isEmpty()) { + ps.add(cb.greaterThanOrEqualTo(root.get("createdAt"), parseStart(q.getStartTime()))); + } + if (q.getEndTime() != null && !q.getEndTime().isEmpty()) { + ps.add(cb.lessThanOrEqualTo(root.get("createdAt"), parseEnd(q.getEndTime()))); + } + return ps; + } + + private List buildOrders(CriteriaBuilder cb, Root root, Sort sort) { + List orders = new ArrayList<>(); + if (sort != null && sort.isSorted()) { + for (Sort.Order o : sort) { + javax.persistence.criteria.Path path = root.get(o.getProperty()); + orders.add(o.isAscending() ? cb.asc(path) : cb.desc(path)); + } + } + if (orders.isEmpty()) { + orders.add(cb.desc(root.get("createdAt"))); + } + return orders; + } + /** 清理 1 年前的记录(每日 3 点由 PushRecordCleanupTask 调用)。 */ @Transactional public long cleanupExpired() { From 52b29c5082ba73417c827474bed69db21d39471a Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 18 Aug 2026 21:46:06 +0800 Subject: [PATCH 46/58] =?UTF-8?q?=E7=BC=96=E5=86=99=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E7=BB=84/=E7=94=A8=E6=88=B7?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E5=8A=A8=E7=BB=84=E7=AD=89=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/SysUserController.java | 6 - .../controller/oapi/GroupController.java | 41 +++++ .../controller/oapi/UserController.java | 85 +++++++--- .../userbase/entity/SysUserCurrentRole.java | 16 +- .../userbase/service/IGroupAuthzService.java | 3 +- .../userbase/service/ISysGroupService.java | 6 +- .../userbase/service/ISysUserService.java | 19 ++- .../service/impl/SysGroupService.java | 145 ++++++++++++++++-- .../userbase/service/impl/SysUserService.java | 65 ++++++-- .../service/impl/UserCenterService.java | 63 +++++++- 10 files changed, 388 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java index ac44a3e..84c23bc 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -53,12 +53,6 @@ public class SysUserController { sysUserService.edit(id, request); } - @OperationLog("删除用户") - @PostMapping("Delete/{id}") - public void delete(@PathVariable Integer id) { - sysUserService.delete(id); - } - /** * 将用户从指定组中移除;若该用户无其它组关联则级联删除用户。 */ diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java index e9bb1e4..5cab5cb 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java @@ -1,10 +1,17 @@ package com.yunhai.userbase.controller.oapi; import com.yunhai.platform.utils.model.BoaGroup; +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.SysGroupRequest; 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.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; @@ -60,4 +67,38 @@ public class GroupController { @RequestParam(value = "withSub", required = false) Boolean withSub) { return sysGroupService.getGroupList(ids, withSub); } + + /** + * 新增组:返回创建后的组节点(含 id)。
+ * 层级类型约束、根节点保护等复用后台逻辑;鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。 + */ + @PostMapping("add") + public BoaGroup add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) { + return sysGroupService.add(request); + } + + /** + * 编辑组(不含移动,移动请走 group/move 后台接口):返回更新后的组节点(含 id)。 + */ + @PostMapping("edit/{id}") + public BoaGroup edit(@PathVariable Integer id, + @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) { + return sysGroupService.edit(id, request); + } + + /** + * 删除组:组下存在子组或用户时拒绝删除(根节点不可删)。无返回值。 + */ + @PostMapping("delete/{id}") + public void delete(@PathVariable Integer id) { + sysGroupService.delete(id); + } + + /** + * 批量新增子组:请求体 { pid, type, items:[{name},...] },返回创建后的组节点列表(均含 id)。 + */ + @PostMapping("batchAdd") + public List batchAdd(@RequestBody @Validated SysGroupBatchRequest request) { + return sysGroupService.batchAdd(request); + } } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index 52e265d..1763a2e 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -5,20 +5,29 @@ import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.sysuser.SysUserMoveRequest; +import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.repository.ApplicationInfoRepository; import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.userbase.service.ISysUserService; import com.yunhai.userbase.service.impl.UserCenterService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.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 javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -41,6 +50,9 @@ public class UserController { @Autowired private UserCenterService userCenterService; + @Autowired + private ISysUserService sysUserService; + @Autowired private IGroupAuthzService groupAuthzService; @@ -130,28 +142,61 @@ public class UserController { return app.getId(); } + /** - * 按「schId 为空时」解析出用户主学校/主组作为模块查询的上下文组ID。
- * 保留方法(逻辑已下沉到 Service.resolveEffectiveGroupId 复用),不再被 modules() 使用。 + * 新增用户:走完整校验(账号唯一/手机号/身份证/密码强度),BCrypt 加密并写入组关联; + * 返回创建后的用户(含 id 与组织链)。
+ * 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。 */ - @Deprecated - private Integer resolveEffectiveGroupId(Integer userId, Integer schId) { - if (schId != null) { - return schId; + @PostMapping("add") + public BoaUser add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { + Integer id = sysUserService.add(request); + return userCenterService.getUserInfoWithDeptById(id); + } + + /** + * 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。 + */ + @PostMapping("edit/{id}") + public BoaUser edit(@PathVariable Integer id, + @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) { + sysUserService.edit(id, request); + return userCenterService.getUserInfoWithDeptById(id); + } + + /** + * 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;
+ * 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。
+ * 这样多校用户只会被退出指定学校,而不会被整体误删。
+ * 若用户仅归属这一个组,调用后该用户将被真正删除(组关联清空即触发级联删)。 + */ + @PostMapping("delete/{id}/{groupId}") + public void delete(@PathVariable Integer id, @PathVariable Integer groupId) { + sysUserService.deleteFromGroup(id, groupId); + } + + /** + * 批量新增用户:逐条走完整校验(含密码强度,每条需携带 password + confirmPassword); + * 返回创建后的用户列表(顺序与入参一致,均含 id 与组织链)。 + */ + @PostMapping("batchAdd") + public List batchAdd(@RequestBody List requests) { + List ids = sysUserService.batchAdd(requests); + List result = new ArrayList<>(); + for (Integer id : ids) { + result.add(userCenterService.getUserInfoWithDeptById(id)); } - BoaUser boa = userCenterService.getUserInfoWithDeptById(userId); - if (boa == null) { - return null; - } - BoaGroup school = boa.getSchool(); - if (school != null && school.getId() != null) { - return school.getId(); - } - List groups = boa.getGroups(); - if (groups != null && !groups.isEmpty()) { - BoaGroup main = groups.get(0); - return main == null ? null : main.getId(); - } - return null; + return result; + } + + /** + * 移动用户到指定组:仅允许在同一学校(组)范围内移动——用户当前必须已在该学校子树内, + * 且目标组也必须在该学校子树内,否则拒绝(禁止跨校移动)。
+ * 语义同后台「调整组」——覆盖式把用户组归属替换为目标组(目标设为主组)。返回移动后的用户(含新的组织链)。 + */ + @PostMapping("moveToGroup") + public BoaUser moveToGroup(@RequestBody @Validated SysUserMoveRequest request) { + sysUserService.moveUserWithinSchool(request.getUserId(), request.getGroupId(), request.getSchoolId()); + return userCenterService.getUserInfoWithDeptById(request.getUserId()); } } diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java b/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java index 8e78664..7935603 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java @@ -37,12 +37,24 @@ public class SysUserCurrentRole { private Integer userId; /** - * 用户切换选中的学校ID(根级group) + * 用户切换选中的学校ID(根级group,数值即学校组 sys_group.id;区域/学段/根直属=0或null) */ private Integer schoolId; /** - * 用户切换后生效角色ID + * 用户切换后生效角色ID(SysRoleEnum.key) */ private Integer roleId; + /** + * 最近一次切换时间(服务端 now() 写入) + */ + @Column(name = "switch_time") + private LocalDateTime switchTime; + + /** + * 学校名冗余(避免每次 join sys_group 取 name) + */ + @Column(name = "school_name", length = 100) + private String schoolName; + } diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java index 09086d7..a69597b 100644 --- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -1,5 +1,6 @@ package com.yunhai.userbase.service; +import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.userbase.model.groupauthz.*; import java.util.List; @@ -130,7 +131,7 @@ public interface IGroupAuthzService { * @param userId 用户ID(必填) * @return 该应用下用户有权限的模块(扁平化;无权限时返回空列表) */ - List findUserModulesForApp(Integer appId, Integer schId, Integer userId); + List findUserModulesForApp(Integer appId, Integer schId, Integer userId); // ===================== 新增:树节点式增量授权(供左侧树 + 右侧 appAuthzList 的勾选交互) ===================== diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java index 3ffc6e0..b148b71 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -17,9 +17,9 @@ import java.util.List; */ public interface ISysGroupService { - void add(SysGroupRequest request); + BoaGroup add(SysGroupRequest request); - void edit(Integer id, SysGroupRequest request); + BoaGroup edit(Integer id, SysGroupRequest request); void delete(Integer id); @@ -36,7 +36,7 @@ public interface ISysGroupService { List getSchoolTree(); /** 在指定父组下按规则批量生成子组。 */ - void batchAdd(SysGroupBatchRequest request); + List batchAdd(SysGroupBatchRequest request); /** 对 pid 的直接子节点按给定顺序重排并落库(ids 必须全是直接子节点)。 */ void sortChildren(SysGroupSortRequest request); diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index 05c85f8..113f86a 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -25,8 +25,10 @@ public interface ISysUserService { /** 编辑用户;密码为空时不修改密码。 */ void edit(Integer id, SysUserRequest request); - /** 删除用户:同时清理其组用户关联。 */ - void delete(Integer id); + /** + * 批量新增用户:逐条走完整校验(含密码强度),返回创建后的用户ID列表(顺序与入参一致)。 + */ + List batchAdd(List requests); /** * 把用户从指定组中移除;若移除后该用户已无其它组关联,则级联删除用户本身。 @@ -87,6 +89,19 @@ public interface ISysUserService { */ void changeGroups(List userIds, List groupIds); + /** + * 开放接口专用:在学校(组)范围内移动用户。
+ * 语义同 {@link #changeGroups}(覆盖式把用户组归属替换为目标组,目标设为主组),但额外强制:
+ * ① 用户当前必须已在该学校子树内(至少一个组归属落在 schoolId 子树);
+ * ② 目标 groupId 必须也在该学校子树内。
+ * 任意一条不满足即抛异常,禁止跨校移动。 + * + * @param userId 待移动用户ID + * @param groupId 目标组ID(移动后的主归属组) + * @param schoolId 学校组ID(限定移动范围,仅允许同校内移动) + */ + void moveUserWithinSchool(Integer userId, Integer groupId, Integer schoolId); + /** * 调整用户在某组内的排序号(sort_no)。
* sortNo 为 null 时清除排序号,列表回退到 user_id 升序兜底。 diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 33044a0..41e499a 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -28,6 +28,7 @@ import javax.transaction.Transactional; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -47,6 +48,63 @@ public class SysGroupService implements ISysGroupService { /** 虚拟父节点:根节点(教育云中心)的 parentId,表示「无上级」。 */ private static final int ROOT_PARENT_ID = 0; + /** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */ + private static final int SYSTEM_USER_ID = 0; + + /** + * 层级类型约束:父类型 -> 允许直接创建的子类型集合。 + * key 为 {@code null} 表示「根节点(教育云中心)」。 + * 集合为空 = 该类型禁止创建任何子组(班级组叶子节点、学段组占位等由空集自然拒绝)。 + *

规则来源:用户中心组层级约束(根-区域-学段-学校-教师|学生-年级-班级)。

+ */ + private static final Map> ALLOWED_CHILDREN = new HashMap<>(); + static { + ALLOWED_CHILDREN.put(null, EnumSet.of( + GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.NORMAL_GROUP, GroupTypeEnum.AREA_GROUP)); + // 区域组:只能挂在根节点下(由根节点的白名单约束),其下可挂 学段组/学校组/普通组 + ALLOWED_CHILDREN.put(GroupTypeEnum.AREA_GROUP, EnumSet.of( + GroupTypeEnum.STAGE_GROUP, GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.NORMAL_GROUP)); + // 学段组(高中/初中/小学):学校组的上级,可含 学校组/普通组 + ALLOWED_CHILDREN.put(GroupTypeEnum.STAGE_GROUP, EnumSet.of( + GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.NORMAL_GROUP)); + // 学校组:可含 教师组/学生组/普通组 + ALLOWED_CHILDREN.put(GroupTypeEnum.SCHOOL_GROUP, EnumSet.of( + GroupTypeEnum.TEACHER_GROUP, GroupTypeEnum.STUDENT_GROUP, GroupTypeEnum.NORMAL_GROUP)); + // 教师组:维持原规范,仅含 普通组 + ALLOWED_CHILDREN.put(GroupTypeEnum.TEACHER_GROUP, EnumSet.of(GroupTypeEnum.NORMAL_GROUP)); + // 学生组:可含 年级组/学段组/普通组(学段组既可在区域组下、也可在学生组下) + ALLOWED_CHILDREN.put(GroupTypeEnum.STUDENT_GROUP, EnumSet.of( + GroupTypeEnum.GRADE_GROUP, GroupTypeEnum.STAGE_GROUP, GroupTypeEnum.NORMAL_GROUP)); + // 年级组:可含 班级组/普通组 + ALLOWED_CHILDREN.put(GroupTypeEnum.GRADE_GROUP, EnumSet.of( + GroupTypeEnum.CLASS_GROUP, GroupTypeEnum.NORMAL_GROUP)); + // 班级组:叶子节点,禁止创建任何子组(⛔ 强约束,空集自然拒绝) + ALLOWED_CHILDREN.put(GroupTypeEnum.CLASS_GROUP, EnumSet.noneOf(GroupTypeEnum.class)); + // 普通组:可含 学校组/教师组/学段组/普通组(年级组/班级组/学生组 受限,区域组受「只能挂根」硬约束排除) + ALLOWED_CHILDREN.put(GroupTypeEnum.NORMAL_GROUP, EnumSet.of( + GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.TEACHER_GROUP, + GroupTypeEnum.STAGE_GROUP, GroupTypeEnum.NORMAL_GROUP)); + } + + /** 层级类型约束校验:parentType 为 null 表示根节点;非法组合抛 {@link DException}。 */ + private void validateParentChild(GroupTypeEnum parentType, GroupTypeEnum childType) { + Set allowed = ALLOWED_CHILDREN.get(parentType); + if (allowed == null) { + allowed = Collections.emptySet(); + } + if (allowed.contains(childType)) { + return; + } + String parentName = (parentType == null) ? "根节点" : parentType.getValue(); + if (childType == GroupTypeEnum.CLASS_GROUP) { + throw new DException(parentName + "下不允许创建班级组(班级组为叶子节点,禁止创建子组)"); + } + if (childType == GroupTypeEnum.AREA_GROUP) { + throw new DException("区域组只能建立在根节点下"); + } + throw new DException(parentName + "下不允许创建" + childType.getValue()); + } + @Autowired private SysGroupRepository sysGroupRepository; @@ -66,29 +124,35 @@ public class SysGroupService implements ISysGroupService { @Override @Transactional - public void add(SysGroupRequest request) { + public BoaGroup 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("父组不存在"); + // 允许在根节点(教育云中心)下建组:parentId 为 null 或 0 视为根 + GroupTypeEnum parentType = null; // null 表示根节点 + if (parentId != null && parentId != ROOT_PARENT_ID) { + SysGroup parent = sysGroupRepository.findById(parentId) + .orElseThrow(() -> new DException("父组不存在")); + parentType = parent.getType(); } GroupTypeEnum typeEnum = resolveType(request.getType()); + // 层级类型约束:当前父组是否允许创建该子类型 + validateParentChild(parentType, typeEnum); + int realPid = (parentId == null) ? ROOT_PARENT_ID : parentId; SysGroup entity = new SysGroup(); entity.setName(request.getName()); - entity.setParentId(parentId); + entity.setParentId(realPid); entity.setType(typeEnum); - entity.setSort(nextSort(parentId)); + entity.setSort(nextSort(realPid)); + entity.setCreateUser(SYSTEM_USER_ID); + entity.setUpdateUser(SYSTEM_USER_ID); SysGroup saved = sysGroupRepository.save(entity); publishGroupEvent(PushInterfaceType.ADD_GROUP, saved.getId()); + return convertToBoaGroup(saved); } @Override @Transactional - public void edit(Integer id, SysGroupRequest request) { + public BoaGroup edit(Integer id, SysGroupRequest request) { SysGroup entity = sysGroupRepository.findById(id) .orElseThrow(() -> new DException("系统组不存在")); // 根节点不可编辑 @@ -97,14 +161,39 @@ public class SysGroupService implements ISysGroupService { } entity.setName(request.getName()); if (request.getType() != null) { - entity.setType(resolveType(request.getType())); + GroupTypeEnum newType = resolveType(request.getType()); + if (!Objects.equals(newType, entity.getType())) { + // 1) 新类型须被当前父允许(根节点 parentId=0,parentType 视为 null) + GroupTypeEnum parentType = null; + if (entity.getParentId() != null && entity.getParentId() != ROOT_PARENT_ID) { + SysGroup parent = sysGroupRepository.findById(entity.getParentId()).orElse(null); + parentType = (parent != null) ? parent.getType() : null; + } + validateParentChild(parentType, newType); + // 2) 新类型的允许子类型须覆盖现有直接子类型,避免把「含子组的组」改成叶子/不兼容类型 + Set newAllowed = ALLOWED_CHILDREN.get(newType); + if (newAllowed == null) { + newAllowed = Collections.emptySet(); + } + List children = sysGroupRepository.findByParentIdOrderBySortAsc(entity.getId()); + for (SysGroup child : children) { + GroupTypeEnum ct = child.getType(); + if (ct != null && !newAllowed.contains(ct)) { + throw new DException("该组下已存在「" + ct.getValue() + "」,不可变更为「" + + newType.getValue() + "」(" + newType.getValue() + "不允许包含" + ct.getValue() + ")"); + } + } + } + entity.setType(newType); } if (request.getSort() != null) { entity.setSort(request.getSort()); } // 注:移动请走 move 接口,edit 不改 parentId,避免与移动逻辑混淆 + entity.setUpdateUser(SYSTEM_USER_ID); sysGroupRepository.save(entity); publishGroupEvent(PushInterfaceType.UPDATE_GROUP, id); + return convertToBoaGroup(entity); } @Override @@ -309,18 +398,28 @@ public class SysGroupService implements ISysGroupService { @Override @Transactional - public void batchAdd(SysGroupBatchRequest request) { + public List batchAdd(SysGroupBatchRequest request) { Integer pid = request.getPid(); - if (pid == null || !sysGroupRepository.existsById(pid)) { - throw new DException("父组不存在"); + GroupTypeEnum parentType = null; // null 表示根节点 + int realPid; + if (pid != null && pid != ROOT_PARENT_ID) { + SysGroup parent = sysGroupRepository.findById(pid) + .orElseThrow(() -> new DException("父组不存在")); + parentType = parent.getType(); + realPid = pid; + } else { + // 允许在根节点下批量建组 + realPid = ROOT_PARENT_ID; } GroupTypeEnum typeEnum = resolveType(request.getType()); + // 层级类型约束 + validateParentChild(parentType, typeEnum); List items = request.getItems(); if (items == null || items.isEmpty()) { throw new DException("批量新增的子组列表不能为空"); } // 追加到当前父组子节点之后 - int base = nextSort(pid); + int base = nextSort(realPid); List batch = new ArrayList<>(); for (int i = 0; i < items.size(); i++) { @@ -330,14 +429,17 @@ public class SysGroupService implements ISysGroupService { } SysGroup g = new SysGroup(); g.setName(name.trim()); - g.setParentId(pid); + g.setParentId(realPid); g.setType(typeEnum); g.setSort(base + i); + g.setCreateUser(SYSTEM_USER_ID); + g.setUpdateUser(SYSTEM_USER_ID); batch.add(g); } sysGroupRepository.saveAll(batch); publishGroupEvent(PushInterfaceType.ADD_GROUP, batch.stream().map(SysGroup::getId).collect(Collectors.toList())); + return batch.stream().map(this::convertToBoaGroup).collect(Collectors.toList()); } // ===================== 子组排序 ===================== @@ -383,9 +485,16 @@ public class SysGroupService implements ISysGroupService { if (moveId == null || !sysGroupRepository.existsById(moveId)) { throw new DException("待移动节点不存在"); } - if (pid == null || pid == ROOT_PARENT_ID || !sysGroupRepository.existsById(pid)) { + if (pid == null) { throw new DException("目标父节点不存在"); } + // 目标父为根节点时 pid=0;其余情况校验目标父存在并取其类型 + GroupTypeEnum parentType = null; // null 表示根节点 + if (pid != ROOT_PARENT_ID) { + SysGroup target = sysGroupRepository.findById(pid) + .orElseThrow(() -> new DException("目标父节点不存在")); + parentType = target.getType(); + } SysGroup moveEntity = sysGroupRepository.findById(moveId).get(); // 根节点不可移动 if (isRoot(moveEntity)) { @@ -400,6 +509,8 @@ public class SysGroupService implements ISysGroupService { if (isDescendantOf(pid, moveId, allMap)) { throw new DException("不能移动到自身子节点下"); } + // 层级类型约束:移动后目标父类型须允许该节点类型 + validateParentChild(parentType, moveEntity.getType()); // 移动后追加到目标父节点的子节点末尾 moveEntity.setParentId(pid); moveEntity.setSort(nextSort(pid)); diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 374512b..f493621 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -69,6 +69,9 @@ public class SysUserService implements ISysUserService { /** 主归属标识:1=本职主学校/部门 */ private static final int IS_MAIN = 1; + /** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */ + private static final int SYSTEM_USER_ID = 0; + /** 系统内置超级管理员账号(与 sys_user_init.sql 种子账号保持一致) */ private static final Set SUPER_ADMIN_ACCOUNTS = new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3")); @@ -136,6 +139,9 @@ public class SysUserService implements ISysUserService { // disabled 用枚举存储:不传时默认 NORMAL(正常/未禁用) entity.setDisabled(request.getDisabled() != null ? resolveStatus(request.getDisabled()) : StatusEnum.NORMAL); entity.setExpired(request.getExpired() != null ? request.getExpired() : false); + // oapi 写操作无登录态时,用系统哨兵值兜底 create_user/update_user(NOT NULL 约束) + entity.setCreateUser(SYSTEM_USER_ID); + entity.setUpdateUser(SYSTEM_USER_ID); SysUser saved = sysUserRepository.save(entity); publishUserEvent(PushInterfaceType.ADD_USER, saved.getId()); @@ -183,22 +189,23 @@ public class SysUserService implements ISysUserService { if (request.getExpired() != null) { entity.setExpired(request.getExpired()); } + entity.setUpdateUser(SYSTEM_USER_ID); sysUserRepository.save(entity); publishUserEvent(PushInterfaceType.UPDATE_USER, id); } @Override @Transactional - public void delete(Integer id) { - if (!sysUserRepository.existsById(id)) { - throw new DException("系统用户不存在"); + public List batchAdd(List requests) { + if (requests == null || requests.isEmpty()) { + throw new DException("批量新增用户列表不能为空"); } - // 先清理组用户关联、用户-角色绑定,再删除用户 - sysUserGroupRelRepository.deleteByUserId(id); - // AI-FIX(角色授权): 删除用户时级联清理用户-业务角色绑定(逻辑外键无物理 FK,避免残留孤儿数据) - sysUserAppRoleRelRepository.deleteByUserIdBulk(id); - sysUserRepository.deleteById(id); - publishUserEvent(PushInterfaceType.DELETE_USER, id); + // 批量新增与单条一致:强制密码强度校验(每条需携带 password + confirmPassword) + List ids = new ArrayList<>(); + for (SysUserRequest request : requests) { + ids.add(createUser(request, true)); + } + return ids; } @Override @@ -384,6 +391,46 @@ public class SysUserService implements ISysUserService { publishUserEvent(PushInterfaceType.UPDATE_USER, userIds); } + @Override + @Transactional + public void moveUserWithinSchool(Integer userId, Integer groupId, Integer schoolId) { + if (userId == null) { + throw new DException("用户ID不能为空"); + } + if (groupId == null) { + throw new DException("目标组ID不能为空"); + } + if (schoolId == null) { + throw new DException("学校ID不能为空"); + } + if (!sysUserRepository.existsById(userId)) { + throw new DException("系统用户不存在"); + } + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("目标分组不存在"); + } + if (!sysGroupRepository.existsById(schoolId)) { + throw new DException("学校分组不存在"); + } + + // 学校子树范围(含学校自身):用户与目标组都必须落在此范围内,否则视为跨校移动 + List schoolScope = sysGroupService.listSelfAndDescendantIds(schoolId); + + // ① 用户当前必须已在该学校内(至少一个组归属落在 schoolScope) + List userGroupIds = findGroupIdsByUserId(userId); + boolean userInSchool = userGroupIds.stream().anyMatch(schoolScope::contains); + if (!userInSchool) { + throw new DException("用户不在该学校范围内,禁止跨校移动"); + } + // ② 目标组必须也在该学校内 + if (!schoolScope.contains(groupId)) { + throw new DException("目标组不属于该学校范围,禁止跨校移动"); + } + + // 复用覆盖式改组逻辑(目标设为主组) + changeGroups(Collections.singletonList(userId), Collections.singletonList(groupId)); + } + @Override @Transactional(rollbackFor = Exception.class) public BatchImportResult batchImport(MultipartFile file) { diff --git a/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java index cea7c2a..f6c5949 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java @@ -4,17 +4,22 @@ 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.SysUserCurrentRole; import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.entity.SysUserRoleRel; 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 com.yunhai.userbase.repository.SysUserRoleRelRepository; +import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -38,6 +43,10 @@ public class UserCenterService { private SysUserGroupRelRepository sysUserGroupRelRepository; @Autowired private SysGroupRepository sysGroupRepository; + @Autowired + private SysUserRoleRelRepository sysUserRoleRelRepository; + @Autowired + private SysUserCurrentRoleRepository sysUserCurrentRoleRepository; public BoaUser getUserInfoWithDeptByAccount(String account) { if (account == null) { @@ -77,11 +86,17 @@ public class UserCenterService { 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()); + // 角色列表:从 sys_user_role_rel(平台身份角色权威源)取正常态 roleId 去重,不再读 sys_user.roles + List roleIds = sysUserRoleRelRepository + .findDistinctRoleIdsByUserIdAndStatus(user.getId(), StatusEnum.NORMAL); + boa.setRoleIds(roleIds); List groups = buildGroups(user.getId()); boa.setGroups(groups); + // 部门/教育局:仍从组织链推导(与主组/区域组语义绑定) deriveOrg(groups, boa); + // 学校/当前激活身份/可选学校:从身份关联表推导(school 语义=当前激活学校) + deriveIdentity(user.getId(), boa); return boa; } @@ -142,4 +157,50 @@ public class UserCenterService { // 部门:主组(链的近端第一个);若主组本身为学校,则部门即学校 boa.setDept(groups.get(0)); } + + /** + * 从身份关联推导当前激活身份与可选学校。school 字段语义=当前激活学校:
+ * ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);
+ * ② 无切换记录时兜底取 {@code sys_user_role_rel.is_main=1} 的整条记录(保证是合法存在的身份, + * 避免「主组+主角色」组合出 B 学校老师的非法身份);
+ * ③ schools:用户在全部学校下的可选身份去重 schoolId(>0)集合。 + */ + private void deriveIdentity(Integer userId, BoaUser boa) { + Integer currentSchoolId = null; + String currentSchoolName = null; + + SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null); + if (current != null && current.getRoleId() != null) { + currentSchoolId = current.getSchoolId(); + currentSchoolName = current.getSchoolName(); + } else { + // 兜底:直接取主身份整条记录(isMain=1),而不是「主组+主角色」组合 + SysUserRoleRel mainRel = sysUserRoleRelRepository + .findByUserIdAndIsMain(userId, 1).orElse(null); + if (mainRel != null) { + currentSchoolId = mainRel.getSchoolId(); + if (currentSchoolId != null && currentSchoolId > 0) { + SysGroup sch = sysGroupRepository.findById(currentSchoolId).orElse(null); + currentSchoolName = sch != null ? sch.getName() : null; + } + } + } + + if (currentSchoolId != null && currentSchoolId > 0) { + boa.setSchool(toBoaGroup(currentSchoolId, currentSchoolName)); + } + } + + /** 组装一个 BoaGroup(仅填充 id 与 name;name 为 null 时回源查 sys_group) */ + private BoaGroup toBoaGroup(Integer groupId, String fallbackName) { + BoaGroup bg = new BoaGroup(); + bg.setId(groupId); + if (fallbackName != null) { + bg.setName(fallbackName); + } else { + SysGroup g = sysGroupRepository.findById(groupId).orElse(null); + bg.setName(g != null ? g.getName() : null); + } + return bg; + } } From 44f966c33bea9fc262a679725d18e13f5cd7c778 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 18 Aug 2026 21:51:18 +0800 Subject: [PATCH 47/58] =?UTF-8?q?=E5=85=B6=E4=BB=96=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cleanup_orphan_roles.sql | 40 +++++ .../controller/api/IdentityController.java | 62 ++++++++ .../userbase/entity/SysUserRoleRel.java | 86 +++++++++++ .../model/identity/IdentityCurrentVo.java | 32 ++++ .../model/identity/IdentityOptionVo.java | 33 ++++ .../model/identity/IdentitySwitchRequest.java | 25 +++ .../model/sysuser/SysUserMoveRequest.java | 24 +++ .../SysUserCurrentRoleRepository.java | 17 ++ .../repository/SysUserRoleRelRepository.java | 39 +++++ .../service/impl/IdentityService.java | 146 ++++++++++++++++++ src/main/resources/sql/fix_roles_mapping.sql | 78 ++++++++++ 11 files changed, 582 insertions(+) create mode 100644 cleanup_orphan_roles.sql create mode 100644 src/main/java/com/yunhai/userbase/controller/api/IdentityController.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java create mode 100644 src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/SysUserMoveRequest.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/IdentityService.java create mode 100644 src/main/resources/sql/fix_roles_mapping.sql diff --git a/cleanup_orphan_roles.sql b/cleanup_orphan_roles.sql new file mode 100644 index 0000000..03ff2c7 --- /dev/null +++ b/cleanup_orphan_roles.sql @@ -0,0 +1,40 @@ +-- ============================================================ +-- 清理孤儿业务角色(指向不存在的应用) +-- 场景:角色授权页 RoleModuleTree?roleId=2 报 "角色所属应用不存在: 3" +-- 根因:sys_app_role 中存在 all_app_flag=0 的普通角色,其 app_id 指向 +-- application_info 中已不存在的应用(应用被删,当时无级联清理)。 +-- 说明:本脚本只读诊断 + 备份 + 清理,请先执行第 1 步确认,再执行第 2/3 步。 +-- ============================================================ + +-- 1) 诊断:列出全部"指向不存在应用"的普通角色(孤儿角色) +SELECT r.id AS role_id, + r.name AS role_name, + r.app_id AS missing_app_id +FROM sys_app_role r +WHERE r.all_app_flag = 0 + AND r.app_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM application_info a WHERE a.id = r.app_id + ); + +-- 2) 备份(执行清理前先保留,便于回滚) +CREATE TABLE IF NOT EXISTS _bak_orphan_role LIKE sys_app_role; +INSERT INTO _bak_orphan_role +SELECT * FROM sys_app_role r +WHERE r.all_app_flag = 0 + AND r.app_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM application_info a WHERE a.id = r.app_id + ); + +CREATE TABLE IF NOT EXISTS _bak_orphan_role_rel LIKE sys_app_role_module_rel; +INSERT INTO _bak_orphan_role_rel +SELECT * FROM sys_app_role_module_rel +WHERE role_id IN (SELECT id FROM _bak_orphan_role); + +-- 3) 清理:先删模块授权关系,再删孤儿角色本身 +DELETE FROM sys_app_role_module_rel +WHERE role_id IN (SELECT id FROM _bak_orphan_role); + +DELETE FROM sys_app_role +WHERE id IN (SELECT id FROM _bak_orphan_role); diff --git a/src/main/java/com/yunhai/userbase/controller/api/IdentityController.java b/src/main/java/com/yunhai/userbase/controller/api/IdentityController.java new file mode 100644 index 0000000..4c3c669 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/IdentityController.java @@ -0,0 +1,62 @@ +package com.yunhai.userbase.controller.api; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.model.identity.IdentityCurrentVo; +import com.yunhai.userbase.model.identity.IdentityOptionVo; +import com.yunhai.userbase.model.identity.IdentitySwitchRequest; +import com.yunhai.userbase.service.impl.IdentityService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 用户身份(平台身份角色 × 学校)管理接口。

+ * 走 CAS 会话路线({@code /{platform}/identity/}),当前用户从 CAS 会话注入的 + * {@link BoaUser#getId()} 取,不接受外部传入 userId(防越权)。
+ * 三个端点对应「切换身份」业务:
+ * ① {@code GET current} 当前激活身份(无切换记录则兜底主身份);
+ * ② {@code GET available} 全部可选身份清单;
+ * ③ {@code POST switch} 切换到指定 (roleId, schoolId),服务端校验属主后写入当前激活状态。 + * + * @author chenx + * @date 2026/8/18 + */ +@RestController +@RequestMapping(Config.API_PATH + "/identity/") +public class IdentityController { + + @Autowired + private IdentityService identityService; + + /** + * 当前激活身份。 + */ + @GetMapping("current") + public IdentityCurrentVo current(BoaUser authUserWithDept) { + return identityService.getCurrent(authUserWithDept.getId()); + } + + /** + * 全部可选身份(正常态),供「切换身份」页渲染。 + */ + @GetMapping("available") + public List available(BoaUser authUserWithDept) { + return identityService.listAvailable(authUserWithDept.getId()); + } + + /** + * 切换身份:校验属主后在 sys_user_current_role 写当前激活状态。 + */ + @PostMapping("switch") + public IdentityCurrentVo switchIdentity(BoaUser authUserWithDept, + @RequestBody @Validated IdentitySwitchRequest request) { + return identityService.switchIdentity(authUserWithDept.getId(), request); + } +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java new file mode 100644 index 0000000..07cb6a6 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java @@ -0,0 +1,86 @@ +package com.yunhai.userbase.entity; + +import com.yunhai.userbase.common.converter.StatusEnumConverter; +import com.yunhai.userbase.enums.StatusEnum; +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/8/18 + * @description 用户-SysRoleEnum角色-学校 三元关联(平台身份角色的权威源)。 + *

区别于 {@link SysUserAppRoleRel}(业务权限角色):本表表达"用户在哪个学校下激活哪个平台身份角色" + * (如"刘文娟在 A 学校是老师"),角色取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key。

+ *

不继承 BaseEntity:高频读写关系表,自定义字段更轻量,与 {@link SysUserGroupRel} 风格一致。

+ */ +@Data +@NoArgsConstructor +@Entity +@Table( + name = "sys_user_role_rel", + uniqueConstraints = @UniqueConstraint(columnNames = {"userId", "roleId", "schoolId"}), + indexes = { + @Index(columnList = "userId"), + @Index(columnList = "schoolId"), + @Index(columnList = "roleId") + } +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysUserRoleRel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @NotNull(groups = Update.class) + private Integer id; + + /** + * 用户ID + */ + @Column(nullable = false) + private Integer userId; + + /** + * 角色ID(SysRoleEnum.key,1-7) + */ + @Column(nullable = false) + private Integer roleId; + + /** + * 学校组ID(=type=SCHOOL_GROUP 的 sys_group.id);区域/学段/根直属用户填 0 + */ + @Column(nullable = false) + private Integer schoolId; + + /** + * 是否主身份标记:每 userId 最多 1 条 isMain=1(应用层校验,不靠 uk) + */ + @Column(nullable = false) + private Integer isMain = 0; + + /** + * 排序号:越小越靠前 + */ + @Column(name = "sort_no", nullable = false) + private Integer sortNo = 0; + + /** + * 状态:0=禁用 1=正常(StatusEnum) + */ + @Convert(converter = StatusEnumConverter.class) + @Column(nullable = false) + private StatusEnum status = StatusEnum.NORMAL; + + @Column(name = "create_time", nullable = false, updatable = false) + private LocalDateTime createTime; + + @Column(name = "update_time", nullable = false) + private LocalDateTime updateTime; + + public @interface Update { + } +} diff --git a/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java b/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java new file mode 100644 index 0000000..60adcaa --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.model.identity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 当前激活身份(来自 {@code sys_user_current_role};无切换记录时兜底主身份)。
+ * + * @author chenx + * @date 2026/8/18 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IdentityCurrentVo { + + /** 当前激活角色ID(SysRoleEnum.key) */ + private Integer roleId; + + /** 当前激活角色名 */ + private String roleName; + + /** 当前激活学校ID(=学校组 sys_group.id;无学校时为 0) */ + private Integer schoolId; + + /** 当前激活学校名 */ + private String schoolName; + + /** 是否兜底(无主动切换记录时为 true,提示前端可引导用户显式切换) */ + private Boolean fallback; +} diff --git a/src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java b/src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java new file mode 100644 index 0000000..c06e657 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.model.identity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 用户可选身份(一条 = 某学校下的某平台角色)。
+ * 数据源:{@code sys_user_role_rel}(平台身份角色权威源)。
+ * + * @author chenx + * @date 2026/8/18 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IdentityOptionVo { + + /** 角色ID(SysRoleEnum.key,1-7) */ + private Integer roleId; + + /** 角色名(老师/校领导/学生…,查枚举快取) */ + private String roleName; + + /** 学校ID(=学校组 sys_group.id;区域/学段/直属无学校时为 0) */ + private Integer schoolId; + + /** 学校名(冗余,免 join sys_group) */ + private String schoolName; + + /** 是否主身份(isMain=1) */ + private Boolean isMain; +} diff --git a/src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchRequest.java b/src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchRequest.java new file mode 100644 index 0000000..f7f88d7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchRequest.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.model.identity; + +import lombok.Data; + +import javax.validation.constraints.NotNull; + +/** + * 切换身份入参:指定目标学校与该学校下的角色。
+ * 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态,
+ * 不存在则拒绝(防止越权切换到非授权身份)。
+ * + * @author chenx + * @date 2026/8/18 + */ +@Data +public class IdentitySwitchRequest { + + /** 目标角色ID(SysRoleEnum.key) */ + @NotNull(message = "角色ID不能为空") + private Integer roleId; + + /** 目标学校ID(=学校组 sys_group.id;无学校维度传 0) */ + @NotNull(message = "学校ID不能为空") + private Integer schoolId; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserMoveRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserMoveRequest.java new file mode 100644 index 0000000..abad1a7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserMoveRequest.java @@ -0,0 +1,24 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import javax.validation.constraints.NotNull; + +/** + * @description 开放接口:移动用户到指定组请求(语义同后台「调整组」——覆盖式把用户组归属替换为目标组,目标设为主组)。 + */ +@Data +public class SysUserMoveRequest { + + /** 待移动用户ID。 */ + @NotNull(message = "用户ID不能为空") + private Integer userId; + + /** 目标组ID(移动后的主归属组,必须存在)。 */ + @NotNull(message = "目标组ID不能为空") + private Integer groupId; + + /** 学校组ID:限定移动范围,仅允许将用户在同一学校(组)范围内移动。 */ + @NotNull(message = "学校ID不能为空") + private Integer schoolId; +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java new file mode 100644 index 0000000..bd79400 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java @@ -0,0 +1,17 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysUserCurrentRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.Optional; + +/** + * @author chenx + * @date 2026/8/18 + * @description 用户当前激活身份仓储(一对一:user_id 唯一) + */ +public interface SysUserCurrentRoleRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByUserId(Integer userId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java new file mode 100644 index 0000000..44b7af7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java @@ -0,0 +1,39 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysUserRoleRel; +import com.yunhai.userbase.enums.StatusEnum; +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; +import java.util.Optional; + +/** + * @author chenx + * @date 2026/8/18 + * @description 用户-SysRoleEnum角色-学校 关联仓储(平台身份角色权威源) + */ +public interface SysUserRoleRelRepository extends JpaRepository, JpaSpecificationExecutor { + + /** 用户全部可选身份(含禁用态由调用方过滤) */ + List findByUserId(Integer userId); + + /** 用户某状态下的全部可选身份 */ + List findByUserIdAndStatus(Integer userId, StatusEnum status); + + /** 用户主身份(isMain=1),最多 1 条 */ + Optional findByUserIdAndIsMain(Integer userId, Integer isMain); + + /** 用户在某学校下、某状态下的全部角色关联 */ + List findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status); + + /** 用户所有正常态的 roleId 去重列表(用于 BoaUser.roleIds) */ + @Query("SELECT DISTINCT r.roleId FROM SysUserRoleRel r WHERE r.userId = :userId AND r.status = :status") + List findDistinctRoleIdsByUserIdAndStatus(@Param("userId") Integer userId, @Param("status") StatusEnum status); + + /** 用户所有正常态的 schoolId 去重列表(用于 BoaUser.schools,schoolId>0 的过滤在调用方做) */ + @Query("SELECT DISTINCT r.schoolId FROM SysUserRoleRel r WHERE r.userId = :userId AND r.status = :status") + List findDistinctSchoolIdsByUserIdAndStatus(@Param("userId") Integer userId, @Param("status") StatusEnum status); +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java new file mode 100644 index 0000000..ecbdd3e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java @@ -0,0 +1,146 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUserCurrentRole; +import com.yunhai.userbase.entity.SysUserRoleRel; +import com.yunhai.userbase.enums.IEnum; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.enums.SysRoleEnum; +import com.yunhai.userbase.model.identity.IdentityCurrentVo; +import com.yunhai.userbase.model.identity.IdentityOptionVo; +import com.yunhai.userbase.model.identity.IdentitySwitchRequest; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; +import com.yunhai.userbase.repository.SysUserRoleRelRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * 用户身份服务:管理「平台身份角色 × 学校」维度的当前激活状态与可选清单。

+ * 身份 = (userId, roleId, schoolId) 三元组,权威源为 {@code sys_user_role_rel};
+ * 当前激活状态存 {@code sys_user_current_role}(1:1,每用户一条)。
+ * 本服务不处理业务权限角色(sys_app_role),那套与学校维度无关。 + * + * @author chenx + * @date 2026/8/18 + */ +@Service +public class IdentityService { + + @Autowired + private SysUserRoleRelRepository sysUserRoleRelRepository; + @Autowired + private SysUserCurrentRoleRepository sysUserCurrentRoleRepository; + @Autowired + private SysGroupRepository sysGroupRepository; + + /** + * 用户全部可选身份(正常态),用于「切换身份」页渲染。 + */ + public List listAvailable(Integer userId) { + List rels = sysUserRoleRelRepository + .findByUserIdAndStatus(userId, StatusEnum.NORMAL); + List result = new ArrayList<>(rels.size()); + for (SysUserRoleRel rel : rels) { + IdentityOptionVo vo = new IdentityOptionVo(); + vo.setRoleId(rel.getRoleId()); + vo.setRoleName(roleName(rel.getRoleId())); + vo.setSchoolId(rel.getSchoolId()); + vo.setSchoolName(schoolName(rel.getSchoolId())); + vo.setIsMain(rel.getIsMain() != null && rel.getIsMain() == 1); + result.add(vo); + } + return result; + } + + /** + * 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(isMain=1)。 + */ + public IdentityCurrentVo getCurrent(Integer userId) { + Optional currentOpt = sysUserCurrentRoleRepository.findByUserId(userId); + IdentityCurrentVo vo = new IdentityCurrentVo(); + if (currentOpt.isPresent()) { + SysUserCurrentRole current = currentOpt.get(); + vo.setRoleId(current.getRoleId()); + vo.setRoleName(roleName(current.getRoleId())); + vo.setSchoolId(current.getSchoolId()); + vo.setSchoolName(current.getSchoolName()); + vo.setFallback(false); + return vo; + } + // 兜底:主身份整条记录(isMain=1) + SysUserRoleRel mainRel = sysUserRoleRelRepository.findByUserIdAndIsMain(userId, 1).orElse(null); + if (mainRel != null) { + vo.setRoleId(mainRel.getRoleId()); + vo.setRoleName(roleName(mainRel.getRoleId())); + vo.setSchoolId(mainRel.getSchoolId()); + vo.setSchoolName(schoolName(mainRel.getSchoolId())); + vo.setFallback(true); + return vo; + } + // 连主身份都没有(测试期初数据尚未建立):返回空对象,前端引导创建身份 + vo.setFallback(true); + return vo; + } + + /** + * 切换身份:校验 (userId, roleId, schoolId) 在 sys_user_role_rel 正常态中存在, + * 再 UPSERT sys_user_current_role(按 userId 唯一)。 + */ + public IdentityCurrentVo switchIdentity(Integer userId, IdentitySwitchRequest request) { + Integer roleId = request.getRoleId(); + Integer schoolId = request.getSchoolId(); + // 校验目标身份确属该用户且正常态,防越权切换 + SysUserRoleRel rel = sysUserRoleRelRepository + .findByUserIdAndSchoolIdAndStatus(userId, schoolId, StatusEnum.NORMAL) + .stream() + .filter(r -> Objects.equals(r.getRoleId(), roleId)) + .findFirst() + .orElse(null); + if (rel == null) { + throw new DException("目标身份不存在或非正常态,无法切换"); + } + + SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null); + if (current == null) { + current = new SysUserCurrentRole(); + current.setUserId(userId); + } + current.setRoleId(roleId); + current.setSchoolId(schoolId); + current.setSchoolName(schoolName(schoolId)); + current.setSwitchTime(LocalDateTime.now()); + sysUserCurrentRoleRepository.save(current); + + IdentityCurrentVo vo = new IdentityCurrentVo(); + vo.setRoleId(roleId); + vo.setRoleName(roleName(roleId)); + vo.setSchoolId(schoolId); + vo.setSchoolName(current.getSchoolName()); + vo.setFallback(false); + return vo; + } + + private String roleName(Integer roleId) { + if (roleId == null) { + return null; + } + SysRoleEnum e = IEnum.getByKey(SysRoleEnum.class, roleId); + return e != null ? e.getValue() : null; + } + + private String schoolName(Integer schoolId) { + if (schoolId == null || schoolId <= 0) { + return null; + } + SysGroup g = sysGroupRepository.findById(schoolId).orElse(null); + return g != null ? g.getName() : null; + } +} diff --git a/src/main/resources/sql/fix_roles_mapping.sql b/src/main/resources/sql/fix_roles_mapping.sql new file mode 100644 index 0000000..b224e4b --- /dev/null +++ b/src/main/resources/sql/fix_roles_mapping.sql @@ -0,0 +1,78 @@ +-- ============================================================ +-- 修复 sys_user.roles 旧映射 -> SysRoleEnum 新映射 +-- ------------------------------------------------------------ +-- 背景: +-- 前端表单与导出注释历史上使用「旧映射」: +-- 1=管理员 2=老师 3=校领导 4=局领导 5=学生 6=家长 7=工作号 +-- 而后端权威枚举 SysRoleEnum 现为: +-- 1=老师 2=校领导 3=学生 4=家长 5=局领导 6=工作号 7=管理员 +-- 旧表单勾选「老师」会提交 value=2,被后端按 SysRoleEnum 存成「校领导」, +-- 于是教师/校领导的 roles 出现 [2,3] 这类错位值。 +-- +-- 本脚本把存量 roles 从「旧映射」逐值改写为 SysRoleEnum 真值。 +-- 前端已改为直接吃 GetSysDict.sysrole(SysRoleEnum 真源),新数据不会再错位。 +-- +-- ⚠️ 重要前提(执行前必读): +-- 1) 本脚本假设 sys_user.roles 的全部现有值都是「旧映射」格式。 +-- 若部分用户曾通过「批量导入」创建(导入路径一直用 SysRoleEnum 新值), +-- 这些行已是新格式,执行本脚本会将其再次错位 —— 必须先跑下面的校验 SQL 确认。 +-- 2) 必须先执行备份语句;本脚本不自动执行,请由人工在数据库上运行。 +-- 3) roles 列在 MySQL 中存为 TEXT(内容为 JSON 数组串,如 "[2,3]"), +-- 下方 UPDATE 用 MySQL 8.0 的 JSON_TABLE / JSON_ARRAYAGG,请确认数据库为 8.0。 +-- ============================================================ + +-- ---------- 第 0 步:备份(务必先执行,可逆) ---------- +CREATE TABLE sys_user_roles_bak_20260817 AS +SELECT id, roles, type FROM sys_user; + +-- ---------- 第 1 步:只读校验,看清现有格式 ---------- +-- 重点看 distinct roles 的值: +-- 若看到 [2,3] / [1] / [7] 这类(旧映射含义:老师/管理员/工作号),说明是旧格式; +-- 若看到 [1,2](新映射含义:老师+校领导),说明已是新格式。 +-- 只有当所有行都符合「旧格式」语义时,才执行第 2 步。 +SELECT roles, COUNT(*) AS cnt +FROM sys_user +WHERE roles IS NOT NULL AND roles <> '' AND roles <> '[]' +GROUP BY roles +ORDER BY cnt DESC; + +-- ---------- 第 2 步:旧映射 -> SysRoleEnum 新映射 逐值替换 ---------- +-- 旧/新 双射: +-- 1(管理员)->7 2(老师)->1 3(校领导)->2 4(局领导)->5 +-- 5(学生)->3 6(家长)->4 7(工作号)->6 +UPDATE sys_user u +SET u.roles = ( + SELECT JSON_ARRAYAGG( + CASE j.r + WHEN 1 THEN 7 -- 旧:管理员 -> 新:管理员 + WHEN 2 THEN 1 -- 旧:老师 -> 新:老师 + WHEN 3 THEN 2 -- 旧:校领导 -> 新:校领导 + WHEN 4 THEN 5 -- 旧:局领导 -> 新:局领导 + WHEN 5 THEN 3 -- 旧:学生 -> 新:学生 + WHEN 6 THEN 4 -- 旧:家长 -> 新:家长 + WHEN 7 THEN 6 -- 旧:工作号 -> 新:工作号 + ELSE j.r + END + ) + FROM JSON_TABLE(CAST(u.roles AS JSON), '$[*]' COLUMNS (r INT PATH '$')) AS j +) +WHERE u.roles IS NOT NULL AND u.roles <> '' AND u.roles <> '[]'; + +-- ---------- 第 3 步(可选):重算 type 列(用户类型) ---------- +-- type 由 resolveUserType 推导:含 ADMIN(7) -> 2(学校管理员),否则 0(普通用户); +-- 超管账号(id 10000~10002, type=1)保持不变。旧数据里 type 曾被旧映射带偏,这里一并纠正。 +UPDATE sys_user +SET type = 2 +WHERE type <> 1 + AND JSON_CONTAINS(CAST(roles AS JSON), JSON_ARRAY(7)); + +UPDATE sys_user +SET type = 0 +WHERE type <> 1 + AND NOT JSON_CONTAINS(CAST(roles AS JSON), JSON_ARRAY(7)); + +-- ---------- 回滚(如需) ---------- +-- INSERT INTO sys_user (id, roles, type) +-- SELECT id, roles, type FROM sys_user_roles_bak_20260817 +-- ON DUPLICATE KEY UPDATE roles = VALUES(roles), type = VALUES(type); +-- DROP TABLE sys_user_roles_bak_20260817; From 2ca67730f7a50bf415d6b0638d106a950bce07fc Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 19 Aug 2026 11:28:47 +0800 Subject: [PATCH 48/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=BC=A0=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userbase/controller/api/SysOperationLogController.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java b/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java index 630e2cf..e980c23 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java @@ -6,6 +6,7 @@ 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 io.swagger.v3.oas.annotations.Operation; import lombok.extern.slf4j.Slf4j; import java.io.ByteArrayInputStream; @@ -39,16 +40,18 @@ public class SysOperationLogController { /** * 分页查询操作日志列表(不含响应结果)。 */ + @Operation(summary = "操作日志分页查询", operationId = "operationLogList") @GetMapping("/List") public BasePageResult list(SysOperationLogQuery query, BasePageQuery pageQuery) { return sysOperationLogService.pageQuery(query, pageQuery); } /** - * 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。
+ * 下载某条日志的响应结果(GZIP 解压后原文, 文本文件)。
* 采用与项目 DownloadFileUtil 一致的 octet-stream + attachment 写法,确保浏览器稳定触发下载。
* 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。 */ + @Operation(summary = "下载日志响应原文", operationId = "operationLogDownload") @GetMapping("/Download/{id}") public ResponseEntity download(@PathVariable Long id) { String data = sysOperationLogService.getResponseData(id); From b521f9634b30aae53e258ceb59226fc71930a3dc Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 19 Aug 2026 12:01:52 +0800 Subject: [PATCH 49/58] =?UTF-8?q?spring=20doc=E6=96=87=E6=A1=A3=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/controller/api/SysGroupController.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java b/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java index 7252ed2..82fbeed 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java @@ -10,6 +10,7 @@ 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 io.swagger.v3.oas.annotations.Operation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -29,23 +30,27 @@ public class SysGroupController { private ISysGroupService sysGroupService; @OperationLog("新增组") + @Operation(operationId = "sysGroupAdd") @PostMapping("Add") public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) { sysGroupService.add(request); } @OperationLog("编辑组") + @Operation(operationId = "sysGroupEdit") @PostMapping("Edit/{id}") public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) { sysGroupService.edit(id, request); } @OperationLog("删除组") + @Operation(operationId = "sysGroupRemove") @PostMapping("Delete/{id}") public void delete(@PathVariable Integer id) { sysGroupService.delete(id); } + @Operation(operationId = "sysGroupList") @GetMapping("/List") public List list(SysGroupQuery query) { return sysGroupService.list(query); From 4c67ec0ed0fb74709526bb9e5e58e5f3db636e6d Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 19 Aug 2026 17:22:38 +0800 Subject: [PATCH 50/58] =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=8A=9F=E8=83=BD=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userbase/service/impl/SysUserService.java | 7 +--- .../userbase/service/push/PushDispatcher.java | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index f493621..3d87eb5 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -229,7 +229,6 @@ public class SysUserService implements ISysUserService { throw new DException("存在无效用户"); } - List deletedUserIds = new ArrayList<>(); for (Integer userId : userIds) { // 删除当前组关联 sysUserGroupRelRepository.deleteByUserIdAndGroupId(userId, groupId); @@ -237,12 +236,10 @@ public class SysUserService implements ISysUserService { if (sysUserGroupRelRepository.countByUserId(userId) == 0) { sysUserAppRoleRelRepository.deleteByUserIdBulk(userId); sysUserRepository.deleteById(userId); - deletedUserIds.add(userId); } } - if (!deletedUserIds.isEmpty()) { - publishUserEvent(PushInterfaceType.DELETE_USER, deletedUserIds); - } + // 业务语义:从组移除 = 对子应用即删除(不论是否真删账户),统一推送 DELETE_USER + publishUserEvent(PushInterfaceType.DELETE_USER, userIds); } @Override diff --git a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java index c69e0cd..5092623 100644 --- a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java +++ b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java @@ -65,6 +65,16 @@ public class PushDispatcher { if (type == null) { return; } + // 删除类事件:实体已从库中删除,AFTER_COMMIT 阶段重载必然为空, + // 直接用事件携带的 id 构建删除体;删除幂等,绕过范围过滤全量投递给已开启删除事件的订阅方 + if (type == PushInterfaceType.DELETE_GROUP || type == PushInterfaceType.DELETE_USER) { + List ids = (type == PushInterfaceType.DELETE_GROUP) + ? event.getGroupIds() : event.getUserIds(); + for (ApplicationInfo app : applicationInfoRepository.findByPushEnabled(1)) { + dispatchDeleteToOne(app, type, ids, "auto"); + } + return; + } List groups = (event.getGroupIds() != null && !event.getGroupIds().isEmpty()) ? sysGroupRepository.findAllById(event.getGroupIds()) : Collections.emptyList(); List users = (event.getUserIds() != null && !event.getUserIds().isEmpty()) @@ -177,6 +187,36 @@ public class PushDispatcher { // ===================== 核心 ===================== + /** 删除类事件专用投递:跳过 DB 重载与范围过滤,直接用事件携带的 id 构建删除体。 */ + private void dispatchDeleteToOne(ApplicationInfo app, PushInterfaceType type, List ids, String triggerType) { + if (app.getPushEnabled() == null || app.getPushEnabled() != 1) { + return; + } + // 仍校验订阅方是否勾选了删除接口 + if (!containsEventType(app.getPushEventTypes(), type.getPath())) { + return; + } + if (ids == null || ids.isEmpty()) { + return; + } + for (Object bodyObj : buildDeleteBodies(type, ids)) { + sendMessage(app, type, bodyObj, triggerType); + } + } + + /** 删除类接口 body 形态:DELETE_GROUP 每个 id 一条 {id};DELETE_USER 整体一条数组 [id,...]。 */ + private List buildDeleteBodies(PushInterfaceType type, List ids) { + if (type == PushInterfaceType.DELETE_GROUP) { + return ids.stream() + .map(id -> Collections.singletonMap("id", id)) + .collect(Collectors.toList()); + } + if (type == PushInterfaceType.DELETE_USER) { + return Collections.singletonList(new ArrayList<>(ids)); + } + return Collections.emptyList(); + } + private void dispatchToOne(ApplicationInfo app, PushInterfaceType type, List groups, List users, String triggerType, boolean force) { if (app.getPushEnabled() == null || app.getPushEnabled() != 1) { From a91dacd442a8582621d57fb895e8952a4eac29c2 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 19 Aug 2026 17:35:55 +0800 Subject: [PATCH 51/58] =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/event/DataSyncEvent.java | 2 ++ .../userbase/service/impl/SysUserService.java | 15 ++++++++++- .../userbase/service/push/PushDispatcher.java | 26 ++++++++++++++----- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java b/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java index 8e415cb..c1d667f 100644 --- a/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java +++ b/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java @@ -22,4 +22,6 @@ public class DataSyncEvent { private List groupIds; /** 用户事件携带的受影响用户 id 列表 */ private List userIds; + /** 组维度的关联事件(如删除用户=解除组关系)携带的组 id */ + private Integer groupId; } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 3d87eb5..c547268 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -239,7 +239,8 @@ public class SysUserService implements ISysUserService { } } // 业务语义:从组移除 = 对子应用即删除(不论是否真删账户),统一推送 DELETE_USER - publishUserEvent(PushInterfaceType.DELETE_USER, userIds); + // 因是「解除组关系」,需携带 (userId, groupId) 供子应用精确处理 + publishUserEvent(PushInterfaceType.DELETE_USER, userIds, groupId); } @Override @@ -1019,4 +1020,16 @@ public class SysUserService implements ISysUserService { e.setUserIds(userIds); eventPublisher.publishEvent(e); } + + /** 组维度的用户事件(如 DELETE_USER=解除组关系),额外携带组 id */ + private void publishUserEvent(PushInterfaceType type, List userIds, Integer groupId) { + if (userIds == null || userIds.isEmpty()) { + return; + } + DataSyncEvent e = new DataSyncEvent(); + e.setType(type); + e.setUserIds(userIds); + e.setGroupId(groupId); + eventPublisher.publishEvent(e); + } } diff --git a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java index 5092623..58515ce 100644 --- a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java +++ b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java @@ -70,8 +70,9 @@ public class PushDispatcher { if (type == PushInterfaceType.DELETE_GROUP || type == PushInterfaceType.DELETE_USER) { List ids = (type == PushInterfaceType.DELETE_GROUP) ? event.getGroupIds() : event.getUserIds(); + Integer groupId = event.getGroupId(); for (ApplicationInfo app : applicationInfoRepository.findByPushEnabled(1)) { - dispatchDeleteToOne(app, type, ids, "auto"); + dispatchDeleteToOne(app, type, ids, groupId, "auto"); } return; } @@ -188,7 +189,8 @@ public class PushDispatcher { // ===================== 核心 ===================== /** 删除类事件专用投递:跳过 DB 重载与范围过滤,直接用事件携带的 id 构建删除体。 */ - private void dispatchDeleteToOne(ApplicationInfo app, PushInterfaceType type, List ids, String triggerType) { + private void dispatchDeleteToOne(ApplicationInfo app, PushInterfaceType type, List ids, + Integer groupId, String triggerType) { if (app.getPushEnabled() == null || app.getPushEnabled() != 1) { return; } @@ -199,20 +201,32 @@ public class PushDispatcher { if (ids == null || ids.isEmpty()) { return; } - for (Object bodyObj : buildDeleteBodies(type, ids)) { + for (Object bodyObj : buildDeleteBodies(type, ids, groupId)) { sendMessage(app, type, bodyObj, triggerType); } } - /** 删除类接口 body 形态:DELETE_GROUP 每个 id 一条 {id};DELETE_USER 整体一条数组 [id,...]。 */ - private List buildDeleteBodies(PushInterfaceType type, List ids) { + /** + * 删除类接口 body 形态:
+ * DELETE_GROUP:每个 id 一条 {@code {id}};
+ * DELETE_USER:因是「解除组关系」,每行携带 {@code {userId, groupId}}(整体一条数组)。 + */ + private List buildDeleteBodies(PushInterfaceType type, List ids, Integer groupId) { if (type == PushInterfaceType.DELETE_GROUP) { return ids.stream() .map(id -> Collections.singletonMap("id", id)) .collect(Collectors.toList()); } if (type == PushInterfaceType.DELETE_USER) { - return Collections.singletonList(new ArrayList<>(ids)); + // 单组维度批量移出,所有 userId 共享同一 groupId + return ids.stream() + .map(userId -> { + Map item = new LinkedHashMap<>(); + item.put("userId", userId); + item.put("groupId", groupId); + return item; + }) + .collect(Collectors.toList()); } return Collections.emptyList(); } From f3656057d4ee9a6052651370012b388a46a42e05 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 19 Aug 2026 20:38:00 +0800 Subject: [PATCH 52/58] =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/config/DWebMvcConfig.java | 5 ++ .../controller/oapi/GroupController.java | 9 ++-- .../controller/oapi/UserController.java | 18 +++++-- .../yunhai/userbase/event/DataSyncEvent.java | 3 ++ .../userbase/service/ISysGroupService.java | 12 +++++ .../userbase/service/ISysUserService.java | 21 ++++++++ .../service/impl/SysGroupService.java | 40 ++++++++++++++ .../userbase/service/impl/SysUserService.java | 52 +++++++++++++++++++ .../userbase/service/push/PushDispatcher.java | 8 +++ 9 files changed, 160 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java b/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java index 5695ada..ebb16e9 100644 --- a/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java +++ b/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java @@ -12,6 +12,8 @@ public class DWebMvcConfig implements WebMvcConfigurer { @Autowired private BoaUserContextInterceptor boaUserContextInterceptor; + @Autowired + private OapiCallerAppInterceptor oapiCallerAppInterceptor; @Override public void addCorsMappings(CorsRegistry registry) @@ -25,6 +27,9 @@ public class DWebMvcConfig implements WebMvcConfigurer @Override public void addInterceptors(InterceptorRegistry registry) { + // oapi 调用方来源注入:仅命中 /oapi/**,供推送来源排除使用 + registry.addInterceptor(oapiCallerAppInterceptor) + .addPathPatterns("/oapi/**"); registry.addInterceptor(boaUserContextInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**"); diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java index 5cab5cb..86ff82f 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java @@ -87,11 +87,12 @@ public class GroupController { } /** - * 删除组:组下存在子组或用户时拒绝删除(根节点不可删)。无返回值。 + * 删除组:组下存在子组或用户时拒绝删除(根节点不可删)。
+ * 新增 {@code schoolId} 边界:服务端强制 id 属于该校子树且不等于 schoolId,避免删学校根组/跨校删组。 */ - @PostMapping("delete/{id}") - public void delete(@PathVariable Integer id) { - sysGroupService.delete(id); + @PostMapping("delete/{id}/{schoolId}") + public void delete(@PathVariable Integer id, @PathVariable Integer schoolId) { + sysGroupService.delete(id, schoolId); } /** diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index 1763a2e..c34b6ba 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -8,6 +8,7 @@ import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; import com.yunhai.userbase.model.sysuser.SysUserMoveRequest; import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.repository.ApplicationInfoRepository; @@ -168,11 +169,20 @@ public class UserController { * 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;
* 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。
* 这样多校用户只会被退出指定学校,而不会被整体误删。
- * 若用户仅归属这一个组,调用后该用户将被真正删除(组关联清空即触发级联删)。 + * 新增 {@code schoolId} 边界:服务端强制 groupId 属于该校子树且不等于 schoolId,避免跨校/整校移除。 */ - @PostMapping("delete/{id}/{groupId}") - public void delete(@PathVariable Integer id, @PathVariable Integer groupId) { - sysUserService.deleteFromGroup(id, groupId); + @PostMapping("delete/{id}/{groupId}/{schoolId}") + public void delete(@PathVariable Integer id, @PathVariable Integer groupId, @PathVariable Integer schoolId) { + sysUserService.deleteFromGroup(id, groupId, schoolId); + } + + /** + * 批量删除用户-组关系:请求体为 {@code SysUserGroupDeleteItem} 数组,每条携带自身 (userId, groupId, schoolId);
+ * 服务端逐条校验学校边界后解绑,语义与单条 delete 一致。 + */ + @PostMapping("deleteUsers") + public void deleteUsers(@RequestBody List items) { + sysUserService.deleteUsers(items); } /** diff --git a/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java b/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java index c1d667f..267a2aa 100644 --- a/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java +++ b/src/main/java/com/yunhai/userbase/event/DataSyncEvent.java @@ -24,4 +24,7 @@ public class DataSyncEvent { private List userIds; /** 组维度的关联事件(如删除用户=解除组关系)携带的组 id */ private Integer groupId; + /** 触发本次变更的 oapi 调用方 appKey;非 oapi 来源(如管理后台)为 null。 + * 推送分发器据此回推时排除来源应用自身,避免收到自己的回声。 */ + private Integer sourceAppKey; } diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java index b148b71..0d55f3c 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -23,6 +23,18 @@ public interface ISysGroupService { void delete(Integer id); + /** + * 开放接口专用:在学校(组)范围内删除组。
+ * 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:
+ * ① id 不能等于 schoolId(禁止删学校根组);
+ * ② id 必须属于 schoolId 子树(禁止跨校删组)。
+ * 任意一条不满足即抛异常。 + * + * @param id 待删除组ID + * @param schoolId 学校组ID(限定删除范围) + */ + void delete(Integer id, Integer schoolId); + List list(SysGroupQuery query); /** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */ diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index 113f86a..6eafe45 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -1,6 +1,7 @@ package com.yunhai.userbase.service; import com.yunhai.userbase.model.sysuser.BatchImportResult; +import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; import com.yunhai.userbase.model.sysuser.SysUserQuery; import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.model.sysuser.SysUserVo; @@ -46,6 +47,26 @@ public interface ISysUserService { */ void batchDeleteFromGroup(List userIds, Integer groupId); + /** + * 开放接口专用:在学校(组)范围内按组解绑用户。
+ * 与 {@link #deleteFromGroup(Integer, Integer)} 语义一致,但额外强制边界:
+ * ① groupId 不能等于 schoolId(禁止对学校根组解绑,避免整校移除);
+ * ② groupId 必须属于 schoolId 子树(禁止跨校解绑)。
+ * 任意一条不满足即抛异常。 + * + * @param userId 用户ID + * @param groupId 当前组ID + * @param schoolId 学校组ID(限定解绑范围) + */ + void deleteFromGroup(Integer userId, Integer groupId, Integer schoolId); + + /** + * 开放接口专用:批量删除用户-组关系;每条携带自身 (userId, groupId, schoolId),逐条做上述学校边界校验后解绑。 + * + * @param items 待删除项列表({@link SysUserGroupDeleteItem}) + */ + void deleteUsers(List items); + /** * 启用/禁用用户。
* diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 41e499a..ceb29b5 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -6,6 +6,7 @@ import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.userbase.enums.IEnum; import com.yunhai.userbase.event.DataSyncEvent; +import com.yunhai.userbase.common.context.CallerAppContext; import com.yunhai.userbase.model.push.PushInterfaceType; import org.springframework.context.ApplicationEventPublisher; import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; @@ -199,6 +200,18 @@ public class SysGroupService implements ISysGroupService { @Override @Transactional public void delete(Integer id) { + deleteInternal(id); + } + + @Override + @Transactional + public void delete(Integer id, Integer schoolId) { + // 开放接口边界:id 必须属于 schoolId 子树且不能等于 schoolId(禁止删学校根组/跨校删组) + assertWithinSchool(id, schoolId); + deleteInternal(id); + } + + private void deleteInternal(Integer id) { SysGroup entity = sysGroupRepository.findById(id) .orElseThrow(() -> new DException("系统组不存在")); // 根节点不可删除 @@ -218,6 +231,31 @@ public class SysGroupService implements ISysGroupService { publishGroupEvent(PushInterfaceType.DELETE_GROUP, id); } + /** + * 学校边界校验:确保待删除的组属于指定学校范围,且不是学校根组本身。 + * + * @param groupId 待删除组ID + * @param schoolId 学校组ID(限定范围) + */ + private void assertWithinSchool(Integer groupId, Integer schoolId) { + if (schoolId == null) { + throw new DException("学校ID不能为空"); + } + if (groupId == null) { + throw new DException("组ID不能为空"); + } + if (!sysGroupRepository.existsById(schoolId)) { + throw new DException("学校分组不存在"); + } + if (groupId.equals(schoolId)) { + throw new DException("禁止删除学校根组,请指定具体子组"); + } + List schoolScope = listSelfAndDescendantIds(schoolId); + if (!schoolScope.contains(groupId)) { + throw new DException("组不属于该学校范围,禁止跨校操作"); + } + } + @Override public List list(SysGroupQuery query) { List list; @@ -653,6 +691,7 @@ public class SysGroupService implements ISysGroupService { DataSyncEvent e = new DataSyncEvent(); e.setType(type); e.setGroupIds(Collections.singletonList(groupId)); + e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); } @@ -663,6 +702,7 @@ public class SysGroupService implements ISysGroupService { DataSyncEvent e = new DataSyncEvent(); e.setType(type); e.setGroupIds(groupIds); + e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); } } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index c547268..da27f4d 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -9,6 +9,7 @@ import com.yunhai.userbase.enums.UserTypeEnum; import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.model.sysuser.BatchImportResult; +import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; import com.yunhai.userbase.model.sysuser.SysUserQuery; import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.model.sysuser.SysUserVo; @@ -19,6 +20,7 @@ import com.yunhai.userbase.repository.SysUserRepository; import com.yunhai.userbase.service.ISysGroupService; import com.yunhai.userbase.service.ISysUserService; import com.yunhai.userbase.event.DataSyncEvent; +import com.yunhai.userbase.common.context.CallerAppContext; import com.yunhai.userbase.model.push.PushInterfaceType; import org.springframework.context.ApplicationEventPublisher; import cn.afterturn.easypoi.excel.ExcelExportUtil; @@ -243,6 +245,53 @@ public class SysUserService implements ISysUserService { publishUserEvent(PushInterfaceType.DELETE_USER, userIds, groupId); } + @Override + @Transactional + public void deleteFromGroup(Integer userId, Integer groupId, Integer schoolId) { + // 开放接口边界:groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校/整校移除) + assertGroupWithinSchool(groupId, schoolId); + batchDeleteFromGroup(Collections.singletonList(userId), groupId); + } + + @Override + @Transactional + public void deleteUsers(List items) { + if (items == null || items.isEmpty()) { + throw new DException("批量删除用户-组关系列表不能为空"); + } + for (SysUserGroupDeleteItem item : items) { + deleteFromGroup(item.getUserId(), item.getGroupId(), item.getSchoolId()); + } + } + + /** + * 学校边界校验:确保待操作的组属于指定学校范围,且不是学校根组本身。
+ * 与 {@link #moveUserWithinSchool} 的「学校子树范围」判定一致,复用 {@code listSelfAndDescendantIds}。 + * + * @param groupId 待操作组ID(解绑/删除目标) + * @param schoolId 学校组ID(限定范围) + */ + private void assertGroupWithinSchool(Integer groupId, Integer schoolId) { + if (schoolId == null) { + throw new DException("学校ID不能为空"); + } + if (groupId == null) { + throw new DException("组ID不能为空"); + } + if (!sysGroupRepository.existsById(schoolId)) { + throw new DException("学校分组不存在"); + } + // 拿学校根组当 groupId = 把用户从整所学校移除,禁止 + if (groupId.equals(schoolId)) { + throw new DException("禁止对学校根组执行该操作,请指定具体子组"); + } + // groupId 必须落在 schoolId 子树(含子孙,不含学校根组自身——上面已拦截) + List schoolScope = sysGroupService.listSelfAndDescendantIds(schoolId); + if (!schoolScope.contains(groupId)) { + throw new DException("组不属于该学校范围,禁止跨校操作"); + } + } + @Override @Transactional public void changeStatus(Integer id, boolean disabled) { @@ -1008,6 +1057,7 @@ public class SysUserService implements ISysUserService { DataSyncEvent e = new DataSyncEvent(); e.setType(type); e.setUserIds(Collections.singletonList(userId)); + e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); } @@ -1018,6 +1068,7 @@ public class SysUserService implements ISysUserService { DataSyncEvent e = new DataSyncEvent(); e.setType(type); e.setUserIds(userIds); + e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); } @@ -1030,6 +1081,7 @@ public class SysUserService implements ISysUserService { e.setType(type); e.setUserIds(userIds); e.setGroupId(groupId); + e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); } } diff --git a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java index 58515ce..3a56ddb 100644 --- a/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java +++ b/src/main/java/com/yunhai/userbase/service/push/PushDispatcher.java @@ -72,6 +72,10 @@ public class PushDispatcher { ? event.getGroupIds() : event.getUserIds(); Integer groupId = event.getGroupId(); for (ApplicationInfo app : applicationInfoRepository.findByPushEnabled(1)) { + // 来源排除:应用自己触发的变更(如 oapi 写接口)不回推给自己,避免收到自己的回声 + if (event.getSourceAppKey() != null && event.getSourceAppKey().equals(app.getAppKey())) { + continue; + } dispatchDeleteToOne(app, type, ids, groupId, "auto"); } return; @@ -88,6 +92,10 @@ public class PushDispatcher { users = sysUserRepository.findAll(); } for (ApplicationInfo app : applicationInfoRepository.findByPushEnabled(1)) { + // 来源排除:应用自己触发的变更(如 oapi 写接口)不回推给自己,避免收到自己的回声 + if (event.getSourceAppKey() != null && event.getSourceAppKey().equals(app.getAppKey())) { + continue; + } dispatchToOne(app, type, groups, users, "auto", false); } } catch (Exception e) { From d4729029576834efc7cd034eae41714a112110c9 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 19 Aug 2026 20:38:12 +0800 Subject: [PATCH 53/58] =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/context/CallerAppContext.java | 27 ++++++++++ .../config/OapiCallerAppInterceptor.java | 49 +++++++++++++++++++ .../model/sysuser/SysUserGroupDeleteItem.java | 40 +++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/main/java/com/yunhai/userbase/common/context/CallerAppContext.java create mode 100644 src/main/java/com/yunhai/userbase/config/OapiCallerAppInterceptor.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/SysUserGroupDeleteItem.java diff --git a/src/main/java/com/yunhai/userbase/common/context/CallerAppContext.java b/src/main/java/com/yunhai/userbase/common/context/CallerAppContext.java new file mode 100644 index 0000000..0450bf8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/context/CallerAppContext.java @@ -0,0 +1,27 @@ +package com.yunhai.userbase.common.context; + +/** + * 开放接口(oapi)调用方来源上下文:在 oapi 请求链路中保存"触发本次数据变更的订阅方 appKey", + * 供业务层发布 {@code DataSyncEvent} 时写入事件来源,推送分发器据此回推时排除来源应用自身 + * (避免应用 A 自己调 oapi 写接口后又收到自己的回声)。

+ * 生命周期:由 {@code OapiCallerAppInterceptor} 在 {@code preHandle} 写入、 + * {@code afterCompletion} 强制清除(防线程池复用泄漏)。管理后台 /api/* 写接口不走 oapi 拦截器, + * 此处为 null,表示"来源非订阅方",推送不对其做来源排除(全量投递)。

+ * 设计与 {@link UserContext} 同套路(ThreadLocal + remove)。 + */ +public class CallerAppContext { + + private static final ThreadLocal CALLER_APP_KEY = new ThreadLocal<>(); + + public static void setAppKey(Integer appKey) { + CALLER_APP_KEY.set(appKey); + } + + public static Integer getAppKey() { + return CALLER_APP_KEY.get(); + } + + public static void clear() { + CALLER_APP_KEY.remove(); + } +} diff --git a/src/main/java/com/yunhai/userbase/config/OapiCallerAppInterceptor.java b/src/main/java/com/yunhai/userbase/config/OapiCallerAppInterceptor.java new file mode 100644 index 0000000..ed6f41b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/OapiCallerAppInterceptor.java @@ -0,0 +1,49 @@ +package com.yunhai.userbase.config; + +import com.yunhai.platform.appauth.AppAuthFilter; +import com.yunhai.userbase.common.context.CallerAppContext; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * oapi 调用方来源注入拦截器:在 oapi 请求处理前,从 yh-app-auth 验签过滤器写入的 + * {@link AppAuthFilter#APP_AUTH_ATTR}(调用方 appKey)解析出整型 appKey, + * 存入 {@link CallerAppContext};请求结束(无论成功/异常)在 {@code afterCompletion} 强制清除, + * 防止线程池复用导致 appKey 串号、进而误把其他订阅方排除。

+ * 配合 {@code PushDispatcher} 的来源排除逻辑,实现"应用自己触发的变更不回推给自己"。 + * 管理后台 /api/* 不走此拦截器 → CallerAppContext 为 null → 推送对该来源不做排除(全量投递)。 + */ +@Component +public class OapiCallerAppInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, + HttpServletResponse response, + Object handler) { + Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + if (attr == null) { + return true; + } + String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr); + if (StringUtils.isBlank(appKeyStr)) { + return true; + } + try { + CallerAppContext.setAppKey(Integer.valueOf(appKeyStr.trim())); + } catch (NumberFormatException ignored) { + // 非法 appKey 格式:不设置来源,等同"非订阅方来源",推送全量投递 + } + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, + HttpServletResponse response, + Object handler, Exception ex) { + CallerAppContext.clear(); + } +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserGroupDeleteItem.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserGroupDeleteItem.java new file mode 100644 index 0000000..7645bfb --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserGroupDeleteItem.java @@ -0,0 +1,40 @@ +package com.yunhai.userbase.model.sysuser; + +/** + * 批量删除用户-组关系单项(与 BasePlatform 开放接口模型 {@code SysUserGroupDeleteItem} 字段对齐)。
+ * 每条记录携带自身所属学校范围 {@code schoolId},供 {@code SysUserService.deleteUsers} 逐条校验:
+ * groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校解绑 / 整校移除)。 + */ +public class SysUserGroupDeleteItem { + + /** 待解绑用户 ID */ + private Integer userId; + /** 待解绑的组 ID(必须属于 schoolId 子树,且不能等于 schoolId) */ + private Integer groupId; + /** 学校组 ID:限定该条解绑的学校范围 */ + private Integer schoolId; + + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } + + public Integer getGroupId() { + return groupId; + } + + public void setGroupId(Integer groupId) { + this.groupId = groupId; + } + + public Integer getSchoolId() { + return schoolId; + } + + public void setSchoolId(Integer schoolId) { + this.schoolId = schoolId; + } +} From 99414e28873e0b5925640617854125291205575c Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 20 Aug 2026 09:31:50 +0800 Subject: [PATCH 54/58] =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/converter/ClientEnumConverter.java | 2 +- .../converter/CommonIntEnumConverter.java | 2 +- .../controller/api/BaseController.java | 2 + .../controller/oapi/AppController.java | 45 ++++++ .../controller/oapi/UserController.java | 20 +++ .../com/yunhai/userbase/enums/ClientEnum.java | 35 ----- .../com/yunhai/userbase/enums/GenderEnum.java | 2 + .../yunhai/userbase/enums/GroupTypeEnum.java | 2 + .../java/com/yunhai/userbase/enums/IEnum.java | 65 --------- .../com/yunhai/userbase/enums/IntegEnum.java | 2 + .../com/yunhai/userbase/enums/StatusEnum.java | 2 + .../yunhai/userbase/enums/SysModeEnum.java | 2 + .../yunhai/userbase/enums/SysRoleEnum.java | 2 + .../yunhai/userbase/enums/UserTypeEnum.java | 2 + .../userbase/model/other/RoleInfoModel.java | 25 ---- .../model/sysuser/SysUserPasswordRequest.java | 33 +++++ .../userbase/service/IAppCategoryService.java | 16 +++ .../userbase/service/IGroupAuthzService.java | 11 ++ .../userbase/service/ISysUserService.java | 19 +++ .../service/impl/AppCategoryService.java | 104 ++++++++++++++ .../service/impl/GroupAuthzService.java | 38 ++++- .../service/impl/IdentityService.java | 2 +- .../service/impl/SysGroupService.java | 2 +- .../userbase/service/impl/SysUserService.java | 71 +++++++++- .../com/yunhai/userbase/utils/EnumUtils.java | 134 ------------------ 25 files changed, 375 insertions(+), 265 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/controller/oapi/AppController.java delete mode 100644 src/main/java/com/yunhai/userbase/enums/ClientEnum.java delete mode 100644 src/main/java/com/yunhai/userbase/enums/IEnum.java delete mode 100644 src/main/java/com/yunhai/userbase/model/other/RoleInfoModel.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/SysUserPasswordRequest.java delete mode 100644 src/main/java/com/yunhai/userbase/utils/EnumUtils.java diff --git a/src/main/java/com/yunhai/userbase/common/converter/ClientEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/ClientEnumConverter.java index e7a51c2..1899280 100644 --- a/src/main/java/com/yunhai/userbase/common/converter/ClientEnumConverter.java +++ b/src/main/java/com/yunhai/userbase/common/converter/ClientEnumConverter.java @@ -1,6 +1,6 @@ package com.yunhai.userbase.common.converter; -import com.yunhai.userbase.enums.ClientEnum; +import com.yunhai.platform.utils.enums.ClientEnum; import javax.persistence.AttributeConverter; diff --git a/src/main/java/com/yunhai/userbase/common/converter/CommonIntEnumConverter.java b/src/main/java/com/yunhai/userbase/common/converter/CommonIntEnumConverter.java index 33c8dc6..47be82a 100644 --- a/src/main/java/com/yunhai/userbase/common/converter/CommonIntEnumConverter.java +++ b/src/main/java/com/yunhai/userbase/common/converter/CommonIntEnumConverter.java @@ -1,6 +1,6 @@ package com.yunhai.userbase.common.converter; -import com.yunhai.userbase.enums.IEnum; +import com.yunhai.platform.utils.enums.IEnum; import javax.persistence.AttributeConverter; diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index 6ff2ea8..ee159c5 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -1,6 +1,8 @@ package com.yunhai.userbase.controller.api; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.platform.utils.enums.ClientEnum; +import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.enums.*; import com.yunhai.userbase.utils.*; diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java new file mode 100644 index 0000000..37ea302 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -0,0 +1,45 @@ +package com.yunhai.userbase.controller.oapi; + +import com.yunhai.platform.utils.enums.ClientEnum; +import com.yunhai.platform.utils.enums.IEnum; +import com.yunhai.platform.utils.model.BoaAppType; +import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.service.IAppCategoryService; +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; + +import java.util.List; + +/** + * 应用域开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/app/...})。
+ * 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求 + * (URL pattern {@code /oapi/*} 已覆盖本控制器),未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。 + */ +@RestController +@RequestMapping(Config.OPEN_API_PATH + "app/") +public class AppController { + + @Autowired + private IAppCategoryService appCategoryService; + + /** + * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。
+ * 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用), + * 超级管理员返回全部;不透出 appSecret。
+ * clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。 + * + * @param userId 用户ID(必填) + * @param clientType 客户端类型 key(可选) + * @return 应用类型分组列表;无权限时各类型 apps 为空数组 + */ + @GetMapping("list") + public List list(@RequestParam("userId") Integer userId, + @RequestParam(value = "clientType", required = false) Integer clientType) { + ClientEnum clientEnum = (clientType == null) ? null + : IEnum.getByKey(ClientEnum.class, clientType); + return appCategoryService.listBoaAppType(userId, clientEnum); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index c34b6ba..e070276 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -4,12 +4,14 @@ import com.yunhai.platform.appauth.AppAuthFilter; import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; import com.yunhai.userbase.model.sysuser.SysUserMoveRequest; +import com.yunhai.userbase.model.sysuser.SysUserPasswordRequest; import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.repository.ApplicationInfoRepository; import com.yunhai.userbase.service.IGroupAuthzService; @@ -209,4 +211,22 @@ public class UserController { sysUserService.moveUserWithinSchool(request.getUserId(), request.getGroupId(), request.getSchoolId()); return userCenterService.getUserInfoWithDeptById(request.getUserId()); } + + /** + * 查询用户的业务角色身份列表(角色 + 主归属学校组上下文)。
+ * 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。 + */ + @GetMapping("roles") + public List roles(@RequestParam("userId") Integer userId) { + return sysUserService.findUserRoles(userId); + } + + /** + * 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。
+ * 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。 + */ + @PostMapping("password") + public void password(@RequestBody @Validated SysUserPasswordRequest request) { + sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword()); + } } diff --git a/src/main/java/com/yunhai/userbase/enums/ClientEnum.java b/src/main/java/com/yunhai/userbase/enums/ClientEnum.java deleted file mode 100644 index 1451dc8..0000000 --- a/src/main/java/com/yunhai/userbase/enums/ClientEnum.java +++ /dev/null @@ -1,35 +0,0 @@ -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 index 9650178..f456f66 100644 --- a/src/main/java/com/yunhai/userbase/enums/GenderEnum.java +++ b/src/main/java/com/yunhai/userbase/enums/GenderEnum.java @@ -1,5 +1,7 @@ package com.yunhai.userbase.enums; +import com.yunhai.platform.utils.enums.IEnum; + import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/yunhai/userbase/enums/GroupTypeEnum.java b/src/main/java/com/yunhai/userbase/enums/GroupTypeEnum.java index 19c3c55..2c7fd11 100644 --- a/src/main/java/com/yunhai/userbase/enums/GroupTypeEnum.java +++ b/src/main/java/com/yunhai/userbase/enums/GroupTypeEnum.java @@ -1,5 +1,7 @@ package com.yunhai.userbase.enums; +import com.yunhai.platform.utils.enums.IEnum; + import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/yunhai/userbase/enums/IEnum.java b/src/main/java/com/yunhai/userbase/enums/IEnum.java deleted file mode 100644 index 491f759..0000000 --- a/src/main/java/com/yunhai/userbase/enums/IEnum.java +++ /dev/null @@ -1,65 +0,0 @@ -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 index 81095fb..bb13031 100644 --- a/src/main/java/com/yunhai/userbase/enums/IntegEnum.java +++ b/src/main/java/com/yunhai/userbase/enums/IntegEnum.java @@ -1,5 +1,7 @@ package com.yunhai.userbase.enums; +import com.yunhai.platform.utils.enums.IEnum; + import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/yunhai/userbase/enums/StatusEnum.java b/src/main/java/com/yunhai/userbase/enums/StatusEnum.java index 0531e46..fe5b7b3 100644 --- a/src/main/java/com/yunhai/userbase/enums/StatusEnum.java +++ b/src/main/java/com/yunhai/userbase/enums/StatusEnum.java @@ -1,5 +1,7 @@ package com.yunhai.userbase.enums; +import com.yunhai.platform.utils.enums.IEnum; + import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/yunhai/userbase/enums/SysModeEnum.java b/src/main/java/com/yunhai/userbase/enums/SysModeEnum.java index 1f4a58b..fa19a8a 100644 --- a/src/main/java/com/yunhai/userbase/enums/SysModeEnum.java +++ b/src/main/java/com/yunhai/userbase/enums/SysModeEnum.java @@ -1,5 +1,7 @@ package com.yunhai.userbase.enums; +import com.yunhai.platform.utils.enums.IEnum; + import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/yunhai/userbase/enums/SysRoleEnum.java b/src/main/java/com/yunhai/userbase/enums/SysRoleEnum.java index 059d55e..0bb3c67 100644 --- a/src/main/java/com/yunhai/userbase/enums/SysRoleEnum.java +++ b/src/main/java/com/yunhai/userbase/enums/SysRoleEnum.java @@ -1,5 +1,7 @@ package com.yunhai.userbase.enums; +import com.yunhai.platform.utils.enums.IEnum; + import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/yunhai/userbase/enums/UserTypeEnum.java b/src/main/java/com/yunhai/userbase/enums/UserTypeEnum.java index f83297b..beb2653 100644 --- a/src/main/java/com/yunhai/userbase/enums/UserTypeEnum.java +++ b/src/main/java/com/yunhai/userbase/enums/UserTypeEnum.java @@ -1,5 +1,7 @@ package com.yunhai.userbase.enums; +import com.yunhai.platform.utils.enums.IEnum; + import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/yunhai/userbase/model/other/RoleInfoModel.java b/src/main/java/com/yunhai/userbase/model/other/RoleInfoModel.java deleted file mode 100644 index fd8d6da..0000000 --- a/src/main/java/com/yunhai/userbase/model/other/RoleInfoModel.java +++ /dev/null @@ -1,25 +0,0 @@ -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/sysuser/SysUserPasswordRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserPasswordRequest.java new file mode 100644 index 0000000..78454c2 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserPasswordRequest.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +/** + * 修改用户密码请求(oapi 开放接口专用)。 + * + * @author chenx + */ +@Data +public class SysUserPasswordRequest { + + /** + * 用户ID(必填)。 + */ + @NotNull + private Integer userId; + + /** + * 旧密码(必填,须与库中 BCrypt 密文匹配)。 + */ + @NotBlank + private String oldPassword; + + /** + * 新密码(必填,走弱密码规则校验后 BCrypt 加密写入)。 + */ + @NotBlank + private String newPassword; +} diff --git a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java index 05678d1..13772b4 100644 --- a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java @@ -1,5 +1,7 @@ package com.yunhai.userbase.service; +import com.yunhai.platform.utils.enums.ClientEnum; +import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.userbase.entity.AppCategory; import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo; import com.yunhai.userbase.model.appcategory.AppCategoryQuery; @@ -33,4 +35,18 @@ public interface IAppCategoryService { */ List listWithApps(); + /** + * 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组)。
+ * 规则:
+ * ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});
+ * ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);
+ * ③ 不透出 appSecret。
+ * 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无有权应用的类型 apps 为空数组。 + * + * @param userId 用户ID + * @param clientType 客户端类型(null=不按端过滤) + * @return 应用类型 + 有权应用列表 + */ + List listBoaAppType(Integer userId, ClientEnum clientType); + } diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java index a69597b..95ef9c6 100644 --- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -176,4 +176,15 @@ public interface IGroupAuthzService { * @param items 模块授权条目列表(groupId/appId/moduleId/mode/isAll) */ void setGroupModulePerms(List items); + + /** + * 查询用户有权访问的应用ID集合(oapi 开放接口 getUserApp 权限过滤用)。
+ * 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:
+ * - 超级管理员:全部应用;
+ * - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。
+ * + * @param userId 用户ID + * @return 有权应用ID列表(无则空列表) + */ + List findUserAuthorizedAppIds(Integer userId); } diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index 6eafe45..d3bb66f 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -1,5 +1,6 @@ package com.yunhai.userbase.service; +import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.userbase.model.sysuser.BatchImportResult; import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; import com.yunhai.userbase.model.sysuser.SysUserQuery; @@ -160,4 +161,22 @@ public interface ISysUserService { * @param response 直接写入 Excel 流 */ void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException; + + /** + * 查询用户的业务角色身份列表(oapi 开放接口专用)。 + * + * @param userId 用户ID + * @return 角色身份列表(roleId/roleName + 主归属学校组上下文) + */ + List findUserRoles(Integer userId); + + /** + * 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。
+ * oapi 开放接口专用;不加操作日志(避免明文密码入日志)。 + * + * @param userId 用户ID + * @param oldPassword 旧密码(须与库中 BCrypt 密文匹配) + * @param newPassword 新密码(弱密码规则校验后加密写入) + */ + void changePassword(Integer userId, String oldPassword, String newPassword); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java index bc98b2b..0fbbb21 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java @@ -1,8 +1,12 @@ package com.yunhai.userbase.service.impl; +import com.yunhai.platform.utils.enums.ClientEnum; import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.model.BoaApp; +import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.userbase.entity.AppCategory; +import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.model.appauthz.AppAuthzAppRow; import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo; import com.yunhai.userbase.model.appcategory.AppCategoryQuery; @@ -11,6 +15,7 @@ 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 com.yunhai.userbase.service.IGroupAuthzService; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -18,9 +23,14 @@ import org.springframework.util.StringUtils; 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.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -36,6 +46,9 @@ public class AppCategoryService implements IAppCategoryService { @Autowired private ApplicationInfoRepository applicationInfoRepository; + @Autowired + private IGroupAuthzService groupAuthzService; + @Override @Transactional @@ -133,6 +146,97 @@ public class AppCategoryService implements IAppCategoryService { return vo; } + @Override + public List listBoaAppType(Integer userId, ClientEnum clientType) { + Set authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId)); + List categories = appCategoryRepository.findAllByOrderBySortNoAsc(); + List apps = applicationInfoRepository.findAll(); + apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ApplicationInfo::getId, Comparator.nullsLast(Integer::compareTo))); + // categoryId -> 该类型下应用列表(插入顺序即排序) + Map> appMap = new LinkedHashMap<>(); + for (ApplicationInfo app : apps) { + if (app.getCategoryId() == null) { + continue; + } + appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app); + } + List result = new ArrayList<>(); + for (AppCategory category : categories) { + BoaAppType type = new BoaAppType(); + type.setId(category.getId()); + type.setName(category.getName()); + List boaApps = new ArrayList<>(); + for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) { + // 用户权限过滤 + if (!authorizedAppIds.contains(app.getId())) { + continue; + } + // 客户端类型位掩码过滤:clients 为空视为不限端 + if (clientType != null && !containsClient(app.getClients(), clientType.getKey())) { + continue; + } + boaApps.add(convertToBoaApp(app)); + } + type.setApps(boaApps); + result.add(type); + } + return result; + } + + /** + * ApplicationInfo -> BoaApp 映射。不透出 appSecret;模块列表留空(由调用方按需填充)。 + */ + private BoaApp convertToBoaApp(ApplicationInfo app) { + BoaApp boa = new BoaApp(); + boa.setId(app.getId()); + boa.setName(app.getName()); + boa.setIcon(app.getPcIcon()); + boa.setType(app.getCategoryId()); + boa.setWebAddr(app.getPcUrl()); + boa.setWebAddress(app.getPcUrl()); + boa.setAppKey(app.getAppKey()); + boa.setOrderNumber(app.getSortNo()); + boa.setCreateUser(app.getCreateUser()); + boa.setCreateTime(app.getCreateTime()); + boa.setMobiIcon(app.getAppIcon()); + boa.setClients(toClientMask(app.getClients())); + boa.setWxgzhAddress(app.getOfficialAccountUrl()); + boa.setDingAddress(app.getDingtalkUrl()); + return boa; + } + + /** + * clients 列表([1,2,4])折叠为位掩码叠加值(7)。 + */ + private Integer toClientMask(List clients) { + if (clients == null || clients.isEmpty()) { + return null; + } + int mask = 0; + for (Integer c : clients) { + if (c != null) { + mask |= c; + } + } + return mask; + } + + /** + * 判断客户端类型位掩码是否与 app.clients 有交集;clients 为空视为不限端。 + */ + private boolean containsClient(List clients, Integer clientKey) { + if (clients == null || clients.isEmpty()) { + return true; + } + for (Integer c : clients) { + if (c != null && (c & clientKey) != 0) { + return true; + } + } + return false; + } + } diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 471df81..4e9af69 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -6,7 +6,7 @@ import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.entity.*; import com.yunhai.userbase.enums.GroupTypeEnum; -import com.yunhai.userbase.enums.IEnum; +import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.userbase.enums.SysModeEnum; import com.yunhai.userbase.enums.UserTypeEnum; import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest; @@ -1599,4 +1599,40 @@ public class GroupAuthzService implements IGroupAuthzService { } return moduleIds; } + + @Override + public List findUserAuthorizedAppIds(Integer userId) { + Objects.requireNonNull(userId, "用户ID不能为空"); + SysUser user = sysUserRepository.findById(userId).orElse(null); + if (user == null) { + throw new DException("用户不存在"); + } + // 超级管理员:全部应用 + if (isSuperAdmin(user.getType(), userId)) { + List allIds = new ArrayList<>(); + for (ApplicationInfo app : applicationInfoRepository.findAll()) { + allIds.add(app.getId()); + } + return allIds; + } + Set appIds = new LinkedHashSet<>(); + // 1. 组开通应用(用户所属组沿祖先链的开通池并集) + List userRels = sysUserGroupRelRepository.findByUserId(userId); + for (SysUserGroupRel rel : userRels) { + if (rel.getGroupId() == null) { + continue; + } + appIds.addAll(collectAppIdsForAuthzScope(rel.getGroupId())); + } + // 2. 启用角色模块所属应用(角色授权不要求组先开通应用) + Set roleModuleIds = collectEnabledRoleModuleIds(userId); + if (!roleModuleIds.isEmpty()) { + for (AppModule m : appModuleRepository.findAllById(roleModuleIds)) { + if (m.getAppId() != null) { + appIds.add(m.getAppId()); + } + } + } + return new ArrayList<>(appIds); + } } diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java index ecbdd3e..16992b1 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java @@ -4,7 +4,7 @@ import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.entity.SysUserCurrentRole; import com.yunhai.userbase.entity.SysUserRoleRel; -import com.yunhai.userbase.enums.IEnum; +import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.userbase.enums.StatusEnum; import com.yunhai.userbase.enums.SysRoleEnum; import com.yunhai.userbase.model.identity.IdentityCurrentVo; diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index ceb29b5..f7e292d 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -4,7 +4,7 @@ import com.yunhai.userbase.entity.SysGroup; import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.userbase.enums.GroupTypeEnum; -import com.yunhai.userbase.enums.IEnum; +import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.userbase.event.DataSyncEvent; import com.yunhai.userbase.common.context.CallerAppContext; import com.yunhai.userbase.model.push.PushInterfaceType; diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index da27f4d..0b6507f 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -1,18 +1,22 @@ package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.userbase.entity.SysAppRole; import com.yunhai.userbase.entity.SysGroup; -import com.yunhai.userbase.enums.IEnum; +import com.yunhai.platform.utils.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.SysUserAppRoleRel; import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.model.sysuser.BatchImportResult; import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; 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.SysAppRoleRepository; import com.yunhai.userbase.repository.SysGroupRepository; import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; import com.yunhai.userbase.repository.SysUserGroupRelRepository; @@ -96,6 +100,9 @@ public class SysUserService implements ISysUserService { @Autowired private ISysGroupService sysGroupService; + @Autowired + private SysAppRoleRepository sysAppRoleRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -799,6 +806,68 @@ public class SysUserService implements ISysUserService { return single; } + @Override + public List findUserRoles(Integer userId) { + if (userId == null) { + throw new DException("用户ID不能为空"); + } + List result = new ArrayList<>(); + // 主归属组(学校/部门)上下文:schId/schName + Integer mainGroupId = null; + String mainGroupName = null; + List groupRels = sysUserGroupRelRepository.findByUserId(userId); + for (SysUserGroupRel gr : groupRels) { + if (gr.getIsMain() != null && gr.getIsMain() == IS_MAIN) { + mainGroupId = gr.getGroupId(); + break; + } + } + if (mainGroupId != null) { + SysGroup mainGroup = sysGroupRepository.findById(mainGroupId).orElse(null); + if (mainGroup != null) { + mainGroupName = mainGroup.getName(); + } + } + // 业务角色:用户-角色关系 → 角色名称 + List rels = sysUserAppRoleRelRepository.findByUserId(userId); + for (SysUserAppRoleRel rel : rels) { + if (rel.getRoleId() == null) { + continue; + } + SysAppRole role = sysAppRoleRepository.findById(rel.getRoleId()).orElse(null); + if (role == null) { + continue; + } + BoaRoleInfo info = new BoaRoleInfo(); + info.setRoleId(role.getId()); + info.setRoleName(role.getName()); + info.setSchId(mainGroupId); + info.setSchName(mainGroupName); + result.add(info); + } + return result; + } + + @Override + @Transactional + public void changePassword(Integer userId, String oldPassword, String newPassword) { + SysUser entity = sysUserRepository.findById(userId) + .orElseThrow(() -> new DException("系统用户不存在")); + if (StringUtils.isBlank(oldPassword) || StringUtils.isBlank(newPassword)) { + throw new DException("新旧密码不能为空"); + } + if (!passwordEncoder.matches(oldPassword, entity.getPassword())) { + throw new DException("原密码不正确"); + } + String weakReason = ValidatorUtil.checkPasswordStrength(newPassword); + if (weakReason != null) { + throw new DException(weakReason); + } + entity.setPassword(passwordEncoder.encode(newPassword)); + entity.setUpdateUser(SYSTEM_USER_ID); + sysUserRepository.save(entity); + } + /** 校验手机号与身份证(有值才校验)。 */ private void validateProfile(SysUserRequest request) { if (StringUtils.isNotBlank(request.getPhone()) && !ValidatorUtil.isPhone(request.getPhone())) { diff --git a/src/main/java/com/yunhai/userbase/utils/EnumUtils.java b/src/main/java/com/yunhai/userbase/utils/EnumUtils.java deleted file mode 100644 index 63fbf11..0000000 --- a/src/main/java/com/yunhai/userbase/utils/EnumUtils.java +++ /dev/null @@ -1,134 +0,0 @@ -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 From e001bc9d77e009749f034fcf0b0430fac470cb99 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 20 Aug 2026 10:24:47 +0800 Subject: [PATCH 55/58] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/aspect/OapiLogAspect.java | 241 ++++++++++++++++++ .../userbase/aspect/OperationLogAspect.java | 3 +- .../userbase/common/annotation/OapiLog.java | 40 +++ .../yunhai/userbase/config/AsyncConfig.java | 38 +++ .../controller/api/SysOapiLogController.java | 77 ++++++ .../controller/oapi/AppController.java | 29 +++ .../yunhai/userbase/entity/SysOapiLog.java | 148 +++++++++++ .../model/oapilog/SysOapiLogListVo.java | 80 ++++++ .../model/oapilog/SysOapiLogQuery.java | 59 +++++ .../repository/SysOapiLogRepository.java | 27 ++ .../userbase/service/ISysOapiLogService.java | 50 ++++ .../service/impl/SysOapiLogServiceImpl.java | 180 +++++++++++++ .../impl/SysOperationLogServiceImpl.java | 2 +- .../userbase/task/OapiLogCleanupTask.java | 33 +++ .../userbase/utils/SensitiveDataMasker.java | 119 +++++++++ 15 files changed, 1124 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java create mode 100644 src/main/java/com/yunhai/userbase/common/annotation/OapiLog.java create mode 100644 src/main/java/com/yunhai/userbase/config/AsyncConfig.java create mode 100644 src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysOapiLog.java create mode 100644 src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java create mode 100644 src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogQuery.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysOapiLogRepository.java create mode 100644 src/main/java/com/yunhai/userbase/service/ISysOapiLogService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java create mode 100644 src/main/java/com/yunhai/userbase/task/OapiLogCleanupTask.java create mode 100644 src/main/java/com/yunhai/userbase/utils/SensitiveDataMasker.java diff --git a/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java b/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java new file mode 100644 index 0000000..965e96e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java @@ -0,0 +1,241 @@ +package com.yunhai.userbase.aspect; + +import com.alibaba.fastjson.JSON; +import com.yunhai.platform.appauth.AppAuthFilter; +import com.yunhai.userbase.common.annotation.OapiLog; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysOapiLog; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.service.ISysOapiLogService; +import com.yunhai.userbase.utils.GzipUtil; +import com.yunhai.userbase.utils.IpUtils; +import com.yunhai.userbase.utils.OperationLogResolver; +import com.yunhai.userbase.utils.SensitiveDataMasker; +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.beans.factory.annotation.Value; +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; + +/** + * 开放接口(/oapi/**)调用日志切面。
+ * 拦截 {@code controller.oapi} 包下所有方法,规则:
+ * - POST 写操作:全量自动记录(无需注解),级别/类型由 OperationLogResolver 关键词判定;
+ * - GET 查询:默认不记录(高频调用,防刷爆表),满足任一条件则记录:方法标注 {@code @OapiLog}、或配置 oapi.log-get=true;
+ * - 排除 {@code SignKeyInternalController}(/oapi/internal,响应含 appSecret 明文,绝不落库);
+ * - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;
+ * - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。
+ * + * @author chenx + * @date 2026/8/20 + */ +@Slf4j +@Aspect +@Component +public class OapiLogAspect { + + /** 内部密钥端点控制器,排除(响应含 appSecret 明文)。 */ + private static final String INTERNAL_CONTROLLER = "com.yunhai.userbase.controller.oapi.SignKeyInternalController"; + + /** GET 默认不记录,开启本配置后全量记录。 */ + @Value("${oapi.log-get:false}") + private boolean logGet; + + @Autowired + private ISysOapiLogService sysOapiLogService; + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + @Around("execution(* com.yunhai.userbase.controller.oapi..*.*(..))") + public Object around(ProceedingJoinPoint point) throws Throwable { + if (INTERNAL_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) { + return point.proceed(); + } + + HttpServletRequest request = getRequest(); + String httpMethod = request != null ? request.getMethod() : ""; + boolean isGet = "GET".equalsIgnoreCase(httpMethod); + + OapiLog oapiLog = findAnnotation(point); + // 决策:POST 全记;GET 仅当注解强制或配置开启才记 + if (isGet && oapiLog == null && !logGet) { + return point.proceed(); + } + + long start = System.currentTimeMillis(); + SysOapiLog entity = buildEntity(point, request, httpMethod, oapiLog); + + boolean saveResponse = oapiLog != null ? oapiLog.saveResponse() : true; + int maxResponseSize = oapiLog != null ? oapiLog.maxResponseSize() : 10240; + + 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); + sysOapiLogService.saveAsync(entity); + } + return result; + } + + private OapiLog findAnnotation(ProceedingJoinPoint point) { + try { + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + return method.getAnnotation(OapiLog.class); + } catch (Exception e) { + return null; + } + } + + private SysOapiLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, + String httpMethod, OapiLog oapiLog) { + SysOapiLog entity = new SysOapiLog(); + + // 调用方应用:验签 attribute → appKey → application_info 解析应用名(失败记 appKey 原值,不阻塞) + resolveCallerApp(request, entity); + + String requestUri = ""; + if (request != null) { + entity.setRequestIp(IpUtils.getIpAddr(request)); + requestUri = request.getRequestURI(); + entity.setUserAgent(request.getHeader("User-Agent")); + entity.setRequestParams(maskRequestParams(point, request)); + } else { + entity.setRequestParams(maskRequestParams(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); + if (oapiLog != null && StringUtils.isNotBlank(oapiLog.value())) { + entity.setOperationDesc(oapiLog.value()); + } + return entity; + } + + /** + * 从 request attribute 解析调用方 appKey,并映射应用名。
+ * 无 attribute / 解析失败:appKey 记 0、应用名记 unknown,仅 warn,不抛异常。 + */ + private void resolveCallerApp(HttpServletRequest request, SysOapiLog entity) { + entity.setCallerAppKey(0); + entity.setCallerAppName("unknown"); + if (request == null) { + return; + } + Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + if (attr == null) { + log.warn("[oapi-log] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}", + request.getRequestURI()); + return; + } + String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr); + if (StringUtils.isBlank(appKeyStr)) { + log.warn("[oapi-log] AppAuth attr is blank, uri={}", request.getRequestURI()); + return; + } + Integer appKey; + try { + appKey = Integer.parseInt(appKeyStr.trim()); + } catch (NumberFormatException e) { + log.warn("[oapi-log] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI()); + return; + } + entity.setCallerAppKey(appKey); + ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey); + entity.setCallerAppName(app != null && StringUtils.isNotBlank(app.getName()) + ? app.getName() : String.valueOf(appKey)); + } + + /** + * 构建请求参数:GET 优先 queryString;其余序列化方法入参(过滤 Servlet 三大件),统一过脱敏。 + */ + private String maskRequestParams(ProceedingJoinPoint point, HttpServletRequest request) { + try { + if (request != null && "GET".equalsIgnoreCase(request.getMethod())) { + String qs = request.getQueryString(); + if (StringUtils.isNotBlank(qs)) { + return SensitiveDataMasker.maskJson(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 SensitiveDataMasker.maskObject(filtered); + } catch (Exception e) { + return ""; + } + } + + /** + * 写入响应结果(脱敏 → 截断 → GZIP)。 + */ + private void fillResponse(SysOapiLog entity, Object result, boolean saveResponse, int maxResponseSize) { + if (!saveResponse || result == null) { + return; + } + try { + String resp = SensitiveDataMasker.maskObject(result); + if (resp == null) { + return; + } + 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/aspect/OperationLogAspect.java b/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java index 980604e..99afc9b 100644 --- a/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java +++ b/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java @@ -51,7 +51,8 @@ public class OperationLogAspect { @Autowired private ISysOperationLogService sysOperationLogService; - @Around("execution(* com.yunhai.userbase.controller..*.*(..))") + @Around("execution(* com.yunhai.userbase.controller..*.*(..))" + + " && !execution(* com.yunhai.userbase.controller.oapi..*.*(..))") public Object around(ProceedingJoinPoint point) throws Throwable { // 排除日志查看接口自身 if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) { diff --git a/src/main/java/com/yunhai/userbase/common/annotation/OapiLog.java b/src/main/java/com/yunhai/userbase/common/annotation/OapiLog.java new file mode 100644 index 0000000..f34290c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/annotation/OapiLog.java @@ -0,0 +1,40 @@ +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; + +/** + * 开放接口日志注解。
+ * 标注在 {@code controller.oapi} 方法上: + *
    + *
  • POST 写操作无需标注,OapiLogAspect 自动全量记录;
  • + *
  • GET 默认不记录(高频查询),标注本注解可强制记录(或开启配置 oapi.log-get=true);
  • + *
  • value 提供中文操作描述,落入 operation_desc 字段。
  • + *
+ * + * @author chenx + * @date 2026/8/20 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface OapiLog { + + /** + * 操作描述,可选。 + */ + String value() default ""; + + /** + * 是否保存响应结果(GZIP 压缩后入库)。 + */ + boolean saveResponse() default true; + + /** + * 响应结果序列化后的最大字符数,超过则截断。默认 10KB。 + */ + int maxResponseSize() default 10240; +} diff --git a/src/main/java/com/yunhai/userbase/config/AsyncConfig.java b/src/main/java/com/yunhai/userbase/config/AsyncConfig.java new file mode 100644 index 0000000..bf8873a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/AsyncConfig.java @@ -0,0 +1,38 @@ +package com.yunhai.userbase.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 异步线程池配置。
+ * 日志类异步落库统一走有界池 {@code oapiLogExecutor}:
+ * - core 4 / max 16 / queue 1000,高并发下不会为每条日志 new 一个线程;
+ * - DiscardPolicy 丢弃策略:池满时直接丢弃新任务,日志宁可丢失也不拖垮/阻塞主流程。
+ * 管理端操作日志(SysOperationLogServiceImpl.saveAsync)与开放接口日志 + * (SysOapiLogServiceImpl.saveAsync)均绑定本池。
+ * + * @author chenx + * @date 2026/8/20 + */ +@Configuration +public class AsyncConfig { + + /** + * 日志异步落库线程池(有界)。 + */ + @Bean("oapiLogExecutor") + public Executor oapiLogExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(16); + executor.setQueueCapacity(1000); + executor.setThreadNamePrefix("oplog-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java b/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java new file mode 100644 index 0000000..46ffd0f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java @@ -0,0 +1,77 @@ +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.oapilog.SysOapiLogListVo; +import com.yunhai.userbase.model.oapilog.SysOapiLogQuery; +import com.yunhai.userbase.service.ISysOapiLogService; +import io.swagger.v3.oas.annotations.Operation; +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; + +/** + * 开放接口日志查询与下载接口。
+ * 本控制器位于 {@code controller.api} 包,不会被 OapiLogAspect(切 oapi 包)拦截。
+ * + * @author chenx + * @date 2026/8/20 + */ +@Slf4j +@RestController +@RequestMapping(value = Config.API_PATH + "/oapilog/") +public class SysOapiLogController { + + @Autowired + private ISysOapiLogService sysOapiLogService; + + /** + * 分页查询开放接口日志列表(不含响应结果)。 + */ + @Operation(summary = "开放接口日志分页查询", operationId = "oapiLogList") + @GetMapping("/List") + public BasePageResult list(SysOapiLogQuery query, BasePageQuery pageQuery) { + return sysOapiLogService.pageQuery(query, pageQuery); + } + + /** + * 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。
+ * 与 {@code SysOperationLogController.Download} 一致的 octet-stream + attachment 写法。
+ * 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。 + */ + @Operation(summary = "下载开放接口日志响应原文", operationId = "oapiLogDownload") + @GetMapping("/Download/{id}") + public ResponseEntity download(@PathVariable Long id) { + String data = sysOapiLogService.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/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java index 37ea302..efc3ebb 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -4,9 +4,14 @@ import com.yunhai.platform.utils.enums.ClientEnum; import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.application.ApplicationInfoVo; import com.yunhai.userbase.service.IAppCategoryService; +import com.yunhai.userbase.service.IApplicationInfoService; +import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -25,6 +30,30 @@ public class AppController { @Autowired private IAppCategoryService appCategoryService; + @Autowired + private IApplicationInfoService applicationInfoService; + + /** + * 按主键 ID 查询应用信息(带缓存)。
+ * 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。
+ * 返回 {@link ApplicationInfoVo} 并清空 appSecret,与 list 接口一致不透出签名密钥。 + * + * @param appId 应用主键 ID(必填) + * @return 应用信息视图;不存在时返回 null + */ + @GetMapping("{appId}") + public ApplicationInfoVo byId(@PathVariable Integer appId) { + ApplicationInfo info = applicationInfoService.getById(appId); + if (info == null) { + return null; + } + ApplicationInfoVo vo = new ApplicationInfoVo(); + BeanUtils.copyProperties(info, vo); + // 不透出签名密钥(与 list 契约一致) + vo.setAppSecret(null); + return vo; + } + /** * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用), diff --git a/src/main/java/com/yunhai/userbase/entity/SysOapiLog.java b/src/main/java/com/yunhai/userbase/entity/SysOapiLog.java new file mode 100644 index 0000000..5ab18d6 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysOapiLog.java @@ -0,0 +1,148 @@ +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; + +/** + * 开放接口调用日志实体。
+ * 记录 yh-app-auth 签名调用方(YschBaseOpenApi 门面背后的 /oapi/** 端点)的操作, + * 身份主体为「调用方应用」(callerAppKey/callerAppName),区别于管理端 sys_operation_log 的登录用户维度。
+ * 请求参数与响应均已脱敏后入库(敏感字段见 SensitiveDataMasker)。
+ * + * @author chenx + * @date 2026/8/20 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_oapi_log", indexes = { + @Index(name = "idx_create_time", columnList = "create_time"), + @Index(name = "idx_appkey_time", columnList = "caller_app_key, 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 SysOapiLog 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; + + /** + * 调用方应用 appKey(验签后写入 request attribute 的 AppAuthFilter.APP_AUTH_ATTR) + */ + @Column(name = "caller_app_key", nullable = false) + private Integer callerAppKey; + + /** + * 调用方应用名(application_info 解析;解析失败记 appKey 原值) + */ + @Column(name = "caller_app_name", length = 128) + private String callerAppName; + + /** + * 请求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; + + /** + * 操作描述(@OapiLog 注解 value 提供,可选) + */ + @Column(name = "operation_desc", length = 255) + private String operationDesc; + + /** + * 请求参数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/model/oapilog/SysOapiLogListVo.java b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java new file mode 100644 index 0000000..3da66fc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java @@ -0,0 +1,80 @@ +package com.yunhai.userbase.model.oapilog; + +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/8/20 + */ +@Data +public class SysOapiLogListVo { + + /** 主键 */ + private Long id; + /** 日志级别 1-重要 2-次要 3-一般 */ + private Integer logLevel; + /** 调用方应用 appKey */ + private Integer callerAppKey; + /** 调用方应用名 */ + private String callerAppName; + /** 请求IP */ + private String requestIp; + /** 请求路径 */ + private String requestUri; + /** 请求方法 GET/POST/PUT/DELETE */ + private String requestMethod; + /** 操作类型 DELETE/CREATE/UPDATE/QUERY */ + private String operationType; + /** 类名.方法名 */ + private String classMethod; + /** 操作描述 */ + private String operationDesc; + /** 请求参数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 SysOapiLog} 的列表投影字段一致。
+ * 用于 JPA Criteria {@code cb.construct} 投影查询。
+ */ + public SysOapiLogListVo(Long id, Integer logLevel, Integer callerAppKey, String callerAppName, + String requestIp, String requestUri, String requestMethod, + String operationType, String classMethod, String operationDesc, + String requestParams, Integer statusCode, Long costTime, + String userAgent, String errorMsg, LocalDateTime createTime) { + this.id = id; + this.logLevel = logLevel; + this.callerAppKey = callerAppKey; + this.callerAppName = callerAppName; + this.requestIp = requestIp; + this.requestUri = requestUri; + this.requestMethod = requestMethod; + this.operationType = operationType; + this.classMethod = classMethod; + this.operationDesc = operationDesc; + 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/oapilog/SysOapiLogQuery.java b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogQuery.java new file mode 100644 index 0000000..cfbad9b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogQuery.java @@ -0,0 +1,59 @@ +package com.yunhai.userbase.model.oapilog; + +import lombok.Data; + +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +/** + * 开放接口日志查询条件。
+ * 覆盖调用方应用、URI、级别、IP、时间等高频查询场景,与表索引前缀对齐。
+ * + * @author chenx + * @date 2026/8/20 + */ +@Data +public class SysOapiLogQuery { + + /** 日志级别(1-重要 2-次要 3-一般) */ + private Integer logLevel; + + /** 调用方应用 appKey(等值) */ + private Integer callerAppKey; + + /** 调用方应用名(模糊) */ + private String callerAppName; + + /** 请求路径(模糊) */ + 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)与应用名(callerAppName)。
+ * 前端单输入框搜索这两个字段。 + */ + 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/repository/SysOapiLogRepository.java b/src/main/java/com/yunhai/userbase/repository/SysOapiLogRepository.java new file mode 100644 index 0000000..7bcf205 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysOapiLogRepository.java @@ -0,0 +1,27 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysOapiLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.time.LocalDateTime; + +/** + * 开放接口调用日志仓储。
+ * + * @author chenx + * @date 2026/8/20 + */ +public interface SysOapiLogRepository extends JpaRepository, + JpaSpecificationExecutor { + + /** + * 按日志级别 + 创建时间批量删除(单条 DELETE 语句,不先加载实体)。
+ * 用于定时清理:一般日志保留 12 个月,重要/次要日志保留 36 个月。
+ * + * @param logLevel 日志级别编码 + * @param createTime 截止时间(早于该时间的记录被删除) + * @return 删除条数 + */ + long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime createTime); +} diff --git a/src/main/java/com/yunhai/userbase/service/ISysOapiLogService.java b/src/main/java/com/yunhai/userbase/service/ISysOapiLogService.java new file mode 100644 index 0000000..293c7d4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/ISysOapiLogService.java @@ -0,0 +1,50 @@ +package com.yunhai.userbase.service; + +import com.yunhai.userbase.entity.SysOapiLog; +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import com.yunhai.userbase.model.oapilog.SysOapiLogListVo; +import com.yunhai.userbase.model.oapilog.SysOapiLogQuery; + +/** + * 开放接口调用日志业务接口。
+ * + * @author chenx + * @date 2026/8/20 + */ +public interface ISysOapiLogService { + + /** + * 异步保存开放接口日志。
+ * 由 OapiLogAspect 在 finally 中调用,失败仅记录日志,不影响主业务。
+ * + * @param entity 日志实体 + */ + void saveAsync(SysOapiLog entity); + + /** + * 分页查询开放接口日志列表。
+ * 列表不含响应结果(response_data),避免加载 MEDIUMBLOB 影响性能。
+ * + * @param query 查询条件 + * @param pageQuery 分页参数(pageIndex 从 1 起) + * @return 分页结果 + */ + BasePageResult pageQuery(SysOapiLogQuery query, BasePageQuery pageQuery); + + /** + * 获取某条日志的响应结果(解压后原文)。
+ * + * @param id 日志ID + * @return 解压后的响应字符串,无记录或无响应数据返回 null + */ + String getResponseData(Long id); + + /** + * 清理过期日志。
+ * 一般日志(level=3)保留 12 个月,重要(level=1)/次要(level=2)保留 36 个月。
+ * + * @return 删除条数 + */ + long cleanupExpired(); +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java b/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java new file mode 100644 index 0000000..542b9c9 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java @@ -0,0 +1,180 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.SysOapiLog; +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.BasePageResult; +import com.yunhai.userbase.model.oapilog.SysOapiLogListVo; +import com.yunhai.userbase.model.oapilog.SysOapiLogQuery; +import com.yunhai.userbase.repository.SysOapiLogRepository; +import com.yunhai.userbase.service.ISysOapiLogService; +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/8/20 + */ +@Slf4j +@Service +public class SysOapiLogServiceImpl implements ISysOapiLogService { + + /** 一般日志(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 SysOapiLogRepository sysOapiLogRepository; + + @PersistenceContext + private EntityManager em; + + @Override + @Async("oapiLogExecutor") + public void saveAsync(SysOapiLog entity) { + try { + sysOapiLogRepository.save(entity); + } catch (Exception e) { + // 日志落库失败绝不抛回主流程,仅记录 + log.error("开放接口日志异步落库失败, uri={}, method={}", + entity.getRequestUri(), entity.getRequestMethod(), e); + } + } + + @Override + public BasePageResult pageQuery(SysOapiLogQuery 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(SysOapiLogListVo.class); + Root root = cq.from(SysOapiLog.class); + cq.select(cb.construct(SysOapiLogListVo.class, + root.get("id"), root.get("logLevel"), root.get("callerAppKey"), root.get("callerAppName"), + root.get("requestIp"), root.get("requestUri"), root.get("requestMethod"), + root.get("operationType"), root.get("classMethod"), root.get("operationDesc"), + 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(SysOapiLog.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) { + SysOapiLog entity = sysOapiLogRepository.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 = sysOapiLogRepository.deleteByLogLevelAndCreateTimeBefore(3, generalDeadline) + + sysOapiLogRepository.deleteByLogLevelAndCreateTimeBefore(2, importantDeadline) + + sysOapiLogRepository.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, + SysOapiLogQuery 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.getCallerAppKey() != null) { + predicates.add(cb.equal(root.get("callerAppKey"), query.getCallerAppKey())); + } + if (StringUtils.isNotBlank(query.getCallerAppName())) { + predicates.add(cb.like(root.get("callerAppName"), "%" + query.getCallerAppName() + "%")); + } + 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("callerAppName"), 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/SysOperationLogServiceImpl.java b/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java index 4e458a4..72076b4 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java @@ -48,7 +48,7 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService { private EntityManager em; @Override - @Async + @Async("oapiLogExecutor") public void saveAsync(SysOperationLog entity) { try { sysOperationLogRepository.save(entity); diff --git a/src/main/java/com/yunhai/userbase/task/OapiLogCleanupTask.java b/src/main/java/com/yunhai/userbase/task/OapiLogCleanupTask.java new file mode 100644 index 0000000..98bc783 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/task/OapiLogCleanupTask.java @@ -0,0 +1,33 @@ +package com.yunhai.userbase.task; + +import com.yunhai.userbase.service.ISysOapiLogService; +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)日志保留三年。
+ * 与管理端 OperationLogCleanupTask 同范式(@EnableScheduling 已在 UserBaseApplication 开启)。
+ * + * @author chenx + * @date 2026/8/20 + */ +@Slf4j +@Component +public class OapiLogCleanupTask { + + @Autowired + private ISysOapiLogService sysOapiLogService; + + @Scheduled(cron = "0 0 3 * * ?") + public void run() { + try { + long deleted = sysOapiLogService.cleanupExpired(); + log.info("开放接口日志定时清理完成, 删除{}条", deleted); + } catch (Exception e) { + log.error("开放接口日志定时清理失败", e); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/SensitiveDataMasker.java b/src/main/java/com/yunhai/userbase/utils/SensitiveDataMasker.java new file mode 100644 index 0000000..0f6a4dc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/SensitiveDataMasker.java @@ -0,0 +1,119 @@ +package com.yunhai.userbase.utils; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * 敏感字段脱敏工具。
+ * 开放接口日志在入库前,请求参数与响应必须经过本工具:命中敏感字段名(忽略大小写) + * 的键值一律替换为 {@code ***},防止明文密码/密钥/令牌写入日志库。
+ * 实现基于 fastjson 递归遍历(不引新依赖);非 JSON 文本原样返回并打 warn 日志。
+ * + * @author chenx + * @date 2026/8/20 + */ +@Slf4j +public class SensitiveDataMasker { + + /** + * 敏感字段名名单(按小写匹配,命中即掩码)。 + */ + private static final List SENSITIVE_FIELDS = Arrays.asList( + "password", "oldpassword", "newpassword", "confirmpassword", + "appsecret", "secret", "token", "accesskey", "accesskeyid", "accesskeysecret"); + + /** + * 掩码占位符。 + */ + public static final String MASK = "***"; + + private SensitiveDataMasker() { + } + + /** + * 对任意对象做脱敏:先序列化为 JSON 字符串,再递归掩码敏感字段。
+ * 序列化失败或对象本身非 JSON 结构时,返回 null(由调用方决定降级处理)。 + * + * @param source 待脱敏对象(可为 null) + * @return 脱敏后的 JSON 字符串;输入为 null 返回 null + */ + public static String maskObject(Object source) { + if (source == null) { + return null; + } + String json = JSON.toJSONString(source); + if (json == null || json.isEmpty()) { + return json; + } + return maskJson(json); + } + + /** + * 对 JSON 字符串递归脱敏(含嵌套对象/数组)。
+ * 解析失败(非 JSON)时原样返回并 warn。 + * + * @param json JSON 字符串(可为 null/空) + * @return 脱敏后的 JSON 字符串;输入为 null 返回 null + */ + public static String maskJson(String json) { + if (json == null) { + return null; + } + try { + Object parsed = JSON.parse(json); + if (parsed == null) { + return json; + } + maskValue(parsed); + return JSON.toJSONString(parsed); + } catch (Exception e) { + // 非 JSON 文本(如错误消息)不脱敏,原样返回 + log.warn("敏感字段脱敏跳过(非 JSON), len={}", json.length()); + return json; + } + } + + /** + * 递归遍历 JSON 结构,命中敏感字段名的键值替换为掩码。 + */ + private static void maskValue(Object value) { + if (value instanceof JSONObject) { + JSONObject obj = (JSONObject) value; + for (String key : obj.keySet()) { + Object v = obj.get(key); + if (v instanceof JSONObject || v instanceof JSONArray) { + maskValue(v); + } else if (isSensitive(key)) { + obj.put(key, MASK); + } + } + } else if (value instanceof JSONArray) { + JSONArray arr = (JSONArray) value; + for (Object item : arr) { + maskValue(item); + } + } + } + + /** + * 判断字段名是否命中敏感名单(忽略大小写)。 + */ + private static boolean isSensitive(String key) { + if (key == null || key.isEmpty()) { + return false; + } + String lower = key.toLowerCase(Locale.ROOT); + for (String field : SENSITIVE_FIELDS) { + if (lower.contains(field)) { + return true; + } + } + return false; + } +} From db0d5e1a2d90796ba1b9be4d6871e543816d5d1b Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 20 Aug 2026 10:57:23 +0800 Subject: [PATCH 56/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunhai/userbase/aspect/OapiLogAspect.java | 42 ++++++++++++++++++- .../common/GlobalExceptionHandler.java | 14 +++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java b/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java index 965e96e..c204f7b 100644 --- a/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java +++ b/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java @@ -1,7 +1,9 @@ package com.yunhai.userbase.aspect; -import com.alibaba.fastjson.JSON; +import com.yunhai.platform.apistd.ApiResponse; +import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.appauth.AppAuthFilter; +import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.common.annotation.OapiLog; import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.SysOapiLog; @@ -90,6 +92,8 @@ public class OapiLogAspect { } catch (Throwable e) { entity.setStatusCode(500); entity.setErrorMsg(truncate(e.getMessage(), 2000)); + // 业务异常(DException/BizException)同样构造实际返回的响应体落库,便于详情下载 + fillErrorResponse(entity, e, saveResponse, maxResponseSize); throw e; } finally { entity.setCostTime(System.currentTimeMillis() - start); @@ -213,8 +217,42 @@ public class OapiLogAspect { if (!saveResponse || result == null) { return; } + writeResponseData(entity, result, maxResponseSize); + } + + /** + * 异常路径:与 {@code GlobalExceptionHandler} 保持一致的响应体(DException/BizException → ApiResponse), + * 使日志下载内容等于调用方实际收到的 body;其余异常不构造(响应不可预期)。 + */ + private void fillErrorResponse(SysOapiLog entity, Throwable e, boolean saveResponse, int maxResponseSize) { + if (!saveResponse) { + return; + } + ApiResponse response = toApiResponse(e); + if (response == null) { + return; + } + writeResponseData(entity, response, maxResponseSize); + } + + private ApiResponse toApiResponse(Throwable e) { + if (e instanceof DException) { + DException de = (DException) e; + return ApiResponse.fail(de.getCode(), de.getMessage(), de.getData()); + } + if (e instanceof BizException) { + BizException be = (BizException) e; + return ApiResponse.fail(be.getErrorCode(), be.getMessage()); + } + return null; + } + + private void writeResponseData(SysOapiLog entity, Object payload, int maxResponseSize) { + if (payload == null) { + return; + } try { - String resp = SensitiveDataMasker.maskObject(result); + String resp = SensitiveDataMasker.maskObject(payload); if (resp == null) { return; } diff --git a/src/main/java/com/yunhai/userbase/common/GlobalExceptionHandler.java b/src/main/java/com/yunhai/userbase/common/GlobalExceptionHandler.java index 32b2632..2e740f9 100644 --- a/src/main/java/com/yunhai/userbase/common/GlobalExceptionHandler.java +++ b/src/main/java/com/yunhai/userbase/common/GlobalExceptionHandler.java @@ -5,8 +5,10 @@ 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.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; @@ -52,6 +54,18 @@ public class GlobalExceptionHandler { return paramError(msg); } + /** 缺少必需的请求参数(@RequestParam 必填项未传,如 oapi 调用方漏传 groupId)。 */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public Map handleMissingParam(MissingServletRequestParameterException ex) { + return paramError("缺少必需的请求参数: " + ex.getParameterName()); + } + + /** 请求参数类型不匹配(如 Integer 参数收到非数字字符串)。 */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public Map handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + return paramError("参数类型不匹配: " + ex.getName()); + } + private String fieldErrorsMessage(java.util.List fieldErrors) { if (fieldErrors == null || fieldErrors.isEmpty()) { return "参数校验失败"; From 355920746b258378d798557684dd4481b125ed66 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 20 Aug 2026 12:15:48 +0800 Subject: [PATCH 57/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/oapi/UserController.java | 3 +- .../model/oapilog/SysOapiLogListVo.java | 2 + .../service/impl/GroupAuthzService.java | 74 ++++++++----------- .../service/impl/SysGroupService.java | 4 +- .../service/impl/SysOapiLogServiceImpl.java | 8 +- 5 files changed, 44 insertions(+), 47 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index e070276..9623605 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -96,7 +96,8 @@ public class UserController { * * @param request 用于取 AppAuthFilter 设置的 appKey * @param userId 用户ID(必填) - * @param schId 学校ID/组ID(可选;为 null 时按"主学校→主归属组"规则推断) + * @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定, + * 为空时取用户全部真实归属组,与权限一览一致) * @return 该应用下用户已授权模块(扁平化;无权限时返回空列表) */ @GetMapping("modules") diff --git a/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java index 3da66fc..37a5acd 100644 --- a/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java +++ b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java @@ -38,6 +38,8 @@ public class SysOapiLogListVo { private String operationDesc; /** 请求参数JSON(脱敏后) */ private String requestParams; + /** 响应结果(GZIP 解压后原文,脱敏后;随列表带出,详情弹窗直接展示) */ + private String responseData; /** HTTP 状态码 */ private Integer statusCode; /** 耗时(ms) */ diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 4e9af69..a7cfe08 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -1,9 +1,7 @@ package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; -import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; -import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.entity.*; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.platform.utils.enums.IEnum; @@ -104,9 +102,6 @@ public class GroupAuthzService implements IGroupAuthzService { @Autowired private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; - @Autowired - private UserCenterService userCenterService; - // ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId) // ===================== @@ -523,23 +518,43 @@ public class GroupAuthzService implements IGroupAuthzService { } // 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用) Set roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId); - // 4. 上下文组解析(schId 非空=只认这一条链路;schId 空=按"主学校→主归属组"规则推断) - Integer effectiveGroupId = resolveEffectiveGroupId(userId, schId); - // 5. 若上下文组有效=构建其祖先链,并判定:该祖先链开通池里是否包含目标 appId(应用开通闸门) - // 上下文组无效(用户无学校/无组)时,仍可能因角色授权而返回模块,不能直接返回空 - Set selfGroupIds = new LinkedHashSet<>(); + // 4. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug): + // - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身), + // 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块, + // 不把 schId 冒充本组,否则会绕过子组继承闸门、丢本组数据); + // - schId 空:取用户全部真实归属组(与权限一览「用户全局模式」一致)。 + List userRels = sysUserGroupRelRepository.findByUserId(userId); Map allGroupMap = buildAllGroupMap(); + Set selfGroupIds = new LinkedHashSet<>(); + if (schId != null) { + for (SysUserGroupRel rel : userRels) { + Integer gid = rel.getGroupId(); + if (gid == null) { + continue; + } + if (gid.equals(schId) || ancestorChain(gid, allGroupMap).contains(schId)) { + selfGroupIds.add(gid); + } + } + } else { + for (SysUserGroupRel rel : userRels) { + if (rel.getGroupId() != null) { + selfGroupIds.add(rel.getGroupId()); + } + } + } + // 5. 构建各归属组的祖先链与开通池,并判定:任意归属组开通池里是否包含目标 appId(应用开通闸门)。 + // 归属组为空(该学校子树内无组 / 无学校无组)时仍可能因角色授权而返回模块,不能直接返回空。 Map> chainMap = new HashMap<>(); Set allChainIds = new LinkedHashSet<>(); Map> groupToApps = new HashMap<>(); boolean appOpened = false; - if (effectiveGroupId != null) { - selfGroupIds.add(effectiveGroupId); - List chain = ancestorChain(effectiveGroupId, allGroupMap); - chainMap.put(effectiveGroupId, chain); + for (Integer gid : selfGroupIds) { + List chain = ancestorChain(gid, allGroupMap); + chainMap.put(gid, chain); allChainIds.addAll(chain); - Set openedApps = collectAppIdsForAuthzScope(effectiveGroupId); - groupToApps.put(effectiveGroupId, openedApps); + Set openedApps = collectAppIdsForAuthzScope(gid); + groupToApps.put(gid, openedApps); if (openedApps.contains(appId)) { appOpened = true; } @@ -612,33 +627,6 @@ public class GroupAuthzService implements IGroupAuthzService { return result; } - /** - * 上下文组解析(与开放接口 modules 语义严格一致): - * - schId 非空:直接用其作为唯一 selfGroupId; - * - schId 为空:取用户主学校组(BoaUser.school.id); - * - 仍空:取用户主归属组 id(groups 第 0 个); - * - 全空:返回 null(此时只能靠角色授权拿模块)。 - */ - private Integer resolveEffectiveGroupId(Integer userId, Integer schId) { - if (schId != null) { - return schId; - } - BoaUser boa = userCenterService.getUserInfoWithDeptById(userId); - if (boa == null) { - return null; - } - BoaGroup school = boa.getSchool(); - if (school != null && school.getId() != null) { - return school.getId(); - } - List groups = boa.getGroups(); - if (groups != null && !groups.isEmpty()) { - BoaGroup main = groups.get(0); - return main == null ? null : main.getId(); - } - return null; - } - private static Map buildAppModuleMap(List appModules) { Map map = new HashMap<>(); for (AppModule m : appModules) { diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index f7e292d..fbb7cd5 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -65,9 +65,9 @@ public class SysGroupService implements ISysGroupService { // 区域组:只能挂在根节点下(由根节点的白名单约束),其下可挂 学段组/学校组/普通组 ALLOWED_CHILDREN.put(GroupTypeEnum.AREA_GROUP, EnumSet.of( GroupTypeEnum.STAGE_GROUP, GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.NORMAL_GROUP)); - // 学段组(高中/初中/小学):学校组的上级,可含 学校组/普通组 + // 学段组(高中/初中/小学):学校组的上级,可含 学校组/年级组 ALLOWED_CHILDREN.put(GroupTypeEnum.STAGE_GROUP, EnumSet.of( - GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.NORMAL_GROUP)); + GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.GRADE_GROUP)); // 学校组:可含 教师组/学生组/普通组 ALLOWED_CHILDREN.put(GroupTypeEnum.SCHOOL_GROUP, EnumSet.of( GroupTypeEnum.TEACHER_GROUP, GroupTypeEnum.STUDENT_GROUP, GroupTypeEnum.NORMAL_GROUP)); diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java b/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java index 542b9c9..e6327c7 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java @@ -89,6 +89,10 @@ public class SysOapiLogServiceImpl implements ISysOapiLogService { typed.setFirstResult((pageIndex - 1) * pageSize); typed.setMaxResults(pageSize); List content = typed.getResultList(); + // 响应结果随列表带出(GZIP 解压后),供详情弹窗直接展示,无需再单独请求 + for (SysOapiLogListVo vo : content) { + vo.setResponseData(getResponseData(vo.getId())); + } int totalPages = (int) Math.ceil((double) total / pageSize); boolean hasNext = pageIndex < totalPages; @@ -165,9 +169,11 @@ public class SysOapiLogServiceImpl implements ISysOapiLogService { } if (StringUtils.isNotBlank(query.getKeyword())) { String kw = "%" + query.getKeyword().trim() + "%"; + // 单输入框同时匹配:请求路径(requestUri) / 应用名(callerAppName) / appKey(callerAppKey) predicates.add(cb.or( cb.like(root.get("requestUri"), kw), - cb.like(root.get("callerAppName"), kw))); + cb.like(root.get("callerAppName"), kw), + cb.like(cb.toString(root.get("callerAppKey")), kw))); } if (query.getStartTime() != null) { predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime())); From e4d6e1da117266a7418e887ab9e58a4631a2cd96 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 20 Aug 2026 19:05:06 +0800 Subject: [PATCH 58/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=A1=B6=E5=B1=82=E7=BB=84=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/SysGroupService.java | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index fbb7cd5..48b327c 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -127,18 +127,23 @@ public class SysGroupService implements ISysGroupService { @Transactional public BoaGroup add(SysGroupRequest request) { Integer parentId = request.getParentId(); - // 允许在根节点(教育云中心)下建组:parentId 为 null 或 0 视为根 + // parentId 为 null 或 0 表示「挂在根节点(教育云中心)下」; + // 新组必须挂到真实根节点的 id 之下,绝不能把 parentId 写成 0(否则会生成第二个伪根节点)。 GroupTypeEnum parentType = null; // null 表示根节点 + int realPid; if (parentId != null && parentId != ROOT_PARENT_ID) { SysGroup parent = sysGroupRepository.findById(parentId) .orElseThrow(() -> new DException("父组不存在")); parentType = parent.getType(); + realPid = parentId; + } else { + // 解析唯一真实根节点,取其真实 id 作为挂载点 + realPid = resolveRootGroup().getId(); } GroupTypeEnum typeEnum = resolveType(request.getType()); // 层级类型约束:当前父组是否允许创建该子类型 validateParentChild(parentType, typeEnum); - int realPid = (parentId == null) ? ROOT_PARENT_ID : parentId; SysGroup entity = new SysGroup(); entity.setName(request.getName()); entity.setParentId(realPid); @@ -446,8 +451,9 @@ public class SysGroupService implements ISysGroupService { parentType = parent.getType(); realPid = pid; } else { - // 允许在根节点下批量建组 - realPid = ROOT_PARENT_ID; + // pid 为 null 或 0 表示「挂在根节点(教育云中心)下」:解析真实根节点 id 作为挂载点, + // 绝不能写成 ROOT_PARENT_ID(0),否则批量建出的组都会变成伪根节点。 + realPid = resolveRootGroup().getId(); } GroupTypeEnum typeEnum = resolveType(request.getType()); // 层级类型约束 @@ -678,6 +684,25 @@ public class SysGroupService implements ISysGroupService { return entity.getParentId() != null && entity.getParentId() == ROOT_PARENT_ID; } + /** + * 解析唯一真实根节点(parentId=0 中 id 最小者,与 {@link #getGroups()} 约定一致)。
+ * 用于「parentId/ pid 为 0 或 null 表示挂根下」场景:必须把新组挂到该根节点的真实 id 下, + * 而非把 parentId 写成 0(0 仅属于根本身,写成 0 会生成第二个伪根节点)。 + * + * @return 根节点实体 + * @throws DException 当不存在任何根节点时 + */ + private SysGroup resolveRootGroup() { + List roots = sysGroupRepository.findByParentIdOrderBySortAsc(ROOT_PARENT_ID); + if (roots.isEmpty()) { + throw new DException("根节点不存在,无法在根下建组"); + } + // 与 getGroups 约定一致:pid=0 中 id 最小者为唯一真实根节点 + return roots.stream() + .min(Comparator.comparingInt(SysGroup::getId)) + .orElse(roots.get(0)); + } + private SysGroupVo convertToVo(SysGroup entity) { SysGroupVo vo = new SysGroupVo(); BeanUtils.copyProperties(entity, vo);