init: UserBaseService (backend + deploy + scripts)
ci-userbase-service / build-and-push (push) Successful in 40s

This commit is contained in:
chenxing
2026-08-10 14:50:22 +08:00
commit 4ec19486e0
234 changed files with 15604 additions and 0 deletions
@@ -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>
* &nbsp;&nbsp;isAll=1 → inherit_flag=true:子孙组在 hasModulePermission 判定时沿祖先链自动继承该模块权限(逻辑继承,不物理写子组);<br>
* &nbsp;&nbsp;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;
}
}