init: UserBaseService (backend + deploy + scripts)
ci-userbase-service / build-and-push (push) Successful in 40s
ci-userbase-service / build-and-push (push) Successful in 40s
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.entity.AppCategory;
|
||||
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryRequest;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/15 21:30
|
||||
* @description
|
||||
*/
|
||||
public interface IAppCategoryService {
|
||||
|
||||
void add(AppCategoryRequest request);
|
||||
|
||||
void edit(Integer id, AppCategoryRequest request);
|
||||
|
||||
void delete(Integer id);
|
||||
|
||||
List<AppCategoryVo> list(AppCategoryQuery query);
|
||||
|
||||
void sort(List<Integer> ids);
|
||||
|
||||
/**
|
||||
* 应用授权用:获取所有应用类型及其类型下的应用(id/name/icon)。<br>
|
||||
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。<br>
|
||||
*
|
||||
* @return 应用类型 + 应用列表的授权视图
|
||||
*/
|
||||
List<AppAuthzCategoryVo> listWithApps();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleQuery;
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/22
|
||||
* @description 应用模块服务。<br>
|
||||
*/
|
||||
public interface IAppModuleService {
|
||||
|
||||
void add(AppModuleRequest request);
|
||||
|
||||
void edit(Integer id, AppModuleRequest request);
|
||||
|
||||
void delete(Integer id);
|
||||
|
||||
List<AppModuleVo> list(AppModuleQuery query);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
|
||||
import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
|
||||
import com.yunhai.userbase.model.approle.AppRoleUserBindRequest;
|
||||
import com.yunhai.userbase.model.approle.AppRoleUserVo;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleQuery;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleRequest;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleVo;
|
||||
import com.yunhai.userbase.model.approle.UserAppRoleBindRequest;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/27
|
||||
* @description 应用业务角色服务(角色 CRUD + 角色模块授权 + 用户角色绑定)。<br>
|
||||
*/
|
||||
public interface IAppRoleService {
|
||||
|
||||
/**
|
||||
* 分页查询角色列表(name 模糊、appId/allAppFlag/disabled 精确)。<br>
|
||||
* 普通角色回填所属应用名称 appName。<br>
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @param pageable 分页参数
|
||||
* @return 分页角色列表
|
||||
*/
|
||||
Page<SysAppRoleVo> list(SysAppRoleQuery query, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 新增角色。<br>
|
||||
* 规则:普通角色 allAppFlag=false 且 appId 必填(应用须存在),同应用下名称唯一;<br>
|
||||
* 全应用角色 allAppFlag=true 且 appId 必须为空,全应用角色之间名称唯一。<br>
|
||||
*
|
||||
* @param request 角色入参
|
||||
*/
|
||||
void add(SysAppRoleRequest request);
|
||||
|
||||
/**
|
||||
* 编辑角色(可改 name/remark/disabled)。<br>
|
||||
* 角色类型 allAppFlag 与所属应用 appId 不允许修改(修改会导致已授权模块越界),需删除后重建。<br>
|
||||
* 重名校验规则与新增一致(排除自身)。<br>
|
||||
*
|
||||
* @param id 角色ID
|
||||
* @param request 角色入参
|
||||
*/
|
||||
void edit(Integer id, SysAppRoleRequest request);
|
||||
|
||||
/**
|
||||
* 删除角色,并级联清理其模块授权记录与用户绑定记录(逻辑外键,无物理 FK)。<br>
|
||||
*
|
||||
* @param id 角色ID
|
||||
*/
|
||||
void delete(Integer id);
|
||||
|
||||
/**
|
||||
* 角色模块授权页回显:返回该角色可授权的应用及其模块树,已授权模块 checked=true。<br>
|
||||
* 普通角色仅返回其所属应用;全应用角色返回系统全部应用(可跨应用授权)。<br>
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @return 应用列表(含模块权限树)
|
||||
*/
|
||||
List<AppRoleAuthzAppVo> findRoleModuleTree(Integer roleId);
|
||||
|
||||
/**
|
||||
* 角色模块授权保存:全量覆盖式更新 sys_app_role_module_rel(bulk 先删后插)。<br>
|
||||
* 校验:模块必须存在;普通角色只能授权其所属应用下的模块,越权写其他应用的模块会被拒绝。<br>
|
||||
*
|
||||
* @param request 角色ID + 勾选的模块ID全量列表
|
||||
*/
|
||||
void saveRoleModules(AppRoleModuleAssignRequest request);
|
||||
|
||||
/**
|
||||
* 查询角色已绑定的用户列表。<br>
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @return 用户列表
|
||||
*/
|
||||
List<AppRoleUserVo> findRoleUsers(Integer roleId);
|
||||
|
||||
/**
|
||||
* 角色绑定用户保存(角色维度,全量覆盖):bulk 先删该角色全部绑定再批量插入。<br>
|
||||
*
|
||||
* @param request 角色ID + 用户ID全量列表
|
||||
*/
|
||||
void saveRoleUsers(AppRoleUserBindRequest request);
|
||||
|
||||
/**
|
||||
* 用户绑定角色保存(用户维度,全量覆盖):bulk 先删该用户全部绑定再批量插入。<br>
|
||||
*
|
||||
* @param request 用户ID + 角色ID全量列表
|
||||
*/
|
||||
void saveUserRoles(UserAppRoleBindRequest request);
|
||||
|
||||
/**
|
||||
* 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。<br>
|
||||
*
|
||||
* @param roleId 角色ID
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void removeRoleUser(Integer roleId, Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 应用信息业务接口(缓存由 Spring Cache 注解统一管控)。
|
||||
*/
|
||||
public interface IApplicationInfoService {
|
||||
|
||||
/**
|
||||
* 根据 id 查询应用信息(带缓存,防击穿)。
|
||||
*/
|
||||
ApplicationInfo getById(Integer id);
|
||||
|
||||
/**
|
||||
* 保存/更新应用信息(写后同步更新缓存)。
|
||||
*/
|
||||
ApplicationInfo save(ApplicationInfo info);
|
||||
|
||||
/**
|
||||
* 根据 id 删除应用信息(同步清除缓存)。
|
||||
*/
|
||||
void deleteById(Integer id);
|
||||
|
||||
/**
|
||||
* 新增应用信息。<br>
|
||||
* 由入参构造实体并落库;appKey 为整型,从 100000 起按数据库最大值自增,appSecret 为 32 位 UUID。<br>
|
||||
*
|
||||
* @param request 应用新增入参
|
||||
*/
|
||||
void add(ApplicationInfoRequest request);
|
||||
|
||||
/**
|
||||
* 编辑应用信息。<br>
|
||||
* 按 id 查找后拷贝入参字段并保存,appKey/appSecret 不随编辑变更。<br>
|
||||
*
|
||||
* @param id 应用ID
|
||||
* @param request 应用编辑入参
|
||||
*/
|
||||
void edit(Integer id, ApplicationInfoRequest request);
|
||||
|
||||
/**
|
||||
* 根据 id 删除应用信息。<br>
|
||||
*
|
||||
* @param id 应用ID
|
||||
*/
|
||||
void delete(Integer id);
|
||||
|
||||
/**
|
||||
* 重设应用密钥(appSecret)。<br>
|
||||
* 按 id 查找应用并生成新的 32 位 UUID 密钥后保存。<br>
|
||||
*
|
||||
* @param id 应用ID
|
||||
*/
|
||||
void resetAppSecret(Integer id);
|
||||
|
||||
/**
|
||||
* 根据条件分页查询应用列表。<br>
|
||||
*
|
||||
* @param query 查询条件(name 关键词按名称模糊或 appKey 精确匹配,categoryId 按类别精确匹配)
|
||||
* @param pageable 分页参数(page 从 0 起,size 每页条数)
|
||||
* @return 分页后的应用视图(含总条数 totalElements)
|
||||
*/
|
||||
Page<ApplicationInfoVo> list(ApplicationInfoQuery query, Pageable pageable);
|
||||
|
||||
List<ApplicationInfoSortVo> findListByCategoryId(Integer categoryId);
|
||||
|
||||
/**
|
||||
* 同一应用类型内排序。<br>
|
||||
* 入参为该类型下应用的完整有序 id 列表,按列表顺序写入 sortNo(1 起)。<br>
|
||||
* 接口会额外校验:所有 id 必须存在且同属一个应用类型,避免跨类型串号导致排序错乱。<br>
|
||||
*
|
||||
* @param ids 同一应用类型下的应用ID有序列表
|
||||
*/
|
||||
void sort(List<Integer> ids);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.model.groupauthz.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/23
|
||||
* @description 组应用权限服务(应用开通 + 组授权 + 权限判定)。<br>
|
||||
*/
|
||||
public interface IGroupAuthzService {
|
||||
|
||||
/**
|
||||
* 功能1回显:查询系统全量应用列表,并标记该组已开通的应用(checked)。<br>
|
||||
*
|
||||
* @param groupId 组ID
|
||||
* @return 全量应用列表(含开通勾选状态)
|
||||
*/
|
||||
List<GroupAppVo> listGroupApps(Integer groupId);
|
||||
|
||||
/**
|
||||
* 功能1保存:全量覆盖式更新组的应用开通记录(先删旧记录再批量插入)。<br>
|
||||
* 被取消开通的应用,同步清理该组在其模块上的权限记录,避免残留脏数据。<br>
|
||||
*
|
||||
* @param request 组ID + 勾选的应用ID全量列表
|
||||
*/
|
||||
void assignAppsToGroup(GroupAppAssignRequest request);
|
||||
|
||||
/**
|
||||
* 功能2数据加载:查询该组已开通的应用及其模块树,并回显勾选状态与子组继承开关。<br>
|
||||
* 仅返回 sys_group_app_rel 中有记录的应用;未开通的应用不显示。<br>
|
||||
*
|
||||
* @param groupId 组ID
|
||||
* @return 已开通应用列表(含模块权限树)
|
||||
*/
|
||||
List<GroupAuthzAppVo> findGroupAppsWithModules(Integer groupId);
|
||||
|
||||
/**
|
||||
* 功能2数据加载(按应用分类分组版):与 {@link #findGroupAppsWithModules(Integer)} 数据一致,<br>
|
||||
* 但按应用类型分类聚合,便于组授权页面按分类面板展示。<br>
|
||||
* 返回全部分类(含空数组),保证前端分类筛选下拉完整。<br>
|
||||
*
|
||||
* @param groupId 组ID
|
||||
* @return 按应用分类分组的已开通应用列表
|
||||
*/
|
||||
List<GroupAuthzCategoryVo> findGroupAppsWithModulesByCategory(Integer groupId);
|
||||
|
||||
/**
|
||||
* 功能2保存:全量覆盖式更新组的模块权限。<br>
|
||||
* 有旧记录则更新 inheritFlag,无则新增,不在传入列表中的旧记录物理删除。<br>
|
||||
*
|
||||
* @param request 组ID + 勾选的模块权限全量列表(moduleId + inheritFlag)
|
||||
*/
|
||||
void saveModulePermissions(GroupModulePermRequest request);
|
||||
|
||||
/**
|
||||
* 权限判定(供登录/拦截器使用):判断用户对某模块是否有权限。<br>
|
||||
* 规则:用户任一所属组,沿祖先链回溯——自身显式勾选即通过;祖先勾选且 inheritFlag=true 即通过。<br>
|
||||
* 前置校验:该所属组必须已开通模块所属应用(最终权限 = 继承的模块权限 ∩ 该组已开通的应用)。<br>
|
||||
* 角色授权并集:用户经「启用状态」业务角色获得该模块授权也通过,且不要求组已开通模块所属应用;禁用角色不参与判定。<br>
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param moduleId 模块ID
|
||||
* @return true=有权限
|
||||
*/
|
||||
boolean hasModulePermission(Integer userId, Integer moduleId);
|
||||
|
||||
/**
|
||||
* 权限一览:查询指定组下的直接用户列表(不含子组,不分页)。<br>
|
||||
*
|
||||
* @param groupId 组ID
|
||||
* @return 用户列表
|
||||
*/
|
||||
List<UserPermissionUserVo> findGroupUsers(Integer groupId);
|
||||
|
||||
/**
|
||||
* 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。<br>
|
||||
* 返回用户及其主归属部门/组织路径。
|
||||
*
|
||||
* @param keyword 搜索关键字
|
||||
* @return 用户列表
|
||||
*/
|
||||
List<UserPermissionUserVo> searchUsers(String keyword);
|
||||
|
||||
/**
|
||||
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
|
||||
* 只返回<b>有授权模块</b>的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
|
||||
* 权限判定复用 {@link #hasModulePermission(Integer, Integer)} 的祖先链继承逻辑。<br>
|
||||
* 角色授权并集:最终模块权限 = 组授权模块 ∪ 启用角色授权模块;<br>
|
||||
* 最终应用范围 = 组开通应用 ∪ 角色模块所属应用(角色模块不要求组必须先开通应用)。<br>
|
||||
*
|
||||
* @param groupId 组ID(上下文,可选;目前仅用于校验用户存在性,实际聚合以 userId 为准)
|
||||
* @param userId 用户ID
|
||||
* @return 按应用类型分组的已授权应用模块树
|
||||
*/
|
||||
List<UserPermissionCategoryVo> findUserPermissionTree(Integer groupId, Integer userId);
|
||||
|
||||
/**
|
||||
* 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。<br>
|
||||
* 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。<br>
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户所属角色列表
|
||||
*/
|
||||
List<UserRoleVo> findUserRoles(Integer userId);
|
||||
|
||||
/**
|
||||
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
|
||||
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组),
|
||||
* 多个归属组时合并为一棵树(公共祖先只出现一次)。<br>
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 所属部门树(根节点列表);用户无组归属时返回空列表
|
||||
*/
|
||||
List<UserGroupNodeVo> findUserGroupTree(Integer userId);
|
||||
|
||||
// ===================== 新增:树节点式增量授权(供左侧树 + 右侧 appAuthzList 的勾选交互) =====================
|
||||
|
||||
/**
|
||||
* 获取某组「已开通的应用ID列表」,供前端右侧应用清单回显勾选状态。<br>
|
||||
* <b>实现思路</b>:应用开通「不继承」(见 SysGroupAppRel 设计)——直接查 sys_group_app_rel 中 group_id=nodeId 的记录,<br>
|
||||
* 返回其 app_id 集合;<b>不</b>沿祖先链回填父组开通的应用(父组开通不代表子组自动拥有)。<br>
|
||||
* 与现有 GroupAppList(返回全量应用 + checked)的区别:本接口只返回已选 id 列表,更轻量,适合做勾选回显。
|
||||
*
|
||||
* @param nodeId 左侧树点击的组节点ID
|
||||
* @return 该组已开通的应用ID列表(无则空列表)
|
||||
*/
|
||||
List<Integer> findAuthorizedAppIds(Integer nodeId);
|
||||
|
||||
/**
|
||||
* 增量设置某组的应用开通/取消(对应前端「应用多选框」勾选/取消)。<br>
|
||||
* <b>入参</b>:{@link SchoolAppAuthzRequest} 封装 schid / appids(逗号分隔字符串) / mode。<br>
|
||||
* <b>实现思路</b>:<br>
|
||||
* - mode=0(追加/APPEND):对 appids 中每个应用,若 sys_group_app_rel 尚无记录则插入(已存在则跳过,幂等)。<br>
|
||||
* - mode=2(删除/DELETE):对每个应用删除开通记录,并<b>同步清理该组在该应用下模块的权限残留</b>(与现有 assignAppsToGroup 取消开通时行为一致,避免脏数据)。<br>
|
||||
* - 应用开通「不继承」,故<b>仅操作传入的 schId 自身,不做子组级联</b>。<br>
|
||||
* - mode=1(覆盖) 不适用于单条增量,直接拒绝。
|
||||
*
|
||||
* @param request 学校组应用授权请求(组ID + 应用ID字符串 + 操作模式)
|
||||
*/
|
||||
void setSchoolApps(SchoolAppAuthzRequest request);
|
||||
|
||||
/**
|
||||
* 增量设置「组-应用-模块」的模块授权(对应前端「组授权」面板逐条勾选/取消)。<br>
|
||||
* <b>实现思路</b>:逐条处理 items 列表(顺序即执行顺序,后者覆盖前者):<br>
|
||||
* - mode=0(追加):对 (groupId, 展开后的 moduleId) upsert 一条 sys_group_module_perm,inherit_flag=isAll。<br>
|
||||
* - mode=2(删除):删除对应权限记录。<br>
|
||||
* - moduleId=0 表示「该应用下全部模块」,展开为应用下每个真实模块各写/删一条(不写 module_id=0 伪记录)。<br>
|
||||
* - <b>isAll 的语义(重点)</b>:直接映射现有 sys_group_module_perm.inherit_flag 列。<br>
|
||||
* isAll=1 → inherit_flag=true:子孙组在 hasModulePermission 判定时沿祖先链自动继承该模块权限(逻辑继承,不物理写子组);<br>
|
||||
* isAll=0 → inherit_flag=false:仅本组生效。<br>
|
||||
* 这与「应用不继承 / 模块可继承」的对称性一致,且复用现有权限判定逻辑,不破坏其他功能。<br>
|
||||
* - 越权防护:模块必须属于该 appId;追加前要求该组已开通该应用(与现有 SaveModulePerms 边界一致)。<br>
|
||||
* <br>
|
||||
* <i>若产品实际想要「物理写子组」(子组各存一条 perm,而非逻辑继承),把 isAll=1 分支改成「对 groupId + 其全部子孙组分别 upsert」即可,详见实现类注释。</i>
|
||||
*
|
||||
* @param items 模块授权条目列表(groupId/appId/moduleId/mode/isAll)
|
||||
*/
|
||||
void setGroupModulePerms(List<GroupModuleAuthzItem> items);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/20 16:00
|
||||
* @description 系统组服务接口
|
||||
*/
|
||||
public interface ISysGroupService {
|
||||
|
||||
void add(SysGroupRequest request);
|
||||
|
||||
void edit(Integer id, SysGroupRequest request);
|
||||
|
||||
void delete(Integer id);
|
||||
|
||||
List<SysGroupVo> list(SysGroupQuery query);
|
||||
|
||||
/** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */
|
||||
List<SysGroupVo> getTree();
|
||||
|
||||
/**
|
||||
* 获取「学校组」剪枝树:末端叶子一定是学校组。<br>
|
||||
* 规则:遇到学校组即截断(学校组作为叶子,其下所有组不显示);<br>
|
||||
* 子树中不含任何学校组的非学校组节点整体剪掉;根节点始终保留。<br>
|
||||
*/
|
||||
List<SysGroupVo> getSchoolTree();
|
||||
|
||||
/** 在指定父组下按规则批量生成子组。 */
|
||||
void batchAdd(SysGroupBatchRequest request);
|
||||
|
||||
/** 对 pid 的直接子节点按给定顺序重排并落库(ids 必须全是直接子节点)。 */
|
||||
void sortChildren(SysGroupSortRequest request);
|
||||
|
||||
/** 将 moveId 移动到 pid 下(环检测)。 */
|
||||
void move(SysGroupMoveRequest request);
|
||||
|
||||
/**
|
||||
* 收集指定组自身及其所有子孙组的ID(沿 parentId 向下遍历)。<br>
|
||||
* 用于「查询该节点及其子组下所有用户」等子树聚合场景。<br>
|
||||
*
|
||||
* @param groupId 组ID
|
||||
* @return 含自身在内的组ID集合;组不存在时返回仅含自身的集合
|
||||
*/
|
||||
List<Integer> listSelfAndDescendantIds(Integer groupId);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.entity.SysOperationLog;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.oplog.SysOperationLogListVo;
|
||||
import com.yunhai.userbase.model.oplog.SysOperationLogQuery;
|
||||
|
||||
/**
|
||||
* 系统操作日志业务接口。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/7/28
|
||||
*/
|
||||
public interface ISysOperationLogService {
|
||||
|
||||
/**
|
||||
* 异步保存操作日志。<br>
|
||||
* 由 AOP 在 finally 中调用,失败仅记录日志,不影响主业务。<br>
|
||||
*
|
||||
* @param entity 日志实体
|
||||
*/
|
||||
void saveAsync(SysOperationLog entity);
|
||||
|
||||
/**
|
||||
* 分页查询操作日志列表。<br>
|
||||
* 列表不含响应结果(response_data),避免加载 MEDIUMBLOB 影响性能。<br>
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @param pageQuery 分页参数(pageIndex 从 1 起)
|
||||
* @return 分页结果
|
||||
*/
|
||||
BasePageResult<SysOperationLogListVo> pageQuery(SysOperationLogQuery query, BasePageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 获取某条日志的响应结果(解压后原文)。<br>
|
||||
*
|
||||
* @param id 日志ID
|
||||
* @return 解压后的响应字符串,无记录或无响应数据返回 null
|
||||
*/
|
||||
String getResponseData(Long id);
|
||||
|
||||
/**
|
||||
* 清理过期日志。<br>
|
||||
* 一般日志(level=3)保留 6 个月,重要(level=1)/次要(level=2)保留 12 个月。<br>
|
||||
*
|
||||
* @return 删除条数
|
||||
*/
|
||||
long cleanupExpired();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.model.sysuser.SysUserQuery;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserVo;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/20 16:14
|
||||
* @description 系统用户服务接口
|
||||
*/
|
||||
public interface ISysUserService {
|
||||
|
||||
/** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。 */
|
||||
void add(SysUserRequest request);
|
||||
|
||||
/** 编辑用户;密码为空时不修改密码。 */
|
||||
void edit(Integer id, SysUserRequest request);
|
||||
|
||||
/** 删除用户:同时清理其组用户关联。 */
|
||||
void delete(Integer id);
|
||||
|
||||
/**
|
||||
* 启用/禁用用户。<br>
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @param disabled true=禁用,false=启用
|
||||
*/
|
||||
void changeStatus(Integer id, boolean disabled);
|
||||
|
||||
/**
|
||||
* 分页查询组下用户。<br>
|
||||
* 依据 query.groupId 与 query.withSub 决定组范围,query.name 做多字段匹配。<br>
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @param pageable 分页与排序
|
||||
* @return 用户分页
|
||||
*/
|
||||
Page<SysUserVo> list(SysUserQuery query, Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 应用签名密钥的 Redis 桥接服务。<br><br>
|
||||
* 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的
|
||||
* common-sign-starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。<br><br>
|
||||
* 约定(读侧 starter 必须严格一致):<br>
|
||||
* 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};<br>
|
||||
* 2) value 为 fastjson 序列化的 JSON 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};<br>
|
||||
* 3) 不设过期时间,依赖 Redis 持久化(AOF/RDB)保证重启不丢;删除应用时直接删 key(等价于禁用)。
|
||||
*/
|
||||
@Service
|
||||
public class SignKeyCacheService implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SignKeyCacheService.class);
|
||||
|
||||
/** Redis key 前缀;读侧 SignKeyResolver 必须保持一致:sign:app:{appKey} */
|
||||
public static final String KEY_PREFIX = "sign:app:";
|
||||
|
||||
@Autowired
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Autowired
|
||||
private ApplicationInfoRepository applicationInfoRepository;
|
||||
|
||||
/**
|
||||
* 同步单条应用到 Redis(新增 / 编辑 / 重置密钥时调用)。
|
||||
*/
|
||||
public void syncOne(ApplicationInfo info) {
|
||||
if (info == null || info.getAppKey() == null || info.getAppSecret() == null) {
|
||||
return;
|
||||
}
|
||||
String key = KEY_PREFIX + info.getAppKey();
|
||||
SignAppKeyPayload payload = new SignAppKeyPayload(
|
||||
String.valueOf(info.getAppKey()), info.getAppSecret(), true);
|
||||
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(payload));
|
||||
log.info("[SignKeyCache] sync appKey={} -> redis", info.getAppKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 移除某应用的密钥(删除应用时调用,等价于禁用该应用签名)。
|
||||
*/
|
||||
public void removeOne(Integer appKey) {
|
||||
if (appKey == null) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.delete(KEY_PREFIX + appKey);
|
||||
log.info("[SignKeyCache] remove appKey={} from redis", appKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量刷新:把数据库全部应用重新写入 Redis,并清理库中已不存在的残留 key。<br>
|
||||
* 用于 Redis 数据丢失 / 不一致后的手动恢复,以及首次部署预热。
|
||||
*
|
||||
* @return 实际写入的密钥条数
|
||||
*/
|
||||
public int refreshAll() {
|
||||
List<ApplicationInfo> all = applicationInfoRepository.findAll();
|
||||
Set<String> dbKeys = new HashSet<>();
|
||||
for (ApplicationInfo info : all) {
|
||||
if (info.getAppKey() == null || info.getAppSecret() == null) {
|
||||
continue;
|
||||
}
|
||||
syncOne(info);
|
||||
dbKeys.add(KEY_PREFIX + info.getAppKey());
|
||||
}
|
||||
// 清理残留:DB 已删除但 Redis 仍存在的 key(防止被删应用仍可验签)
|
||||
Set<String> redisKeys = stringRedisTemplate.keys(KEY_PREFIX + "*");
|
||||
if (redisKeys != null) {
|
||||
for (String k : redisKeys) {
|
||||
if (!dbKeys.contains(k)) {
|
||||
stringRedisTemplate.delete(k);
|
||||
log.info("[SignKeyCache] prune stale key={}", k);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("[SignKeyCache] refreshAll done, synced={}", dbKeys.size());
|
||||
return dbKeys.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动预热:应用启动后尽力把密钥同步到 Redis。
|
||||
* Redis 不可用时仅告警,不影响应用启动(此时验签依赖后续手动刷新或 Redis 恢复)。
|
||||
*/
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
try {
|
||||
int n = refreshAll();
|
||||
log.info("[SignKeyCache] startup warm-up synced={}", n);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SignKeyCache] startup warm-up failed (redis may be unavailable): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis value 的 JSON 载体。字段名即契约,读侧 starter 的解析类需一一对应。
|
||||
*/
|
||||
public static class SignAppKeyPayload {
|
||||
private String appKey;
|
||||
private String secret;
|
||||
private boolean enabled;
|
||||
|
||||
public SignAppKeyPayload() {
|
||||
}
|
||||
|
||||
public SignAppKeyPayload(String appKey, String secret, boolean enabled) {
|
||||
this.appKey = appKey;
|
||||
this.secret = secret;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getAppKey() {
|
||||
return appKey;
|
||||
}
|
||||
|
||||
public void setAppKey(String appKey) {
|
||||
this.appKey = appKey;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用户中心核心服务:把 UserBase 自身的 sys_user / sys_group 组装成基础平台统一的
|
||||
* {@link BoaUser}(含部门/学校/教育局多级组织)。<br><br>
|
||||
* 这是「UserBase 由子应用升级为用户中心」的关键:平台侧 {@code UserDetailsProvider}
|
||||
* ({@code UserBaseUserDetailsProvider})与开放接口({@code OpenBaseController})都复用本服务,
|
||||
* 保证 CAS 会话流与签名开放流返回的 BoaUser 结构完全一致。
|
||||
*/
|
||||
@Service
|
||||
public class UserCenterService {
|
||||
|
||||
/** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */
|
||||
private static final int ROOT_PARENT_ID = 0;
|
||||
|
||||
@Autowired
|
||||
private SysUserRepository sysUserRepository;
|
||||
@Autowired
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
@Autowired
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
|
||||
public BoaUser getUserInfoWithDeptByAccount(String account) {
|
||||
if (account == null) {
|
||||
return null;
|
||||
}
|
||||
return resolve(sysUserRepository.findByAccount(account.trim()));
|
||||
}
|
||||
|
||||
public BoaUser getUserInfoWithDeptByPhone(String phone) {
|
||||
if (phone == null) {
|
||||
return null;
|
||||
}
|
||||
return resolve(sysUserRepository.findByPhone(phone.trim()));
|
||||
}
|
||||
|
||||
public BoaUser getUserInfoWithDeptById(Integer id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
return resolve(sysUserRepository.findById(id).orElse(null));
|
||||
}
|
||||
|
||||
private BoaUser resolve(SysUser user) {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
BoaUser boa = new BoaUser();
|
||||
boa.setId(user.getId());
|
||||
boa.setAccount(user.getAccount());
|
||||
boa.setName(user.getRealName());
|
||||
boa.setIdcard(user.getIdcard());
|
||||
boa.setPhone(user.getPhone());
|
||||
boa.setProfilePicture(user.getProfilePicture());
|
||||
// 用户表无 email / gender 列,置 null
|
||||
boa.setEmail(null);
|
||||
boa.setGender(null);
|
||||
boa.setType(user.getType() != null ? user.getType().getKey() : null);
|
||||
// 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用)
|
||||
boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0);
|
||||
boa.setRoleIds(user.getRoles());
|
||||
|
||||
List<BoaGroup> groups = buildGroups(user.getId());
|
||||
boa.setGroups(groups);
|
||||
deriveOrg(groups, boa);
|
||||
return boa;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装用户组织链:取主归属组(isMain=1,否则首个关联),沿 parentId 向上回溯到根,
|
||||
* 顺序为「主组 -> ... -> 根方向」(近 -> 远)。
|
||||
*/
|
||||
private List<BoaGroup> buildGroups(Integer userId) {
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserId(userId);
|
||||
if (rels == null || rels.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
SysUserGroupRel mainRel = rels.stream()
|
||||
.filter(r -> r.getIsMain() != null && r.getIsMain() == 1)
|
||||
.findFirst()
|
||||
.orElse(rels.get(0));
|
||||
Integer groupId = mainRel.getGroupId();
|
||||
if (groupId == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<BoaGroup> chain = new ArrayList<>();
|
||||
Integer cursor = groupId;
|
||||
Set<Integer> seen = new HashSet<>();
|
||||
while (cursor != null && cursor != ROOT_PARENT_ID && seen.add(cursor)) {
|
||||
SysGroup g = sysGroupRepository.findById(cursor).orElse(null);
|
||||
if (g == null) {
|
||||
break;
|
||||
}
|
||||
BoaGroup bg = new BoaGroup();
|
||||
bg.setId(g.getId());
|
||||
bg.setName(g.getName());
|
||||
bg.setParentId(g.getParentId());
|
||||
bg.setType(g.getType() != null ? g.getType().getKey() : null);
|
||||
chain.add(bg);
|
||||
cursor = g.getParentId();
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** 从组织链推导 school / bureau / dept。 */
|
||||
private void deriveOrg(List<BoaGroup> groups, BoaUser boa) {
|
||||
if (groups == null || groups.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BoaGroup school = null;
|
||||
BoaGroup bureau = null;
|
||||
for (BoaGroup g : groups) {
|
||||
if (school == null && Objects.equals(g.getType(), GroupTypeEnum.SCHOOL_GROUP.getKey())) {
|
||||
school = g;
|
||||
}
|
||||
if (bureau == null && Objects.equals(g.getType(), GroupTypeEnum.AREA_GROUP.getKey())) {
|
||||
bureau = g;
|
||||
}
|
||||
}
|
||||
boa.setSchool(school);
|
||||
boa.setBureau(bureau);
|
||||
// 部门:主组(链的近端第一个);若主组本身为学校,则部门即学校
|
||||
boa.setDept(groups.get(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.userbase.entity.AppCategory;
|
||||
import com.yunhai.userbase.model.appauthz.AppAuthzAppRow;
|
||||
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryRequest;
|
||||
import com.yunhai.userbase.model.appcategory.AppCategoryVo;
|
||||
import com.yunhai.userbase.repository.AppCategoryRepository;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import com.yunhai.userbase.service.IAppCategoryService;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.transaction.Transactional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/15 21:30
|
||||
* @description
|
||||
*/
|
||||
@Service
|
||||
public class AppCategoryService implements IAppCategoryService {
|
||||
@Autowired
|
||||
private AppCategoryRepository appCategoryRepository;
|
||||
|
||||
@Autowired
|
||||
private ApplicationInfoRepository applicationInfoRepository;
|
||||
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(AppCategoryRequest request) {
|
||||
// 重名校验:新增类别名称不允许重复
|
||||
if (appCategoryRepository.existsByName(request.getName())) {
|
||||
throw new DException("应用类型已存在");
|
||||
}
|
||||
AppCategory entity = new AppCategory();
|
||||
entity.setName(request.getName());
|
||||
|
||||
if (request.getSortNo() == null) {
|
||||
Integer maxSortNo = appCategoryRepository.findMaxSortNo();
|
||||
entity.setSortNo(maxSortNo == null ? 1 : maxSortNo + 1);
|
||||
} else {
|
||||
entity.setSortNo(request.getSortNo());
|
||||
}
|
||||
|
||||
appCategoryRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void edit(Integer id, AppCategoryRequest request) {
|
||||
AppCategory entity = appCategoryRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("应用类别不存在"));
|
||||
// 重名校验:编辑时排除自身
|
||||
if (appCategoryRepository.existsByNameAndIdNot(request.getName(), id)) {
|
||||
throw new DException("应用类型已存在");
|
||||
}
|
||||
entity.setName(request.getName());
|
||||
if (request.getSortNo() != null) {
|
||||
entity.setSortNo(request.getSortNo());
|
||||
}
|
||||
appCategoryRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Integer id) {
|
||||
if (applicationInfoRepository.countByCategoryId(id) > 0) {
|
||||
throw new RuntimeException("该类别下存在应用,无法删除");
|
||||
}
|
||||
appCategoryRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppCategoryVo> list(AppCategoryQuery query) {
|
||||
List<AppCategory> list;
|
||||
if (StringUtils.hasText(query.getName())) {
|
||||
list = appCategoryRepository.findByNameContainingOrderBySortNoAsc(query.getName());
|
||||
} else {
|
||||
list = appCategoryRepository.findAllByOrderBySortNoAsc();
|
||||
}
|
||||
return list.stream().map(this::convertToVo).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void sort(List<Integer> ids) {
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
if (!appCategoryRepository.existsById(ids.get(i))) {
|
||||
throw new RuntimeException("类别不存在: " + ids.get(i));
|
||||
}
|
||||
appCategoryRepository.updateSortNoById(ids.get(i), i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppAuthzCategoryVo> listWithApps() {
|
||||
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
|
||||
List<AppAuthzAppRow> rows = applicationInfoRepository.findAllAuthzRows();
|
||||
// categoryId -> 该类型下应用列表(查询已按 sortNo、id 升序,插入顺序即排序)
|
||||
Map<Integer, List<AppAuthzCategoryVo.AppItem>> appMap = new HashMap<>();
|
||||
for (AppAuthzAppRow row : rows) {
|
||||
AppAuthzCategoryVo.AppItem item = new AppAuthzCategoryVo.AppItem();
|
||||
item.setId(row.getId());
|
||||
item.setName(row.getName());
|
||||
appMap.computeIfAbsent(row.getCategoryId(), k -> new ArrayList<>()).add(item);
|
||||
}
|
||||
List<AppAuthzCategoryVo> result = new ArrayList<>();
|
||||
for (AppCategory category : categories) {
|
||||
AppAuthzCategoryVo vo = new AppAuthzCategoryVo();
|
||||
vo.setId(category.getId());
|
||||
vo.setName(category.getName());
|
||||
vo.setApp(appMap.getOrDefault(category.getId(), new ArrayList<>()));
|
||||
result.add(vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private AppCategoryVo convertToVo(AppCategory entity) {
|
||||
AppCategoryVo vo = new AppCategoryVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
return vo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.bestsch.utils.DException;
|
||||
import com.yunhai.userbase.entity.AppModule;
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleQuery;
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
|
||||
import com.yunhai.userbase.model.appmodule.AppModuleVo;
|
||||
import com.yunhai.userbase.repository.AppModuleRepository;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
|
||||
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
|
||||
import com.yunhai.userbase.service.IAppModuleService;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.transaction.Transactional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/22
|
||||
* @description 应用模块服务:树形模块的增删改查与层级校验(逻辑外键,最深5层)。<br>
|
||||
*/
|
||||
@Service
|
||||
public class AppModuleService implements IAppModuleService {
|
||||
|
||||
/** 模块最大层级(根模块=1,最深5层)。<br> */
|
||||
private static final int MAX_DEPTH = 5;
|
||||
|
||||
@Autowired
|
||||
private AppModuleRepository appModuleRepository;
|
||||
|
||||
@Autowired
|
||||
private ApplicationInfoRepository applicationInfoRepository;
|
||||
|
||||
@Autowired
|
||||
private SysGroupModulePermRepository sysGroupModulePermRepository;
|
||||
|
||||
@Autowired
|
||||
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(AppModuleRequest request) {
|
||||
Objects.requireNonNull(request, "模块新增入参不能为空");
|
||||
if (!applicationInfoRepository.existsById(request.getAppId())) {
|
||||
throw new DException("所属应用不存在");
|
||||
}
|
||||
if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) {
|
||||
throw new DException("自定义ID在该应用下已存在");
|
||||
}
|
||||
AppModule entity = new AppModule();
|
||||
BeanUtils.copyProperties(request, entity);
|
||||
entity.setId(null);
|
||||
entity.setLevel(resolveLevel(request.getAppId(), request.getParentId()));
|
||||
// sortNo 默认为空(由前端编辑时带入),不自动递增
|
||||
appModuleRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void edit(Integer id, AppModuleRequest request) {
|
||||
Objects.requireNonNull(id, "模块ID不能为空");
|
||||
Objects.requireNonNull(request, "模块编辑入参不能为空");
|
||||
AppModule entity = appModuleRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("模块不存在: " + id));
|
||||
if (appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), request.getCustomId(), id)) {
|
||||
throw new DException("自定义ID在该应用下已存在");
|
||||
}
|
||||
// 可编辑字段:name/customId/path/sortNo;id、appId 不可改
|
||||
entity.setName(request.getName());
|
||||
entity.setCustomId(request.getCustomId());
|
||||
entity.setPath(request.getPath());
|
||||
entity.setSortNo(request.getSortNo());
|
||||
// parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级
|
||||
if (!Objects.equals(request.getParentId(), entity.getParentId())) {
|
||||
moveSubtree(entity, request.getParentId());
|
||||
} else {
|
||||
appModuleRepository.save(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Integer id) {
|
||||
Objects.requireNonNull(id, "模块ID不能为空");
|
||||
if (!appModuleRepository.existsById(id)) {
|
||||
throw new RuntimeException("模块不存在: " + id);
|
||||
}
|
||||
if (appModuleRepository.countByParentId(id) > 0) {
|
||||
throw new DException("该模块下存在子模块,无法删除");
|
||||
}
|
||||
// AI-FIX: 删除模块时级联清理各组对该模块的权限记录(逻辑外键无物理 FK,避免残留孤儿数据)
|
||||
sysGroupModulePermRepository.deleteByModuleIdInBulk(Collections.singletonList(id));
|
||||
// AI-FIX(角色授权): 删除模块时级联清理各业务角色对该模块的授权(逻辑外键无物理 FK)
|
||||
sysAppRoleModuleRelRepository.deleteByModuleIdInBulk(Collections.singletonList(id));
|
||||
appModuleRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppModuleVo> list(AppModuleQuery query) {
|
||||
// 载入该应用下全部模块(忽略 parentId/name,树在后处理阶段组装)
|
||||
List<AppModule> all = appModuleRepository.findModules(query.getAppId(), null, null);
|
||||
if (all.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// parentId -> 子节点列表
|
||||
Map<Integer, List<AppModule>> childrenMap = new HashMap<Integer, List<AppModule>>();
|
||||
for (AppModule m : all) {
|
||||
Integer key = (m.getParentId() == null) ? 0 : m.getParentId();
|
||||
childrenMap.computeIfAbsent(key, k -> new ArrayList<AppModule>()).add(m);
|
||||
}
|
||||
// 每个兄弟组按 sortNo(空视为999) 升序,再按 id 升序
|
||||
Comparator<AppModule> siblingOrder = (a, b) -> {
|
||||
int sa = a.getSortNo() == null ? 999 : a.getSortNo();
|
||||
int sb = b.getSortNo() == null ? 999 : b.getSortNo();
|
||||
if (sa != sb) {
|
||||
return Integer.compare(sa, sb);
|
||||
}
|
||||
return Integer.compare(a.getId(), b.getId());
|
||||
};
|
||||
for (List<AppModule> siblings : childrenMap.values()) {
|
||||
siblings.sort(siblingOrder);
|
||||
}
|
||||
// 组装树:默认顶层为 level-1(parentId=null);传入 parentId 则以该模块为根返回其完整子树
|
||||
List<AppModuleVo> tree;
|
||||
if (query.getParentId() != null) {
|
||||
AppModule root = all.stream()
|
||||
.filter(m -> Objects.equals(m.getId(), query.getParentId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("父模块不存在: " + query.getParentId()));
|
||||
tree = Collections.singletonList(buildNode(root, childrenMap));
|
||||
} else {
|
||||
List<AppModule> roots = childrenMap.getOrDefault(0, Collections.emptyList());
|
||||
tree = new ArrayList<AppModuleVo>();
|
||||
for (AppModule r : roots) {
|
||||
tree.add(buildNode(r, childrenMap));
|
||||
}
|
||||
}
|
||||
// 名称过滤:保留名称匹配或含匹配后代的分支
|
||||
String name = query.getName();
|
||||
if (name != null && !name.trim().isEmpty()) {
|
||||
tree = pruneByKeyword(tree, name.trim());
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归组装节点及其子模块。<br>
|
||||
*/
|
||||
private AppModuleVo buildNode(AppModule node, Map<Integer, List<AppModule>> childrenMap) {
|
||||
AppModuleVo vo = convertToVo(node);
|
||||
List<AppModule> children = childrenMap.get(node.getId());
|
||||
if (children != null) {
|
||||
for (AppModule child : children) {
|
||||
vo.getChildren().add(buildNode(child, childrenMap));
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键字剪枝:仅保留自身或后代命中的分支。<br>
|
||||
*/
|
||||
private List<AppModuleVo> pruneByKeyword(List<AppModuleVo> nodes, String keyword) {
|
||||
List<AppModuleVo> result = new ArrayList<AppModuleVo>();
|
||||
for (AppModuleVo n : nodes) {
|
||||
boolean selfMatch = n.getName() != null && n.getName().contains(keyword);
|
||||
List<AppModuleVo> keptChildren = pruneByKeyword(n.getChildren(), keyword);
|
||||
if (selfMatch || !keptChildren.isEmpty()) {
|
||||
n.setChildren(keptChildren);
|
||||
result.add(n);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算新建模块层级。<br>
|
||||
* 根模块(parentId=null)=1;子模块=父模块层级+1,超过 MAX_DEPTH 抛异常。
|
||||
*/
|
||||
private Integer resolveLevel(Integer appId, Integer parentId) {
|
||||
if (parentId == null) {
|
||||
return 1;
|
||||
}
|
||||
AppModule parent = appModuleRepository.findById(parentId)
|
||||
.orElseThrow(() -> new DException("父模块不存在"));
|
||||
if (!Objects.equals(parent.getAppId(), appId)) {
|
||||
throw new DException("父模块不属于该应用");
|
||||
}
|
||||
int childLevel = parent.getLevel() + 1;
|
||||
if (childLevel > MAX_DEPTH) {
|
||||
throw new DException("模块层级不能超过" + MAX_DEPTH + "层");
|
||||
}
|
||||
return childLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动子树:校验环与层级上限,刷新自身及后代层级后批量保存。<br>
|
||||
* 依赖同一事务内持久化上下文的对象同一性(all 中即 entity)。
|
||||
*/
|
||||
private void moveSubtree(AppModule entity, Integer newParentId) {
|
||||
List<AppModule> all = appModuleRepository.findModules(entity.getAppId(), null, null);
|
||||
Map<Integer, AppModule> idMap = new HashMap<Integer, AppModule>();
|
||||
for (AppModule m : all) {
|
||||
idMap.put(m.getId(), m);
|
||||
}
|
||||
// 环检测:新父节点不能是自身或自身的后代
|
||||
if (newParentId != null) {
|
||||
if (Objects.equals(newParentId, entity.getId())) {
|
||||
throw new DException("不能将模块移动到自身下");
|
||||
}
|
||||
Integer ancestor = newParentId;
|
||||
while (ancestor != null) {
|
||||
AppModule a = idMap.get(ancestor);
|
||||
if (a == null) {
|
||||
throw new DException("父模块不存在");
|
||||
}
|
||||
if (Objects.equals(a.getId(), entity.getId())) {
|
||||
throw new DException("不能将模块移动到其子模块下");
|
||||
}
|
||||
ancestor = a.getParentId();
|
||||
}
|
||||
}
|
||||
int newLevel = (newParentId == null) ? 1 : idMap.get(newParentId).getLevel() + 1;
|
||||
int delta = newLevel - entity.getLevel();
|
||||
int maxRelative = maxDescendantRelativeDepth(entity.getId(), all);
|
||||
if (newLevel + maxRelative > MAX_DEPTH) {
|
||||
throw new DException("移动后模块层级超过" + MAX_DEPTH + "层");
|
||||
}
|
||||
entity.setLevel(newLevel);
|
||||
entity.setParentId(newParentId);
|
||||
shiftDescendantLevels(entity.getId(), delta, all);
|
||||
appModuleRepository.saveAll(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指定节点下后代的最大相对深度(直接子节点=1)。<br>
|
||||
*/
|
||||
private int maxDescendantRelativeDepth(Integer rootId, List<AppModule> all) {
|
||||
int max = 0;
|
||||
for (AppModule m : all) {
|
||||
if (Objects.equals(m.getParentId(), rootId)) {
|
||||
int d = 1 + maxDescendantRelativeDepth(m.getId(), all);
|
||||
if (d > max) {
|
||||
max = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定节点所有后代的 level 增加 delta。<br>
|
||||
*/
|
||||
private void shiftDescendantLevels(Integer rootId, int delta, List<AppModule> all) {
|
||||
for (AppModule m : all) {
|
||||
if (Objects.equals(m.getParentId(), rootId)) {
|
||||
m.setLevel(m.getLevel() + delta);
|
||||
shiftDescendantLevels(m.getId(), delta, all);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AppModuleVo convertToVo(AppModule entity) {
|
||||
AppModuleVo vo = new AppModuleVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.bestsch.utils.DException;
|
||||
import com.yunhai.userbase.entity.AppModule;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.entity.SysAppRole;
|
||||
import com.yunhai.userbase.entity.SysAppRoleModuleRel;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.entity.SysUserAppRoleRel;
|
||||
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
|
||||
import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo;
|
||||
import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
|
||||
import com.yunhai.userbase.model.approle.AppRoleUserBindRequest;
|
||||
import com.yunhai.userbase.model.approle.AppRoleUserVo;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleQuery;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleRequest;
|
||||
import com.yunhai.userbase.model.approle.SysAppRoleVo;
|
||||
import com.yunhai.userbase.model.approle.UserAppRoleBindRequest;
|
||||
import com.yunhai.userbase.repository.AppModuleRepository;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
|
||||
import com.yunhai.userbase.repository.SysAppRoleRepository;
|
||||
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.service.IAppRoleService;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.CriteriaQuery;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import javax.transaction.Transactional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/27
|
||||
* @description 应用业务角色服务实现。<br>
|
||||
* 角色创建:普通角色(allAppFlag=false,appId 必填,同应用下重名校验)/ 全应用角色(allAppFlag=true,appId 为空,全应用角色间重名校验)。<br>
|
||||
* 模块授权:sys_app_role_module_rel 全量覆盖式保存(bulk 先删后插);普通角色仅能授权本应用模块,全应用角色可跨应用。<br>
|
||||
* 用户绑定:sys_user_app_role_rel 支持角色维度 / 用户维度两个方向的全量覆盖保存。<br>
|
||||
*/
|
||||
@Service
|
||||
public class AppRoleService implements IAppRoleService {
|
||||
|
||||
@Autowired
|
||||
private SysAppRoleRepository sysAppRoleRepository;
|
||||
|
||||
@Autowired
|
||||
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
|
||||
|
||||
@Autowired
|
||||
private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
|
||||
|
||||
@Autowired
|
||||
private ApplicationInfoRepository applicationInfoRepository;
|
||||
|
||||
@Autowired
|
||||
private AppModuleRepository appModuleRepository;
|
||||
|
||||
@Autowired
|
||||
private SysUserRepository sysUserRepository;
|
||||
|
||||
// ===================== 角色 CRUD =====================
|
||||
|
||||
@Override
|
||||
public Page<SysAppRoleVo> list(SysAppRoleQuery query, Pageable pageable) {
|
||||
Objects.requireNonNull(query, "角色查询条件不能为空");
|
||||
Objects.requireNonNull(pageable, "分页参数不能为空");
|
||||
Page<SysAppRole> page = sysAppRoleRepository.findAll(select(query), pageable);
|
||||
// 批量取应用名称,避免逐条查询
|
||||
Set<Integer> appIds = new LinkedHashSet<>();
|
||||
for (SysAppRole role : page.getContent()) {
|
||||
if (role.getAppId() != null) {
|
||||
appIds.add(role.getAppId());
|
||||
}
|
||||
}
|
||||
Map<Integer, String> appNameMap = buildAppNameMap(appIds);
|
||||
List<SysAppRoleVo> content = new ArrayList<SysAppRoleVo>();
|
||||
for (SysAppRole role : page.getContent()) {
|
||||
content.add(convertToVo(role, appNameMap));
|
||||
}
|
||||
return new PageImpl<>(content, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(SysAppRoleRequest request) {
|
||||
Objects.requireNonNull(request, "角色新增入参不能为空");
|
||||
boolean allAppFlag = Boolean.TRUE.equals(request.getAllAppFlag());
|
||||
checkRoleShape(allAppFlag, request.getAppId());
|
||||
// 重名校验:全应用角色之间唯一;普通角色同应用下唯一
|
||||
if (allAppFlag) {
|
||||
if (sysAppRoleRepository.existsByNameAndAllAppFlagTrue(request.getName())) {
|
||||
throw new DException("全应用角色名称已存在");
|
||||
}
|
||||
} else {
|
||||
if (sysAppRoleRepository.existsByNameAndAppId(request.getName(), request.getAppId())) {
|
||||
throw new DException("该应用下角色名称已存在");
|
||||
}
|
||||
}
|
||||
SysAppRole entity = new SysAppRole();
|
||||
entity.setName(request.getName());
|
||||
entity.setAllAppFlag(allAppFlag);
|
||||
// 全应用角色强制 appId 为空,防止脏数据
|
||||
entity.setAppId(allAppFlag ? null : request.getAppId());
|
||||
entity.setRemark(request.getRemark());
|
||||
entity.setDisabled(Boolean.TRUE.equals(request.getDisabled()));
|
||||
sysAppRoleRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void edit(Integer id, SysAppRoleRequest request) {
|
||||
Objects.requireNonNull(id, "角色ID不能为空");
|
||||
Objects.requireNonNull(request, "角色编辑入参不能为空");
|
||||
SysAppRole entity = sysAppRoleRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("角色不存在: " + id));
|
||||
// 角色类型与所属应用不允许修改:修改会导致已授权模块越界(普通角色授权范围与 appId 绑定)
|
||||
if (!Objects.equals(entity.getAllAppFlag(), Boolean.TRUE.equals(request.getAllAppFlag()))
|
||||
|| !Objects.equals(entity.getAppId(), request.getAppId())) {
|
||||
throw new DException("角色类型与所属应用不允许修改,请删除后重建");
|
||||
}
|
||||
// 重名校验(排除自身)
|
||||
if (Boolean.TRUE.equals(entity.getAllAppFlag())) {
|
||||
if (sysAppRoleRepository.existsByNameAndAllAppFlagTrueAndIdNot(request.getName(), id)) {
|
||||
throw new DException("全应用角色名称已存在");
|
||||
}
|
||||
} else {
|
||||
if (sysAppRoleRepository.existsByNameAndAppIdAndIdNot(request.getName(), entity.getAppId(), id)) {
|
||||
throw new DException("该应用下角色名称已存在");
|
||||
}
|
||||
}
|
||||
entity.setName(request.getName());
|
||||
entity.setRemark(request.getRemark());
|
||||
if (request.getDisabled() != null) {
|
||||
entity.setDisabled(request.getDisabled());
|
||||
}
|
||||
sysAppRoleRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Integer id) {
|
||||
Objects.requireNonNull(id, "角色ID不能为空");
|
||||
if (!sysAppRoleRepository.existsById(id)) {
|
||||
throw new RuntimeException("角色不存在: " + id);
|
||||
}
|
||||
// 逻辑外键级联:先清模块授权与用户绑定,再删角色本体
|
||||
sysAppRoleModuleRelRepository.deleteByRoleIdBulk(id);
|
||||
sysUserAppRoleRelRepository.deleteByRoleIdBulk(id);
|
||||
sysAppRoleRepository.deleteById(id);
|
||||
}
|
||||
|
||||
// ===================== 角色模块授权 =====================
|
||||
|
||||
@Override
|
||||
public List<AppRoleAuthzAppVo> findRoleModuleTree(Integer roleId) {
|
||||
Objects.requireNonNull(roleId, "角色ID不能为空");
|
||||
SysAppRole role = sysAppRoleRepository.findById(roleId)
|
||||
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
|
||||
// 授权范围:普通角色仅本应用;全应用角色为系统全部应用
|
||||
List<ApplicationInfo> apps;
|
||||
if (Boolean.TRUE.equals(role.getAllAppFlag())) {
|
||||
apps = applicationInfoRepository.findAll();
|
||||
} else {
|
||||
ApplicationInfo app = applicationInfoRepository.findById(role.getAppId()).orElse(null);
|
||||
if (app == null) {
|
||||
throw new DException("角色所属应用不存在: " + role.getAppId());
|
||||
}
|
||||
apps = Collections.singletonList(app);
|
||||
}
|
||||
apps.sort(appOrder());
|
||||
List<Integer> appIds = new ArrayList<>();
|
||||
for (ApplicationInfo app : apps) {
|
||||
appIds.add(app.getId());
|
||||
}
|
||||
// 应用下全部模块,按 appId 分桶
|
||||
Map<Integer, List<AppModule>> appModuleMap = new HashMap<>();
|
||||
for (AppModule m : appModuleRepository.findByAppIdIn(appIds)) {
|
||||
appModuleMap.computeIfAbsent(m.getAppId(), k -> new ArrayList<>()).add(m);
|
||||
}
|
||||
// 已授权模块集合,供 checked 回显
|
||||
Set<Integer> checkedModuleIds = new HashSet<>();
|
||||
for (SysAppRoleModuleRel rel : sysAppRoleModuleRelRepository.findByRoleId(roleId)) {
|
||||
checkedModuleIds.add(rel.getModuleId());
|
||||
}
|
||||
List<AppRoleAuthzAppVo> result = new ArrayList<>();
|
||||
for (ApplicationInfo app : apps) {
|
||||
AppRoleAuthzAppVo appVo = new AppRoleAuthzAppVo();
|
||||
BeanUtils.copyProperties(app, appVo);
|
||||
List<AppModule> modules = appModuleMap.get(app.getId());
|
||||
if (modules != null) {
|
||||
appVo.setModules(buildModuleTree(modules, checkedModuleIds));
|
||||
}
|
||||
result.add(appVo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveRoleModules(AppRoleModuleAssignRequest request) {
|
||||
Objects.requireNonNull(request, "角色模块授权入参不能为空");
|
||||
Integer roleId = request.getRoleId();
|
||||
SysAppRole role = sysAppRoleRepository.findById(roleId)
|
||||
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
|
||||
// 去重并校验模块均存在
|
||||
List<Integer> moduleIds = new ArrayList<>(new LinkedHashSet<>(
|
||||
request.getModuleIds() == null ? Collections.emptyList() : request.getModuleIds()));
|
||||
if (!moduleIds.isEmpty()) {
|
||||
Map<Integer, AppModule> moduleMap = new HashMap<>();
|
||||
for (AppModule m : appModuleRepository.findAllById(moduleIds)) {
|
||||
moduleMap.put(m.getId(), m);
|
||||
}
|
||||
for (Integer moduleId : moduleIds) {
|
||||
AppModule m = moduleMap.get(moduleId);
|
||||
if (m == null) {
|
||||
throw new DException("模块不存在: " + moduleId);
|
||||
}
|
||||
// 越权防护:普通角色只能授权其所属应用下的模块;全应用角色可跨应用
|
||||
if (!Boolean.TRUE.equals(role.getAllAppFlag()) && !Objects.equals(m.getAppId(), role.getAppId())) {
|
||||
throw new DException("模块 " + moduleId + " 不属于该角色所属应用,普通角色不能跨应用授权");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 全量覆盖:bulk 先删旧记录(立即发 SQL,避免 flush 顺序触发唯一键冲突),再批量插入
|
||||
sysAppRoleModuleRelRepository.deleteByRoleIdBulk(roleId);
|
||||
List<SysAppRoleModuleRel> batch = new ArrayList<>();
|
||||
for (Integer moduleId : moduleIds) {
|
||||
SysAppRoleModuleRel rel = new SysAppRoleModuleRel();
|
||||
rel.setRoleId(roleId);
|
||||
rel.setModuleId(moduleId);
|
||||
batch.add(rel);
|
||||
}
|
||||
if (!batch.isEmpty()) {
|
||||
sysAppRoleModuleRelRepository.saveAll(batch);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 用户角色绑定 =====================
|
||||
|
||||
@Override
|
||||
public List<AppRoleUserVo> findRoleUsers(Integer roleId) {
|
||||
Objects.requireNonNull(roleId, "角色ID不能为空");
|
||||
if (!sysAppRoleRepository.existsById(roleId)) {
|
||||
throw new DException("角色不存在: " + roleId);
|
||||
}
|
||||
List<SysUserAppRoleRel> rels = sysUserAppRoleRelRepository.findByRoleId(roleId);
|
||||
if (rels.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Set<Integer> userIds = new LinkedHashSet<>();
|
||||
for (SysUserAppRoleRel rel : rels) {
|
||||
userIds.add(rel.getUserId());
|
||||
}
|
||||
Map<Integer, SysUser> userMap = new HashMap<>();
|
||||
for (SysUser user : sysUserRepository.findAllById(userIds)) {
|
||||
userMap.put(user.getId(), user);
|
||||
}
|
||||
List<AppRoleUserVo> result = new ArrayList<>();
|
||||
for (SysUserAppRoleRel rel : rels) {
|
||||
SysUser user = userMap.get(rel.getUserId());
|
||||
if (user == null) {
|
||||
continue;
|
||||
}
|
||||
AppRoleUserVo vo = new AppRoleUserVo();
|
||||
vo.setId(user.getId());
|
||||
vo.setAccount(user.getAccount());
|
||||
vo.setRealName(user.getRealName());
|
||||
result.add(vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveRoleUsers(AppRoleUserBindRequest request) {
|
||||
Objects.requireNonNull(request, "角色绑定用户入参不能为空");
|
||||
Integer roleId = request.getRoleId();
|
||||
if (!sysAppRoleRepository.existsById(roleId)) {
|
||||
throw new DException("角色不存在: " + roleId);
|
||||
}
|
||||
// 去重并校验用户均存在
|
||||
List<Integer> userIds = new ArrayList<>(new LinkedHashSet<>(
|
||||
request.getUserIds() == null ? Collections.emptyList() : request.getUserIds()));
|
||||
for (Integer userId : userIds) {
|
||||
if (!sysUserRepository.existsById(userId)) {
|
||||
throw new DException("用户不存在: " + userId);
|
||||
}
|
||||
}
|
||||
// 全量覆盖:bulk 先删旧绑定,再批量插入
|
||||
sysUserAppRoleRelRepository.deleteByRoleIdBulk(roleId);
|
||||
List<SysUserAppRoleRel> batch = new ArrayList<>();
|
||||
for (Integer userId : userIds) {
|
||||
SysUserAppRoleRel rel = new SysUserAppRoleRel();
|
||||
rel.setUserId(userId);
|
||||
rel.setRoleId(roleId);
|
||||
batch.add(rel);
|
||||
}
|
||||
if (!batch.isEmpty()) {
|
||||
sysUserAppRoleRelRepository.saveAll(batch);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveUserRoles(UserAppRoleBindRequest request) {
|
||||
Objects.requireNonNull(request, "用户绑定角色入参不能为空");
|
||||
Integer userId = request.getUserId();
|
||||
if (!sysUserRepository.existsById(userId)) {
|
||||
throw new DException("用户不存在: " + userId);
|
||||
}
|
||||
// 去重并校验角色均存在
|
||||
List<Integer> roleIds = new ArrayList<>(new LinkedHashSet<>(
|
||||
request.getRoleIds() == null ? Collections.emptyList() : request.getRoleIds()));
|
||||
for (Integer roleId : roleIds) {
|
||||
if (!sysAppRoleRepository.existsById(roleId)) {
|
||||
throw new DException("角色不存在: " + roleId);
|
||||
}
|
||||
}
|
||||
// 全量覆盖:bulk 先删旧绑定,再批量插入
|
||||
sysUserAppRoleRelRepository.deleteByUserIdBulk(userId);
|
||||
List<SysUserAppRoleRel> batch = new ArrayList<>();
|
||||
for (Integer roleId : roleIds) {
|
||||
SysUserAppRoleRel rel = new SysUserAppRoleRel();
|
||||
rel.setUserId(userId);
|
||||
rel.setRoleId(roleId);
|
||||
batch.add(rel);
|
||||
}
|
||||
if (!batch.isEmpty()) {
|
||||
sysUserAppRoleRelRepository.saveAll(batch);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void removeRoleUser(Integer roleId, Integer userId) {
|
||||
Objects.requireNonNull(roleId, "角色ID不能为空");
|
||||
Objects.requireNonNull(userId, "用户ID不能为空");
|
||||
if (!sysAppRoleRepository.existsById(roleId)) {
|
||||
throw new DException("角色不存在: " + roleId);
|
||||
}
|
||||
if (!sysUserRepository.existsById(userId)) {
|
||||
throw new DException("用户不存在: " + userId);
|
||||
}
|
||||
sysUserAppRoleRelRepository.deleteByRoleIdAndUserId(roleId, userId);
|
||||
}
|
||||
|
||||
// ===================== 私有工具 =====================
|
||||
|
||||
/**
|
||||
* 校验角色形态:普通角色 appId 必填且应用存在;全应用角色 appId 必须为空。<br>
|
||||
*/
|
||||
private void checkRoleShape(boolean allAppFlag, Integer appId) {
|
||||
if (allAppFlag) {
|
||||
if (appId != null) {
|
||||
throw new DException("全应用角色不能指定所属应用");
|
||||
}
|
||||
} else {
|
||||
if (appId == null) {
|
||||
throw new DException("普通角色必须指定所属应用");
|
||||
}
|
||||
if (!applicationInfoRepository.existsById(appId)) {
|
||||
throw new DException("所属应用不存在: " + appId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建角色列表的动态查询条件(Tier 1 Specification)。<br>
|
||||
* name 模糊匹配;appId/allAppFlag/disabled 精确匹配。<br>
|
||||
*/
|
||||
private Specification<SysAppRole> select(SysAppRoleQuery query) {
|
||||
Specification<SysAppRole> specification = new Specification<SysAppRole>() {
|
||||
@Override
|
||||
public Predicate toPredicate(Root<SysAppRole> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
|
||||
List<Predicate> predicates = new ArrayList<Predicate>();
|
||||
if (query != null) {
|
||||
if (StringUtils.isNotEmpty(query.getName())) {
|
||||
predicates.add(cb.like(root.get("name"), "%" + query.getName() + "%"));
|
||||
}
|
||||
if (query.getAppId() != null) {
|
||||
predicates.add(cb.equal(root.get("appId"), query.getAppId()));
|
||||
}
|
||||
if (query.getAllAppFlag() != null) {
|
||||
predicates.add(cb.equal(root.get("allAppFlag"), query.getAllAppFlag()));
|
||||
}
|
||||
if (query.getDisabled() != null) {
|
||||
predicates.add(cb.equal(root.get("disabled"), query.getDisabled()));
|
||||
}
|
||||
}
|
||||
return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction();
|
||||
}
|
||||
};
|
||||
return specification;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询应用名称映射(角色列表回填 appName 用)。<br>
|
||||
*/
|
||||
private Map<Integer, String> buildAppNameMap(Set<Integer> appIds) {
|
||||
Map<Integer, String> map = new HashMap<>();
|
||||
if (appIds.isEmpty()) {
|
||||
return map;
|
||||
}
|
||||
for (ApplicationInfo app : applicationInfoRepository.findAllById(appIds)) {
|
||||
map.put(app.getId(), app.getName());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private SysAppRoleVo convertToVo(SysAppRole entity, Map<Integer, String> appNameMap) {
|
||||
SysAppRoleVo vo = new SysAppRoleVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
if (entity.getAppId() != null) {
|
||||
vo.setAppName(appNameMap.get(entity.getAppId()));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装单个应用下的模块树(沿用 GroupAuthzService 的组装方式:childrenMap + sortNo 排序),并回显 checked。<br>
|
||||
*/
|
||||
private List<AppRoleAuthzModuleVo> buildModuleTree(List<AppModule> modules, Set<Integer> checkedModuleIds) {
|
||||
// parentId -> 子节点列表(null 视为 0)
|
||||
Map<Integer, List<AppModule>> childrenMap = new HashMap<>();
|
||||
for (AppModule m : modules) {
|
||||
Integer key = (m.getParentId() == null) ? 0 : m.getParentId();
|
||||
childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(m);
|
||||
}
|
||||
// 每个兄弟组按 sortNo(空视为999) 升序,再按 id 升序
|
||||
Comparator<AppModule> siblingOrder = (a, b) -> {
|
||||
int sa = a.getSortNo() == null ? 999 : a.getSortNo();
|
||||
int sb = b.getSortNo() == null ? 999 : b.getSortNo();
|
||||
if (sa != sb) {
|
||||
return Integer.compare(sa, sb);
|
||||
}
|
||||
return Integer.compare(a.getId(), b.getId());
|
||||
};
|
||||
for (List<AppModule> siblings : childrenMap.values()) {
|
||||
siblings.sort(siblingOrder);
|
||||
}
|
||||
List<AppModule> roots = childrenMap.getOrDefault(0, Collections.emptyList());
|
||||
List<AppRoleAuthzModuleVo> tree = new ArrayList<>();
|
||||
for (AppModule root : roots) {
|
||||
tree.add(buildModuleNode(root, childrenMap, checkedModuleIds));
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归组装模块节点,并按角色授权记录回显 checked。<br>
|
||||
*/
|
||||
private AppRoleAuthzModuleVo buildModuleNode(AppModule node,
|
||||
Map<Integer, List<AppModule>> childrenMap,
|
||||
Set<Integer> checkedModuleIds) {
|
||||
AppRoleAuthzModuleVo vo = new AppRoleAuthzModuleVo();
|
||||
BeanUtils.copyProperties(node, vo);
|
||||
if (checkedModuleIds.contains(node.getId())) {
|
||||
vo.setChecked(Boolean.TRUE);
|
||||
}
|
||||
List<AppModule> children = childrenMap.get(node.getId());
|
||||
if (children != null) {
|
||||
for (AppModule child : children) {
|
||||
vo.getChildren().add(buildModuleNode(child, childrenMap, checkedModuleIds));
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 应用排序:sortNo 升序(空视为999),再按 id 升序(与应用列表展示一致)。 */
|
||||
private Comparator<ApplicationInfo> appOrder() {
|
||||
return (a, b) -> {
|
||||
int sa = a.getSortNo() == null ? 999 : a.getSortNo();
|
||||
int sb = b.getSortNo() == null ? 999 : b.getSortNo();
|
||||
if (sa != sb) {
|
||||
return Integer.compare(sa, sb);
|
||||
}
|
||||
return Integer.compare(a.getId(), b.getId());
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.userbase.entity.AppModule;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.entity.SysAppRole;
|
||||
import com.yunhai.userbase.repository.AppModuleRepository;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
|
||||
import com.yunhai.userbase.repository.SysAppRoleRepository;
|
||||
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
|
||||
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
|
||||
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
|
||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||
import com.yunhai.userbase.service.SignKeyCacheService;
|
||||
import com.bestsch.utils.DException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.CachePut;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.CriteriaQuery;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import javax.transaction.Transactional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
@Service
|
||||
public class ApplicationInfoService implements IApplicationInfoService {
|
||||
|
||||
@Autowired
|
||||
private ApplicationInfoRepository applicationInfoRepository;
|
||||
|
||||
@Autowired
|
||||
private AppModuleRepository appModuleRepository;
|
||||
|
||||
@Autowired
|
||||
private SysGroupAppRelRepository sysGroupAppRelRepository;
|
||||
|
||||
@Autowired
|
||||
private SysGroupModulePermRepository sysGroupModulePermRepository;
|
||||
|
||||
@Autowired
|
||||
private SysAppRoleRepository sysAppRoleRepository;
|
||||
|
||||
@Autowired
|
||||
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
|
||||
|
||||
@Autowired
|
||||
private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
|
||||
|
||||
@Autowired
|
||||
private SignKeyCacheService signKeyCacheService;
|
||||
|
||||
/**
|
||||
* 查询缓存:sync = true 防击穿;底层优先 Redis,Redis 故障时自动降级 Caffeine。
|
||||
*/
|
||||
@Override
|
||||
@Cacheable(value = "appInfo", key = "#id", sync = true)
|
||||
public ApplicationInfo getById(Integer id) {
|
||||
return applicationInfoRepository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新缓存:方法返回即写入缓存(与 @Cacheable 同一 cacheName/key,保证一致)。
|
||||
*/
|
||||
@Override
|
||||
@CachePut(value = "appInfo", key = "#info.id")
|
||||
public ApplicationInfo save(ApplicationInfo info) {
|
||||
return applicationInfoRepository.save(info);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存:删除数据时同步清除对应缓存项。
|
||||
*/
|
||||
@Override
|
||||
@CacheEvict(value = "appInfo", key = "#id")
|
||||
public void deleteById(Integer id) {
|
||||
applicationInfoRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(ApplicationInfoRequest request) {
|
||||
Objects.requireNonNull(request, "应用新增入参不能为空");
|
||||
// 重名校验:新增应用名称不允许重复
|
||||
if (applicationInfoRepository.existsByName(request.getName())) {
|
||||
throw new DException("应用名称已存在");
|
||||
}
|
||||
ApplicationInfo entity = new ApplicationInfo();
|
||||
BeanUtils.copyProperties(request, entity);
|
||||
entity.setId(null);
|
||||
// appKey:整型,从 100000 起,取数据库现有最大 appKey + 1
|
||||
Integer maxAppKey = applicationInfoRepository.findMaxAppKey();
|
||||
Integer appKey = (maxAppKey == null) ? 100000 : maxAppKey + 1;
|
||||
entity.setAppKey(appKey);
|
||||
// appSecret:32 位 UUID(去除连字符)
|
||||
entity.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
|
||||
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||
signKeyCacheService.syncOne(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void edit(Integer id, ApplicationInfoRequest request) {
|
||||
Objects.requireNonNull(id, "应用ID不能为空");
|
||||
Objects.requireNonNull(request, "应用编辑入参不能为空");
|
||||
// 重名校验:编辑时排除自身
|
||||
if (applicationInfoRepository.existsByNameAndIdNot(request.getName(), id)) {
|
||||
throw new DException("应用名称已存在");
|
||||
}
|
||||
ApplicationInfo entity = applicationInfoRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
|
||||
BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret");
|
||||
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||
signKeyCacheService.syncOne(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理),并清除 appInfo 缓存
|
||||
@CacheEvict(value = "appInfo", key = "#id")
|
||||
public void delete(Integer id) {
|
||||
Objects.requireNonNull(id, "应用ID不能为空");
|
||||
ApplicationInfo existing = applicationInfoRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
|
||||
Integer appKey = existing.getAppKey();
|
||||
// AI-FIX: 删除应用前级联清理组权限关联(逻辑外键无物理 FK):先清该应用模块的组权限记录,再清组开通记录
|
||||
List<AppModule> modules = appModuleRepository.findByAppIdIn(java.util.Collections.singletonList(id));
|
||||
if (!modules.isEmpty()) {
|
||||
List<Integer> moduleIds = new ArrayList<Integer>();
|
||||
for (AppModule m : modules) {
|
||||
moduleIds.add(m.getId());
|
||||
}
|
||||
sysGroupModulePermRepository.deleteByModuleIdInBulk(moduleIds);
|
||||
}
|
||||
sysGroupAppRelRepository.deleteByAppIdBulk(id);
|
||||
// AI-FIX(角色授权): 删除应用前级联清理该应用下的业务角色及其授权关系(逻辑外键无物理 FK)
|
||||
// 仅清理「普通角色」(appId=本应用);全应用角色(appId=null)不受应用删除影响,保留不动
|
||||
List<SysAppRole> appRoles = sysAppRoleRepository.findByAppId(id);
|
||||
if (!appRoles.isEmpty()) {
|
||||
List<Integer> roleIds = new ArrayList<Integer>();
|
||||
for (SysAppRole r : appRoles) {
|
||||
roleIds.add(r.getId());
|
||||
}
|
||||
sysAppRoleModuleRelRepository.deleteByRoleIdInBulk(roleIds);
|
||||
sysUserAppRoleRelRepository.deleteByRoleIdInBulk(roleIds);
|
||||
sysAppRoleRepository.deleteAll(appRoles);
|
||||
}
|
||||
// 先删模块(整棵子树随 appId 一并清除),再删应用,避免残留孤儿数据
|
||||
appModuleRepository.deleteByAppId(id);
|
||||
applicationInfoRepository.deleteById(id);
|
||||
signKeyCacheService.removeOne(appKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ApplicationInfoVo> list(ApplicationInfoQuery query, Pageable pageable) {
|
||||
Objects.requireNonNull(query, "应用查询条件不能为空");
|
||||
Objects.requireNonNull(pageable, "分页参数不能为空");
|
||||
Page<ApplicationInfo> page = applicationInfoRepository.findAll(select(query), pageable);
|
||||
List<ApplicationInfoVo> content = new ArrayList<ApplicationInfoVo>();
|
||||
for (ApplicationInfo entity : page.getContent()) {
|
||||
content.add(convertToVo(entity));
|
||||
}
|
||||
return new PageImpl<>(content, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建应用列表的动态查询条件。<br>
|
||||
* name 关键词:按应用名称模糊匹配,或按 appKey(appId)精确匹配;categoryId:按应用类别精确匹配。<br>
|
||||
*
|
||||
* @param query 前端传入的查询条件
|
||||
* @return 动态查询 Specification
|
||||
*/
|
||||
private Specification select(ApplicationInfoQuery query) {
|
||||
Specification<ApplicationInfo> specification = new Specification<ApplicationInfo>() {
|
||||
@Override
|
||||
public Predicate toPredicate(Root<ApplicationInfo> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
|
||||
List<Predicate> predicates = new ArrayList<Predicate>();
|
||||
if (query != null) {
|
||||
if (StringUtils.isNotEmpty(query.getName())) {
|
||||
String keyword = query.getName();
|
||||
Predicate byName = cb.like(root.get("name"), "%" + keyword + "%");
|
||||
// 关键词为纯数字时,同时按 appKey(appId)精确匹配
|
||||
if (keyword.matches("\\d+")) {
|
||||
predicates.add(cb.or(byName, cb.equal(root.get("appKey"), Integer.parseInt(keyword))));
|
||||
} else {
|
||||
predicates.add(byName);
|
||||
}
|
||||
}
|
||||
if (query.getCategoryId() != null) {
|
||||
predicates.add(cb.equal(root.get("categoryId"), query.getCategoryId()));
|
||||
}
|
||||
}
|
||||
return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction();
|
||||
}
|
||||
};
|
||||
return specification;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
// AI-FIX: 重置密钥后必须清除 appInfo 缓存,否则控制器随后 getById 命中旧缓存、返回旧密钥
|
||||
@CacheEvict(value = "appInfo", key = "#id")
|
||||
public void resetAppSecret(Integer id) {
|
||||
Objects.requireNonNull(id, "应用ID不能为空");
|
||||
ApplicationInfo entity = applicationInfoRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
|
||||
entity.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
|
||||
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||
signKeyCacheService.syncOne(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void sort(List<Integer> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
throw new DException("排序应用ID列表不能为空");
|
||||
}
|
||||
Integer categoryId = null;
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
Integer id = ids.get(i);
|
||||
ApplicationInfo entity = applicationInfoRepository.findById(id)
|
||||
.orElseThrow(() -> new DException("应用不存在: " + id));
|
||||
// 额外校验:排序必须限定在同一个应用类型内,避免跨类型串号导致排序错乱
|
||||
if (categoryId == null) {
|
||||
categoryId = entity.getCategoryId();
|
||||
} else if (!Objects.equals(categoryId, entity.getCategoryId())) {
|
||||
throw new DException("排序应用不属于同一应用类型,请检查入参");
|
||||
}
|
||||
applicationInfoRepository.updateSortNoById(id, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private ApplicationInfoVo convertToVo(ApplicationInfo entity) {
|
||||
ApplicationInfoVo vo = new ApplicationInfoVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApplicationInfoSortVo> findListByCategoryId(Integer categoryId) {
|
||||
return applicationInfoRepository.findSortVoByCategoryId(categoryId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.bestsch.utils.DException;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||
import com.yunhai.userbase.enums.IEnum;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
|
||||
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
|
||||
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.transaction.Transactional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/20 16:00
|
||||
* @description 系统组服务实现:组织架构树(仅 parentId 逻辑外键,不使用 path;后续可用 MySQL8 WITH 优化递归查询)。<br>
|
||||
*/
|
||||
@Service
|
||||
public class SysGroupService implements ISysGroupService {
|
||||
|
||||
/** 虚拟父节点:根节点(教育云中心)的 parentId,表示「无上级」。 */
|
||||
private static final int ROOT_PARENT_ID = 0;
|
||||
|
||||
@Autowired
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
|
||||
@Autowired
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
|
||||
@Autowired
|
||||
private SysGroupAppRelRepository sysGroupAppRelRepository;
|
||||
|
||||
@Autowired
|
||||
private SysGroupModulePermRepository sysGroupModulePermRepository;
|
||||
|
||||
// ===================== 基础增删改(含根节点保护) =====================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(SysGroupRequest request) {
|
||||
Integer parentId = request.getParentId();
|
||||
// 根节点由系统自动维护,不允许手动新增一个 pid=0 的组
|
||||
if (parentId == null || parentId == ROOT_PARENT_ID) {
|
||||
throw new DException("根节点由系统自动创建,不可手动添加");
|
||||
}
|
||||
if (!sysGroupRepository.existsById(parentId)) {
|
||||
throw new DException("父组不存在");
|
||||
}
|
||||
GroupTypeEnum typeEnum = resolveType(request.getType());
|
||||
|
||||
SysGroup entity = new SysGroup();
|
||||
entity.setName(request.getName());
|
||||
entity.setParentId(parentId);
|
||||
entity.setType(typeEnum);
|
||||
entity.setSort(nextSort(parentId));
|
||||
sysGroupRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void edit(Integer id, SysGroupRequest request) {
|
||||
SysGroup entity = sysGroupRepository.findById(id)
|
||||
.orElseThrow(() -> new DException("系统组不存在"));
|
||||
// 根节点不可编辑
|
||||
if (isRoot(entity)) {
|
||||
throw new DException("根节点不可编辑");
|
||||
}
|
||||
entity.setName(request.getName());
|
||||
if (request.getType() != null) {
|
||||
entity.setType(resolveType(request.getType()));
|
||||
}
|
||||
if (request.getSort() != null) {
|
||||
entity.setSort(request.getSort());
|
||||
}
|
||||
// 注:移动请走 move 接口,edit 不改 parentId,避免与移动逻辑混淆
|
||||
sysGroupRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Integer id) {
|
||||
SysGroup entity = sysGroupRepository.findById(id)
|
||||
.orElseThrow(() -> new DException("系统组不存在"));
|
||||
// 根节点不可删除
|
||||
if (isRoot(entity)) {
|
||||
throw new DException("根节点不可删除");
|
||||
}
|
||||
if (sysGroupRepository.countByParentId(id) > 0) {
|
||||
throw new DException("该组下存在子组,无法删除");
|
||||
}
|
||||
if (sysUserGroupRelRepository.countByGroupId(id) > 0) {
|
||||
throw new DException("有用户,不能删除组");
|
||||
}
|
||||
// AI-FIX: 删除组时级联清理其应用开通记录与模块权限记录(逻辑外键无物理 FK,避免残留孤儿数据)
|
||||
sysGroupAppRelRepository.deleteByGroupIdBulk(id);
|
||||
sysGroupModulePermRepository.deleteByGroupIdBulk(id);
|
||||
sysGroupRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SysGroupVo> list(SysGroupQuery query) {
|
||||
List<SysGroup> list;
|
||||
if (StringUtils.hasText(query.getName())) {
|
||||
list = sysGroupRepository.findByNameContainingOrderBySortAsc(query.getName());
|
||||
} else if (query.getParentId() != null) {
|
||||
list = sysGroupRepository.findByParentIdOrderBySortAsc(query.getParentId());
|
||||
} else {
|
||||
list = sysGroupRepository.findAllByOrderBySortAsc();
|
||||
}
|
||||
return list.stream().map(this::convertToVo).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ===================== 组织架构树 =====================
|
||||
|
||||
@Override
|
||||
public List<SysGroupVo> getTree() {
|
||||
// 根节点由 SysGroupRootInitializer 在启动时幂等创建,此处直接读取组装
|
||||
List<SysGroup> all = sysGroupRepository.findAll();
|
||||
if (all.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
|
||||
// 从根(pid=0)开始组装树
|
||||
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
|
||||
return roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SysGroupVo> getSchoolTree() {
|
||||
List<SysGroup> all = sysGroupRepository.findAll();
|
||||
if (all.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
|
||||
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
|
||||
List<SysGroupVo> result = new ArrayList<>();
|
||||
for (SysGroup root : roots) {
|
||||
SysGroupVo vo = buildSchoolNode(root, childrenMap);
|
||||
if (vo == null) {
|
||||
// 根节点始终保留(即使其下没有任何学校组),children 为空
|
||||
vo = convertToVo(root);
|
||||
}
|
||||
result.add(vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归组装「学校组」剪枝节点。<br>
|
||||
* 学校组:直接作为叶子返回(不再向下递归,其下所有组不显示);<br>
|
||||
* 非学校组:递归剪枝后仍有子节点才保留,否则返回 null(整枝剪掉)。<br>
|
||||
*/
|
||||
private SysGroupVo buildSchoolNode(SysGroup node, Map<Integer, List<SysGroup>> childrenMap) {
|
||||
if (node.getType() == GroupTypeEnum.SCHOOL_GROUP) {
|
||||
return convertToVo(node);
|
||||
}
|
||||
List<SysGroup> children = childrenMap.get(node.getId());
|
||||
if (children == null) {
|
||||
return null;
|
||||
}
|
||||
SysGroupVo vo = convertToVo(node);
|
||||
for (SysGroup child : children) {
|
||||
SysGroupVo childVo = buildSchoolNode(child, childrenMap);
|
||||
if (childVo != null) {
|
||||
vo.getChildren().add(childVo);
|
||||
}
|
||||
}
|
||||
return vo.getChildren().isEmpty() ? null : vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 parentId -> 直接子节点列表 的映射(null 视为 0),同层级按 sort 升序、再按 id 升序。<br>
|
||||
*/
|
||||
private Map<Integer, List<SysGroup>> buildSortedChildrenMap(List<SysGroup> all) {
|
||||
Map<Integer, List<SysGroup>> childrenMap = new HashMap<>();
|
||||
for (SysGroup g : all) {
|
||||
Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
|
||||
childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g);
|
||||
}
|
||||
Comparator<SysGroup> siblingOrder = (a, b) -> {
|
||||
int sa = a.getSort() == null ? 999 : a.getSort();
|
||||
int sb = b.getSort() == null ? 999 : b.getSort();
|
||||
if (sa != sb) {
|
||||
return Integer.compare(sa, sb);
|
||||
}
|
||||
return Integer.compare(a.getId(), b.getId());
|
||||
};
|
||||
for (List<SysGroup> siblings : childrenMap.values()) {
|
||||
siblings.sort(siblingOrder);
|
||||
}
|
||||
return childrenMap;
|
||||
}
|
||||
|
||||
private SysGroupVo buildNode(SysGroup node, Map<Integer, List<SysGroup>> childrenMap) {
|
||||
SysGroupVo vo = convertToVo(node);
|
||||
List<SysGroup> children = childrenMap.get(node.getId());
|
||||
if (children != null) {
|
||||
for (SysGroup child : children) {
|
||||
vo.getChildren().add(buildNode(child, childrenMap));
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ===================== 批量新增 =====================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void batchAdd(SysGroupBatchRequest request) {
|
||||
Integer pid = request.getPid();
|
||||
if (pid == null || !sysGroupRepository.existsById(pid)) {
|
||||
throw new DException("父组不存在");
|
||||
}
|
||||
GroupTypeEnum typeEnum = resolveType(request.getType());
|
||||
List<SysGroupBatchRequest.BatchItem> items = request.getItems();
|
||||
if (items == null || items.isEmpty()) {
|
||||
throw new DException("批量新增的子组列表不能为空");
|
||||
}
|
||||
// 追加到当前父组子节点之后
|
||||
int base = nextSort(pid);
|
||||
|
||||
List<SysGroup> batch = new ArrayList<>();
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
String name = items.get(i).getName();
|
||||
if (name == null || name.trim().isEmpty()) {
|
||||
throw new DException("第 " + (i + 1) + " 个子组名称为空");
|
||||
}
|
||||
SysGroup g = new SysGroup();
|
||||
g.setName(name.trim());
|
||||
g.setParentId(pid);
|
||||
g.setType(typeEnum);
|
||||
g.setSort(base + i);
|
||||
batch.add(g);
|
||||
}
|
||||
sysGroupRepository.saveAll(batch);
|
||||
}
|
||||
|
||||
// ===================== 子组排序 =====================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void sortChildren(SysGroupSortRequest request) {
|
||||
Integer pid = request.getPid();
|
||||
List<Integer> ids = request.getIds();
|
||||
if (pid == null || !sysGroupRepository.existsById(pid)) {
|
||||
throw new DException("父组不存在");
|
||||
}
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
throw new DException("子节点ID列表不能为空");
|
||||
}
|
||||
if (new HashSet<>(ids).size() != ids.size()) {
|
||||
throw new DException("子节点ID列表存在重复");
|
||||
}
|
||||
// 取出 pid 的直接子节点,校验 ids 全是其直接子节点
|
||||
List<SysGroup> children = sysGroupRepository.findByParentIdOrderBySortAsc(pid);
|
||||
Set<Integer> childIdSet = children.stream().map(SysGroup::getId).collect(Collectors.toSet());
|
||||
for (Integer id : ids) {
|
||||
if (!childIdSet.contains(id)) {
|
||||
throw new DException("ids 中存在不属于该父组的直接子节点");
|
||||
}
|
||||
}
|
||||
// 按传入顺序落库排序(1 起)
|
||||
Map<Integer, SysGroup> childMap = children.stream()
|
||||
.collect(Collectors.toMap(SysGroup::getId, c -> c, (a, b) -> a));
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
childMap.get(ids.get(i)).setSort(i + 1);
|
||||
}
|
||||
sysGroupRepository.saveAll(children);
|
||||
}
|
||||
|
||||
// ===================== 移动组 =====================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void move(SysGroupMoveRequest request) {
|
||||
Integer moveId = request.getMoveId();
|
||||
Integer pid = request.getPid();
|
||||
if (moveId == null || !sysGroupRepository.existsById(moveId)) {
|
||||
throw new DException("待移动节点不存在");
|
||||
}
|
||||
if (pid == null || pid == ROOT_PARENT_ID || !sysGroupRepository.existsById(pid)) {
|
||||
throw new DException("目标父节点不存在");
|
||||
}
|
||||
SysGroup moveEntity = sysGroupRepository.findById(moveId).get();
|
||||
// 根节点不可移动
|
||||
if (isRoot(moveEntity)) {
|
||||
throw new DException("根节点不可移动");
|
||||
}
|
||||
if (Objects.equals(pid, moveId)) {
|
||||
throw new DException("不能移动到自身下");
|
||||
}
|
||||
// 环检测:目标父节点不能是待移动节点的后代(含自身)
|
||||
Map<Integer, SysGroup> allMap = sysGroupRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(SysGroup::getId, g -> g, (a, b) -> a));
|
||||
if (isDescendantOf(pid, moveId, allMap)) {
|
||||
throw new DException("不能移动到自身子节点下");
|
||||
}
|
||||
// 移动后追加到目标父节点的子节点末尾
|
||||
moveEntity.setParentId(pid);
|
||||
moveEntity.setSort(nextSort(pid));
|
||||
sysGroupRepository.save(moveEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 ancestorCandidate 是否为 nodeId 的后代(沿 parentId 向上回溯)。<br>
|
||||
*/
|
||||
private boolean isDescendantOf(Integer ancestorCandidate, Integer nodeId, Map<Integer, SysGroup> allMap) {
|
||||
Integer cursor = ancestorCandidate;
|
||||
while (cursor != null && cursor != ROOT_PARENT_ID) {
|
||||
if (Objects.equals(cursor, nodeId)) {
|
||||
return true;
|
||||
}
|
||||
SysGroup g = allMap.get(cursor);
|
||||
if (g == null) {
|
||||
return false;
|
||||
}
|
||||
cursor = g.getParentId();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===================== 子树聚合 =====================
|
||||
|
||||
@Override
|
||||
public List<Integer> listSelfAndDescendantIds(Integer groupId) {
|
||||
List<Integer> result = new ArrayList<>();
|
||||
if (groupId == null) {
|
||||
return result;
|
||||
}
|
||||
result.add(groupId);
|
||||
// parentId -> 直接子节点ID列表
|
||||
Map<Integer, List<Integer>> childrenMap = new HashMap<>();
|
||||
for (SysGroup g : sysGroupRepository.findAll()) {
|
||||
Integer parent = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
|
||||
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(g.getId());
|
||||
}
|
||||
// 广度优先向下收集所有子孙
|
||||
List<Integer> frontier = new ArrayList<>();
|
||||
frontier.add(groupId);
|
||||
while (!frontier.isEmpty()) {
|
||||
List<Integer> next = new ArrayList<>();
|
||||
for (Integer id : frontier) {
|
||||
List<Integer> children = childrenMap.get(id);
|
||||
if (children != null) {
|
||||
result.addAll(children);
|
||||
next.addAll(children);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===================== 私有工具 =====================
|
||||
|
||||
/** pid 下已有子节点的最大 sort +1;无子节点返回 1。 */
|
||||
private int nextSort(Integer pid) {
|
||||
Integer max = sysGroupRepository.findMaxSortByParentId(pid);
|
||||
return (max == null ? 0 : max) + 1;
|
||||
}
|
||||
|
||||
/** 校验并返回组类型枚举;非法类型抛异常。 */
|
||||
private GroupTypeEnum resolveType(Integer type) {
|
||||
GroupTypeEnum typeEnum = IEnum.getByKey(GroupTypeEnum.class, type);
|
||||
if (typeEnum == null) {
|
||||
throw new DException("组类型不存在");
|
||||
}
|
||||
return typeEnum;
|
||||
}
|
||||
|
||||
/** 是否为系统根节点(唯一的 pid=0 节点)。 */
|
||||
private boolean isRoot(SysGroup entity) {
|
||||
return entity.getParentId() != null && entity.getParentId() == ROOT_PARENT_ID;
|
||||
}
|
||||
|
||||
private SysGroupVo convertToVo(SysGroup entity) {
|
||||
SysGroupVo vo = new SysGroupVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
vo.setType(entity.getType() == null ? null : entity.getType().getKey());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.userbase.entity.SysOperationLog;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.oplog.SysOperationLogListVo;
|
||||
import com.yunhai.userbase.model.oplog.SysOperationLogQuery;
|
||||
import com.yunhai.userbase.repository.SysOperationLogRepository;
|
||||
import com.yunhai.userbase.service.ISysOperationLogService;
|
||||
import com.yunhai.userbase.utils.GzipUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import javax.persistence.TypedQuery;
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.CriteriaQuery;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统操作日志业务实现。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/7/28
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SysOperationLogServiceImpl implements ISysOperationLogService {
|
||||
|
||||
/** 一般日志(level=3)保留时长:一年(12 个月)。 */
|
||||
private static final int GENERAL_RETENTION_MONTHS = 12;
|
||||
/** 重要(level=1)/次要(level=2)日志保留时长:三年(36 个月)。 */
|
||||
private static final int IMPORTANT_RETENTION_MONTHS = 36;
|
||||
|
||||
@Autowired
|
||||
private SysOperationLogRepository sysOperationLogRepository;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager em;
|
||||
|
||||
@Override
|
||||
@Async
|
||||
public void saveAsync(SysOperationLog entity) {
|
||||
try {
|
||||
sysOperationLogRepository.save(entity);
|
||||
} catch (Exception e) {
|
||||
// 日志落库失败绝不抛回主流程,仅记录
|
||||
log.error("操作日志异步落库失败, uri={}, method={}",
|
||||
entity.getRequestUri(), entity.getRequestMethod(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BasePageResult<SysOperationLogListVo> pageQuery(SysOperationLogQuery query,
|
||||
BasePageQuery pageQuery) {
|
||||
int pageIndex = Math.max(pageQuery.getPageIndex(), 1);
|
||||
int pageSize = Math.max(pageQuery.getPageSize(), 1);
|
||||
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
// 列表投影:显式选择字段,不包含 response_data,避免加载 MEDIUMBLOB
|
||||
CriteriaQuery<SysOperationLogListVo> cq = cb.createQuery(SysOperationLogListVo.class);
|
||||
Root<SysOperationLog> root = cq.from(SysOperationLog.class);
|
||||
cq.select(cb.construct(SysOperationLogListVo.class,
|
||||
root.get("id"), root.get("logLevel"), root.get("userId"), root.get("username"),
|
||||
root.get("requestIp"), root.get("requestUri"), root.get("requestMethod"),
|
||||
root.get("operationType"), root.get("classMethod"), root.get("requestParams"),
|
||||
root.get("statusCode"), root.get("costTime"), root.get("userAgent"),
|
||||
root.get("errorMsg"), root.get("createTime")));
|
||||
List<Predicate> predicates = buildPredicates(cb, root, query);
|
||||
cq.where(predicates.toArray(new Predicate[0]));
|
||||
cq.orderBy(cb.desc(root.get("createTime")));
|
||||
|
||||
// 总数查询
|
||||
CriteriaQuery<Long> countCq = cb.createQuery(Long.class);
|
||||
Root<SysOperationLog> countRoot = countCq.from(SysOperationLog.class);
|
||||
countCq.select(cb.count(countRoot));
|
||||
countCq.where(buildPredicates(cb, countRoot, query).toArray(new Predicate[0]));
|
||||
long total = em.createQuery(countCq).getSingleResult();
|
||||
|
||||
TypedQuery<SysOperationLogListVo> typed = em.createQuery(cq);
|
||||
typed.setFirstResult((pageIndex - 1) * pageSize);
|
||||
typed.setMaxResults(pageSize);
|
||||
List<SysOperationLogListVo> content = typed.getResultList();
|
||||
|
||||
int totalPages = (int) Math.ceil((double) total / pageSize);
|
||||
boolean hasNext = pageIndex < totalPages;
|
||||
|
||||
return new BasePageResult<>(content, (int) total, pageIndex, pageSize, totalPages, hasNext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getResponseData(Long id) {
|
||||
SysOperationLog entity = sysOperationLogRepository.findById(id).orElse(null);
|
||||
if (entity == null || entity.getResponseData() == null) {
|
||||
return null;
|
||||
}
|
||||
return GzipUtil.decompress(entity.getResponseData());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public long cleanupExpired() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime generalDeadline = now.minusMonths(GENERAL_RETENTION_MONTHS);
|
||||
LocalDateTime importantDeadline = now.minusMonths(IMPORTANT_RETENTION_MONTHS);
|
||||
// 一般日志(level=3)保留一年(12个月);重要(level=1)/次要(level=2)保留三年(36个月)
|
||||
long deleted = sysOperationLogRepository.deleteByLogLevelAndCreateTimeBefore(3, generalDeadline)
|
||||
+ sysOperationLogRepository.deleteByLogLevelAndCreateTimeBefore(2, importantDeadline)
|
||||
+ sysOperationLogRepository.deleteByLogLevelAndCreateTimeBefore(1, importantDeadline);
|
||||
log.info("操作日志清理完成, 删除{}条(一般截止={}, 重要/次要截止={})",
|
||||
deleted, generalDeadline, importantDeadline);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建动态查询条件。<br>
|
||||
* 模糊字段使用 LIKE,时间使用范围,其余等值匹配。<br>
|
||||
*
|
||||
* @param cb 条件构建器
|
||||
* @param root 根路径
|
||||
* @param query 查询条件
|
||||
* @return 谓词列表
|
||||
*/
|
||||
private List<Predicate> buildPredicates(CriteriaBuilder cb, Root<SysOperationLog> root,
|
||||
SysOperationLogQuery query) {
|
||||
List<Predicate> predicates = new ArrayList<Predicate>();
|
||||
if (query == null) {
|
||||
return predicates;
|
||||
}
|
||||
if (query.getLogLevel() != null) {
|
||||
predicates.add(cb.equal(root.get("logLevel"), query.getLogLevel()));
|
||||
}
|
||||
if (query.getUserId() != null) {
|
||||
predicates.add(cb.equal(root.get("userId"), query.getUserId()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getUsername())) {
|
||||
predicates.add(cb.like(root.get("username"), "%" + query.getUsername() + "%"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getRequestUri())) {
|
||||
predicates.add(cb.like(root.get("requestUri"), "%" + query.getRequestUri() + "%"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getRequestIp())) {
|
||||
predicates.add(cb.like(root.get("requestIp"), "%" + query.getRequestIp() + "%"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getOperationType())) {
|
||||
predicates.add(cb.equal(root.get("operationType"), query.getOperationType()));
|
||||
}
|
||||
if (query.getStatusCode() != null) {
|
||||
predicates.add(cb.equal(root.get("statusCode"), query.getStatusCode()));
|
||||
}
|
||||
if (query.getSuccess() != null) {
|
||||
if (Boolean.TRUE.equals(query.getSuccess())) {
|
||||
predicates.add(cb.equal(root.get("statusCode"), 200));
|
||||
} else {
|
||||
predicates.add(cb.notEqual(root.get("statusCode"), 200));
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getKeyword())) {
|
||||
String kw = "%" + query.getKeyword().trim() + "%";
|
||||
predicates.add(cb.or(
|
||||
cb.like(root.get("requestUri"), kw),
|
||||
cb.like(root.get("username"), kw)));
|
||||
}
|
||||
if (query.getStartTime() != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime()));
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime()));
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.bestsch.utils.DException;
|
||||
import com.yunhai.userbase.enums.IEnum;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.enums.SysRoleEnum;
|
||||
import com.yunhai.userbase.enums.UserTypeEnum;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserQuery;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserVo;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import com.yunhai.userbase.service.ISysUserService;
|
||||
import com.yunhai.userbase.utils.ValidatorUtil;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import javax.persistence.criteria.Subquery;
|
||||
import javax.transaction.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
* @date 2026/7/20 16:14
|
||||
* @description 系统用户服务实现
|
||||
*/
|
||||
@Service
|
||||
public class SysUserService implements ISysUserService {
|
||||
|
||||
/** 主归属标识:1=本职主学校/部门 */
|
||||
private static final int IS_MAIN = 1;
|
||||
|
||||
/** 系统内置超级管理员账号(与 sys_user_init.sql 种子账号保持一致) */
|
||||
private static final Set<String> SUPER_ADMIN_ACCOUNTS =
|
||||
new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3"));
|
||||
|
||||
@Autowired
|
||||
private SysUserRepository sysUserRepository;
|
||||
|
||||
@Autowired
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
|
||||
@Autowired
|
||||
private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
|
||||
|
||||
@Autowired
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
|
||||
@Autowired
|
||||
private ISysGroupService sysGroupService;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(SysUserRequest request) {
|
||||
if (sysUserRepository.countByAccount(request.getAccount()) > 0) {
|
||||
throw new DException("账号已存在");
|
||||
}
|
||||
if (!sysGroupRepository.existsById(request.getGroupId())) {
|
||||
throw new DException("所属分组不存在");
|
||||
}
|
||||
validateProfile(request);
|
||||
String encodedPassword = validateAndEncodePassword(request.getPassword(), request.getConfirmPassword());
|
||||
|
||||
SysUser entity = new SysUser();
|
||||
entity.setAccount(request.getAccount());
|
||||
entity.setPassword(encodedPassword);
|
||||
entity.setRealName(request.getRealName());
|
||||
entity.setPhone(request.getPhone());
|
||||
entity.setBirthday(request.getBirthday());
|
||||
entity.setIdcard(request.getIdcard());
|
||||
entity.setProfilePicture(request.getProfilePicture());
|
||||
entity.setRoles(request.getRoles());
|
||||
// 用户类型推导:超级管理员账号固定为 1;其余含 ADMIN(7) 角色 -> 2 学校管理员;默认 0 普通用户
|
||||
entity.setType(resolveUserType(entity.getAccount(), entity.getRoles()));
|
||||
// disabled 用枚举存储:不传时默认 NORMAL(正常/未禁用)
|
||||
entity.setDisabled(request.getDisabled() != null ? resolveStatus(request.getDisabled()) : StatusEnum.NORMAL);
|
||||
entity.setExpired(request.getExpired() != null ? request.getExpired() : false);
|
||||
sysUserRepository.save(entity);
|
||||
|
||||
// 写入组用户关联
|
||||
SysUserGroupRel rel = new SysUserGroupRel();
|
||||
rel.setUserId(entity.getId());
|
||||
rel.setGroupId(request.getGroupId());
|
||||
rel.setIsMain(IS_MAIN);
|
||||
rel.setCreateTime(LocalDateTime.now());
|
||||
sysUserGroupRelRepository.save(rel);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void edit(Integer id, SysUserRequest request) {
|
||||
if (sysUserRepository.countByAccountAndIdNot(request.getAccount(), id) > 0) {
|
||||
throw new DException("账号已被其他用户使用");
|
||||
}
|
||||
SysUser entity = sysUserRepository.findById(id)
|
||||
.orElseThrow(() -> new DException("系统用户不存在"));
|
||||
validateProfile(request);
|
||||
|
||||
entity.setAccount(request.getAccount());
|
||||
// 密码非空才修改:需两次一致 + 弱密码校验 + BCrypt 加密
|
||||
if (StringUtils.isNotBlank(request.getPassword())) {
|
||||
entity.setPassword(validateAndEncodePassword(request.getPassword(), request.getConfirmPassword()));
|
||||
}
|
||||
entity.setRealName(request.getRealName());
|
||||
entity.setPhone(request.getPhone());
|
||||
entity.setBirthday(request.getBirthday());
|
||||
entity.setIdcard(request.getIdcard());
|
||||
entity.setProfilePicture(request.getProfilePicture());
|
||||
entity.setRoles(request.getRoles());
|
||||
// 用户类型推导:仅当本次传入了角色时才重算,避免编辑时未传角色把 type 误重置为 0
|
||||
if (request.getRoles() != null) {
|
||||
entity.setType(resolveUserType(entity.getAccount(), entity.getRoles()));
|
||||
}
|
||||
if (request.getDisabled() != null) {
|
||||
entity.setDisabled(resolveStatus(request.getDisabled()));
|
||||
}
|
||||
if (request.getExpired() != null) {
|
||||
entity.setExpired(request.getExpired());
|
||||
}
|
||||
sysUserRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Integer id) {
|
||||
if (!sysUserRepository.existsById(id)) {
|
||||
throw new DException("系统用户不存在");
|
||||
}
|
||||
// 先清理组用户关联、用户-角色绑定,再删除用户
|
||||
sysUserGroupRelRepository.deleteByUserId(id);
|
||||
// AI-FIX(角色授权): 删除用户时级联清理用户-业务角色绑定(逻辑外键无物理 FK,避免残留孤儿数据)
|
||||
sysUserAppRoleRelRepository.deleteByUserIdBulk(id);
|
||||
sysUserRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void changeStatus(Integer id, boolean disabled) {
|
||||
SysUser entity = sysUserRepository.findById(id)
|
||||
.orElseThrow(() -> new DException("系统用户不存在"));
|
||||
// 实体 disabled 字段为 StatusEnum(0=正常/未禁用,1=禁用):boolean 直接映射枚举
|
||||
entity.setDisabled(disabled ? StatusEnum.DISABLED : StatusEnum.NORMAL);
|
||||
sysUserRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<SysUserVo> list(SysUserQuery query, Pageable pageable) {
|
||||
Specification<SysUser> spec = buildSpecification(query);
|
||||
return sysUserRepository.findAll(spec, pageable).map(this::convertToVo);
|
||||
}
|
||||
|
||||
// ===================== 私有工具 =====================
|
||||
|
||||
/**
|
||||
* 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。<br>
|
||||
*/
|
||||
private Specification<SysUser> buildSpecification(SysUserQuery query) {
|
||||
return (root, cbQuery, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// 组范围过滤:用户ID 属于目标组集合下的关联记录
|
||||
if (query.getGroupId() != null) {
|
||||
List<Integer> groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub());
|
||||
Subquery<Integer> sub = cbQuery.subquery(Integer.class);
|
||||
Root<SysUserGroupRel> relRoot = sub.from(SysUserGroupRel.class);
|
||||
sub.select(relRoot.get("userId"));
|
||||
sub.where(relRoot.get("groupId").in(groupIds));
|
||||
predicates.add(root.get("id").in(sub));
|
||||
}
|
||||
|
||||
// 关键字:账号/姓名/身份证模糊,手机号精确
|
||||
if (StringUtils.isNotBlank(query.getName())) {
|
||||
String keyword = query.getName().trim();
|
||||
String like = "%" + keyword + "%";
|
||||
Predicate byAccount = cb.like(root.get("account"), like);
|
||||
Predicate byRealName = cb.like(root.get("realName"), like);
|
||||
Predicate byIdcard = cb.like(root.get("idcard"), like);
|
||||
Predicate byPhone = cb.equal(root.get("phone"), keyword);
|
||||
predicates.add(cb.or(byAccount, byRealName, byIdcard, byPhone));
|
||||
}
|
||||
|
||||
if (query.getDisabled() != null) {
|
||||
// 实体 disabled 为 StatusEnum,查询条件需用枚举值比较(按 key 解析)
|
||||
predicates.add(cb.equal(root.get("disabled"), IEnum.getByKey(StatusEnum.class, query.getDisabled())));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
/** 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */
|
||||
private List<Integer> resolveGroupIds(Integer groupId, Integer withSub) {
|
||||
if (withSub != null && withSub == 1) {
|
||||
return sysGroupService.listSelfAndDescendantIds(groupId);
|
||||
}
|
||||
List<Integer> single = new ArrayList<>();
|
||||
single.add(groupId);
|
||||
return single;
|
||||
}
|
||||
|
||||
/** 校验手机号与身份证(有值才校验)。 */
|
||||
private void validateProfile(SysUserRequest request) {
|
||||
if (StringUtils.isNotBlank(request.getPhone()) && !ValidatorUtil.isPhone(request.getPhone())) {
|
||||
throw new DException("手机号格式不正确");
|
||||
}
|
||||
if (StringUtils.isNotBlank(request.getIdcard()) && !ValidatorUtil.isIdCard(request.getIdcard())) {
|
||||
throw new DException("身份证号不合法");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验两次密码一致性与弱密码规则,通过后返回 BCrypt 密文。<br>
|
||||
*/
|
||||
private String validateAndEncodePassword(String password, String confirmPassword) {
|
||||
if (!StringUtils.equals(password, confirmPassword)) {
|
||||
throw new DException("两次输入的密码不一致");
|
||||
}
|
||||
String weakReason = ValidatorUtil.checkPasswordStrength(password);
|
||||
if (weakReason != null) {
|
||||
throw new DException(weakReason);
|
||||
}
|
||||
return passwordEncoder.encode(password);
|
||||
}
|
||||
|
||||
private SysUserVo convertToVo(SysUser entity) {
|
||||
SysUserVo vo = new SysUserVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
// disabled 为枚举,需要单独映射为 key(与 GroupTypeEnum 的 type 处理一致)
|
||||
vo.setDisabled(entity.getDisabled() == null ? null : entity.getDisabled().getKey());
|
||||
// type 为枚举,同样映射为 key
|
||||
vo.setType(entity.getType() == null ? null : entity.getType().getKey());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推导用户类型(见 {@link UserTypeEnum}):
|
||||
* - 内置超级管理员账号 -> 1 超级管理员;
|
||||
* - 其余账号若拥有 ADMIN(7) 角色 -> 2 学校管理员;
|
||||
* - 其他 -> 0 普通用户。
|
||||
*/
|
||||
private UserTypeEnum resolveUserType(String account, List<Integer> roles) {
|
||||
if (account != null && SUPER_ADMIN_ACCOUNTS.contains(account)) {
|
||||
return UserTypeEnum.SUPER_ADMIN;
|
||||
}
|
||||
if (roles != null && roles.contains(SysRoleEnum.ADMIN.getKey())) {
|
||||
return UserTypeEnum.SCHOOL_ADMIN;
|
||||
}
|
||||
return UserTypeEnum.NORMAL;
|
||||
}
|
||||
|
||||
/** 校验并返回用户状态枚举;非法状态抛异常。 */
|
||||
private StatusEnum resolveStatus(Integer status) {
|
||||
StatusEnum statusEnum = IEnum.getByKey(StatusEnum.class, status);
|
||||
if (statusEnum == null) {
|
||||
throw new DException("用户状态不存在");
|
||||
}
|
||||
return statusEnum;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user