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
+ *
+ * @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