@@ -1,62 +0,0 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.identity.IdentityCurrentVo;
|
||||
import com.yunhai.userbase.model.identity.IdentityOptionVo;
|
||||
import com.yunhai.userbase.model.identity.IdentitySwitchRequest;
|
||||
import com.yunhai.userbase.service.impl.IdentityService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户身份(平台身份角色 × 学校)管理接口。<br><br>
|
||||
* 走 CAS 会话路线({@code /{platform}/identity/}),当前用户从 CAS 会话注入的
|
||||
* {@link BoaUser#getId()} 取,不接受外部传入 userId(防越权)。<br>
|
||||
* 三个端点对应「切换身份」业务:<br>
|
||||
* ① {@code GET current} 当前激活身份(无切换记录则兜底主身份);<br>
|
||||
* ② {@code GET available} 全部可选身份清单;<br>
|
||||
* ③ {@code POST switch} 切换到指定 (roleId, schoolId),服务端校验属主后写入当前激活状态。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/18
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH + "/identity/")
|
||||
public class IdentityController {
|
||||
|
||||
@Autowired
|
||||
private IdentityService identityService;
|
||||
|
||||
/**
|
||||
* 当前激活身份。
|
||||
*/
|
||||
@GetMapping("current")
|
||||
public IdentityCurrentVo current(BoaUser authUserWithDept) {
|
||||
return identityService.getCurrent(authUserWithDept.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可选身份(正常态),供「切换身份」页渲染。
|
||||
*/
|
||||
@GetMapping("available")
|
||||
public List<IdentityOptionVo> available(BoaUser authUserWithDept) {
|
||||
return identityService.listAvailable(authUserWithDept.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换身份:校验属主后在 sys_user_current_role 写当前激活状态。
|
||||
*/
|
||||
@PostMapping("switch")
|
||||
public IdentityCurrentVo switchIdentity(BoaUser authUserWithDept,
|
||||
@RequestBody @Validated IdentitySwitchRequest request) {
|
||||
return identityService.switchIdentity(authUserWithDept.getId(), request);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import com.yunhai.userbase.model.sysuser.ChangeSortNoRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserQuery;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserVo;
|
||||
import com.yunhai.userbase.model.sysuser.UserIdentityVo;
|
||||
import com.yunhai.userbase.service.ISysUserService;
|
||||
import com.yunhai.userbase.utils.PageUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -110,7 +111,7 @@ public class SysUserController {
|
||||
@OperationLog("调整组")
|
||||
@PostMapping("ChangeGroups")
|
||||
public void changeGroups(@RequestBody @Validated ChangeGroupsRequest request) {
|
||||
sysUserService.changeGroups(request.getUserIds(), request.getGroupIds());
|
||||
sysUserService.changeGroups(request.getUserIds(), request.getGroupIds(), request.getMainGroupId());
|
||||
}
|
||||
|
||||
@GetMapping("Groups/{userId}")
|
||||
@@ -118,6 +119,15 @@ public class SysUserController {
|
||||
return sysUserService.findGroupIdsByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户身份(按所在组解析 schoolId):返回 {schoolId, roleIds};同一学校多组返回同一 schoolId 与并集。<br>
|
||||
* 「用户身份」回显唯一来源(roles 列已停写,仅历史兼容读取);编辑请求提交时原样携带 {schoolId, roleIds}。
|
||||
*/
|
||||
@GetMapping("UserIdentity")
|
||||
public UserIdentityVo userIdentity(@RequestParam Integer userId, @RequestParam Integer groupId) {
|
||||
return sysUserService.getUserIdentity(userId, groupId);
|
||||
}
|
||||
|
||||
@OperationLog("批量导入用户")
|
||||
@PostMapping("BatchImport")
|
||||
public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.userbase.common.context.CallerAppContext;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.identity.IdentitySwitchOapiRequest;
|
||||
import com.yunhai.userbase.service.impl.IdentityService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户身份开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/identity/...})。<br>
|
||||
* <br>
|
||||
* 契约统一 {@link BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName),
|
||||
* 与 {@code /oapi/user/roles}、{@code BoaUser.currentRole} 完全同构,前端零转换。<br>
|
||||
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制;<br>
|
||||
* <b>切换(switch)</b>额外受 {@code yh.identity.switch-allowed-app-keys} 白名单约束(AppKey 级闸门),
|
||||
* 且目标用户必须真实存在于其身份集({@code sys_user_role_rel} 三元组校验,防越权切换他人)。<br>
|
||||
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/27
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.OPEN_API_PATH + "identity/")
|
||||
public class IdentityController {
|
||||
|
||||
@Autowired
|
||||
private IdentityService identityService;
|
||||
|
||||
/** 允许调用 switch 的应用 appKey 白名单(逗号分隔,缺省空 = 禁闭) */
|
||||
@Value("${yh.identity.switch-allowed-app-keys:}")
|
||||
private String switchAllowedAppKeys;
|
||||
|
||||
/**
|
||||
* 查询用户当前激活身份:优先 sys_user_current_role,未切换时兜底主身份(动态解析);
|
||||
* 无任何身份返回 null。
|
||||
*/
|
||||
@GetMapping("current")
|
||||
public BoaRoleInfo current(@RequestParam("userId") Integer userId) {
|
||||
return identityService.getCurrent(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户全部可选身份(正常态),按 主身份优先 → sortNo → id 排序,供「切换身份」页渲染。
|
||||
*/
|
||||
@GetMapping("available")
|
||||
public List<BoaRoleInfo> available(@RequestParam("userId") Integer userId) {
|
||||
return identityService.listAvailable(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换身份:AppKey 白名单校验后,校验三元组合法并 UPSERT sys_user_current_role。
|
||||
*/
|
||||
@PostMapping("switch")
|
||||
public BoaRoleInfo switchIdentity(@RequestBody @Validated IdentitySwitchOapiRequest request) {
|
||||
checkSwitchAllowed();
|
||||
return identityService.switchIdentity(request.getUserId(), request.getRoleId(), request.getSchoolId());
|
||||
}
|
||||
|
||||
/** AppKey 级闸门:仅允许白名单内的应用替用户切换身份。 */
|
||||
private void checkSwitchAllowed() {
|
||||
Integer callerAppKey = CallerAppContext.getAppKey();
|
||||
if (callerAppKey == null || !parseAllowedAppKeys().contains(callerAppKey)) {
|
||||
throw new DException("当前应用无权调用身份切换接口");
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Integer> parseAllowedAppKeys() {
|
||||
if (switchAllowedAppKeys == null || switchAllowedAppKeys.trim().isEmpty()) {
|
||||
return new HashSet<>();
|
||||
}
|
||||
return Arrays.stream(switchAllowedAppKeys.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.map(Integer::valueOf)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -262,10 +262,11 @@ public class UserController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨校转入(转出后迁移到其他学校)。<br>
|
||||
* 跨校转入(转出后迁移到其他学校),同时支持同校转组(原学校与目标学校相同时,从原组转到另一组)。<br>
|
||||
* 前置:用户已在原学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系(先经 groupState 置转出);<br>
|
||||
* 操作成功后:删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系
|
||||
* (主归属、状态恢复正常),并推送编辑用户订阅。<br>
|
||||
* 原组即该转出状态组关系,仅当原学校与原组都与目标一致(fromSchoolId==schoolId 且转出组==groupId)才拒绝。<br>
|
||||
* 操作成功后:同校转组删除原组关系、新增目标组关系(主归属继承原组,其他组与他校关系保留);
|
||||
* 跨校转入删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系(主归属、状态恢复正常),并推送编辑用户订阅。<br>
|
||||
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
|
||||
*/
|
||||
@PostMapping("transferToSchool")
|
||||
|
||||
@@ -37,7 +37,7 @@ public class SysUserCurrentRole {
|
||||
private Integer userId;
|
||||
|
||||
/**
|
||||
* 用户切换选中的学校ID(根级group,数值即学校组 sys_group.id;区域/学段/根直属=0或null)
|
||||
* 用户切换选中的身份归属学校(恒为真实组 id:学校组/区域组/根直属=根节点 id;原「区域/学段/根直属=0或null」约定废弃)
|
||||
*/
|
||||
private Integer schoolId;
|
||||
/**
|
||||
|
||||
@@ -51,17 +51,13 @@ public class SysUserRoleRel {
|
||||
private Integer roleId;
|
||||
|
||||
/**
|
||||
* 学校组ID(=type=SCHOOL_GROUP 的 sys_group.id);区域/学段/根直属用户填 0
|
||||
* 身份归属学校(恒为真实组 id:学校组 / 区域组 / 根直属=根节点 id)。
|
||||
* 由祖先链解析(见 {@link com.yunhai.userbase.service.ISysGroupService#resolveSchoolId});
|
||||
* 原注释「区域/学段/根=0」约定已废弃。
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private Integer schoolId;
|
||||
|
||||
/**
|
||||
* 是否主身份标记:每 userId 最多 1 条 isMain=1(应用层校验,不靠 uk)
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private Integer isMain = 0;
|
||||
|
||||
/**
|
||||
* 排序号:越小越靠前
|
||||
*/
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.yunhai.userbase.model.identity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 当前激活身份(来自 {@code sys_user_current_role};无切换记录时兜底主身份)。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/18
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IdentityCurrentVo {
|
||||
|
||||
/** 当前激活角色ID(SysRoleEnum.key) */
|
||||
private Integer roleId;
|
||||
|
||||
/** 当前激活角色名 */
|
||||
private String roleName;
|
||||
|
||||
/** 当前激活学校ID(=学校组 sys_group.id;无学校时为 0) */
|
||||
private Integer schoolId;
|
||||
|
||||
/** 当前激活学校名 */
|
||||
private String schoolName;
|
||||
|
||||
/** 是否兜底(无主动切换记录时为 true,提示前端可引导用户显式切换) */
|
||||
private Boolean fallback;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.yunhai.userbase.model.identity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 用户可选身份(一条 = 某学校下的某平台角色)。<br>
|
||||
* 数据源:{@code sys_user_role_rel}(平台身份角色权威源)。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/18
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IdentityOptionVo {
|
||||
|
||||
/** 角色ID(SysRoleEnum.key,1-7) */
|
||||
private Integer roleId;
|
||||
|
||||
/** 角色名(老师/校领导/学生…,查枚举快取) */
|
||||
private String roleName;
|
||||
|
||||
/** 学校ID(=学校组 sys_group.id;区域/学段/直属无学校时为 0) */
|
||||
private Integer schoolId;
|
||||
|
||||
/** 学校名(冗余,免 join sys_group) */
|
||||
private String schoolName;
|
||||
|
||||
/** 是否主身份(isMain=1) */
|
||||
private Boolean isMain;
|
||||
}
|
||||
+9
-4
@@ -5,15 +5,20 @@ import lombok.Data;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 切换身份入参:指定目标学校与该学校下的角色。<br>
|
||||
* 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态,<br>
|
||||
* 开放接口(oapi)切换身份入参:目标用户 + 目标学校与该学校下的角色。<br>
|
||||
* 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态,
|
||||
* 不存在则拒绝(防止越权切换到非授权身份)。<br>
|
||||
* 调用方应用须在 {@code yh.identity.switch-allowed-app-keys} 白名单内(AppKey 级闸门)。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/18
|
||||
* @date 2026/8/27
|
||||
*/
|
||||
@Data
|
||||
public class IdentitySwitchRequest {
|
||||
public class IdentitySwitchOapiRequest {
|
||||
|
||||
/** 目标用户ID(由调用方应用后端从登录会话获取,禁止前端透传) */
|
||||
@NotNull(message = "用户ID不能为空")
|
||||
private Integer userId;
|
||||
|
||||
/** 目标角色ID(SysRoleEnum.key) */
|
||||
@NotNull(message = "角色ID不能为空")
|
||||
@@ -20,4 +20,7 @@ public class ChangeGroupsRequest {
|
||||
/** 目标组ID列表(覆盖式) */
|
||||
@NotEmpty(message = "请至少保留一个组")
|
||||
private List<Integer> groupIds;
|
||||
|
||||
/** 主归属组ID(可选):不传时后端按「旧主组保留」策略降级(旧主组勾掉才落到 groupIds 首项) */
|
||||
private Integer mainGroupId;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,16 @@ public class SysUserRequest {
|
||||
private String email;
|
||||
private Integer gender;
|
||||
private String profilePicture;
|
||||
private List<Integer> roles;
|
||||
/**
|
||||
* 身份归属学校(schoolId 解析后的真实组 id:学校组/区域组/根节点)。<br>
|
||||
* 编辑时必传(随 Select 当前选中组提交原值);新增可不传,后端按 groupId 解析。
|
||||
*/
|
||||
private Integer schoolId;
|
||||
/**
|
||||
* 用户身份角色(SysRoleEnum.key 1-7),按 schoolId 全量覆盖保存;空数组=清空该校身份。<br>
|
||||
* 替代旧 roles 字段:sys_user.roles 列已停写(仅历史兼容读取)。
|
||||
*/
|
||||
private List<Integer> roleIds;
|
||||
/** 启用状态:对应 StatusEnum 的 key(0=正常/未禁用,1=禁用);不传则默认启用 */
|
||||
private Integer disabled;
|
||||
private Boolean expired;
|
||||
|
||||
@@ -31,6 +31,11 @@ public class SysUserVo {
|
||||
private Integer sortNo;
|
||||
/** 组内状态:UserGroupRelStatusEnum 的 key(0=正常,1=转出);仅组上下文列表有值,根节点/未选组为 null */
|
||||
private Integer groupStatus;
|
||||
/**
|
||||
* 主校身份名(如「老师/校领导」):列表页按页批量带出(动态解析主身份,无 N+1)。<br>
|
||||
* roles 列已停写(冻结历史值),「用户身份」列展示一律切此字段。
|
||||
*/
|
||||
private String identityNames;
|
||||
private Boolean expired;
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yunhai.userbase.model.sysuser;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户身份回显 VO(GET /sysuser/UserIdentity)。<br>
|
||||
* 后端按所在组解析 schoolId 后返回;同一学校多组返回同一 schoolId 与角色并集。<br>
|
||||
* 编辑请求提交时原样携带 {schoolId, roleIds},由后端按 schoolId 全量覆盖写库并对齐。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/27
|
||||
*/
|
||||
@Data
|
||||
public class UserIdentityVo {
|
||||
|
||||
/** 身份归属学校(解析后的真实组 id:学校组/区域组/根节点) */
|
||||
private Integer schoolId;
|
||||
|
||||
/** 该校用户身份角色(SysRoleEnum.key 1-7,并集去重) */
|
||||
private List<Integer> roleIds;
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package com.yunhai.userbase.repository;
|
||||
import com.yunhai.userbase.entity.SysUserCurrentRole;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -14,4 +17,9 @@ import java.util.Optional;
|
||||
public interface SysUserCurrentRoleRepository extends JpaRepository<SysUserCurrentRole, Integer>, JpaSpecificationExecutor<SysUserCurrentRole> {
|
||||
|
||||
Optional<SysUserCurrentRole> findByUserId(Integer userId);
|
||||
|
||||
/** 删除用户当前激活身份记录(级联删用户时清理残留切换记录) */
|
||||
@Modifying
|
||||
@Query("DELETE FROM SysUserCurrentRole r WHERE r.userId = :userId")
|
||||
void deleteByUserIdBulk(@Param("userId") Integer userId);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
@@ -23,13 +24,21 @@ public interface SysUserRoleRelRepository extends JpaRepository<SysUserRoleRel,
|
||||
/** 用户某状态下的全部可选身份 */
|
||||
List<SysUserRoleRel> findByUserIdAndStatus(Integer userId, StatusEnum status);
|
||||
|
||||
/** 用户主身份(isMain=1),最多 1 条 */
|
||||
Optional<SysUserRoleRel> findByUserIdAndIsMain(Integer userId, Integer isMain);
|
||||
/** 批量取用户在指定状态下的全部身份行(列表页/导出批量主身份解析用,替代已删除的 findByUserIdInAndIsMain) */
|
||||
List<SysUserRoleRel> findByUserIdInAndStatus(List<Integer> userIds, StatusEnum status);
|
||||
|
||||
/** 用户在某学校下、某状态下的全部角色关联 */
|
||||
List<SysUserRoleRel> findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status);
|
||||
|
||||
/** 用户所有正常态的 roleId 去重列表(用于 BoaUser.roleIds) */
|
||||
/** 删除用户在某学校下的全部身份行(按 schoolId 全量覆盖保存用) */
|
||||
void deleteByUserIdAndSchoolId(Integer userId, Integer schoolId);
|
||||
|
||||
/** 删除用户全部身份行(级联删用户时清理,bulk delete 直接发 SQL) */
|
||||
@Modifying
|
||||
@Query("DELETE FROM SysUserRoleRel r WHERE r.userId = :userId")
|
||||
void deleteByUserIdBulk(@Param("userId") Integer userId);
|
||||
|
||||
/** 用户所有正常态的 roleId 去重列表(用于 BoaUser.roleIds / resolveUserType) */
|
||||
@Query("SELECT DISTINCT r.roleId FROM SysUserRoleRel r WHERE r.userId = :userId AND r.status = :status")
|
||||
List<Integer> findDistinctRoleIdsByUserIdAndStatus(@Param("userId") Integer userId, @Param("status") StatusEnum status);
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@ import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
@@ -43,6 +46,40 @@ public interface ISysGroupService {
|
||||
*/
|
||||
void assertSchoolId(Integer schoolId);
|
||||
|
||||
/**
|
||||
* 解析唯一真实根节点(parentId=0 中 id 最小者,与 {@link #getGroups()} 约定一致)。<br>
|
||||
* 供「parentId/pid 为 0 或 null 表示挂根下」场景与身份归属解析({@link #resolveSchoolId})共用。
|
||||
*
|
||||
* @return 根节点实体
|
||||
* @throws com.yunhai.platform.utils.exception.DException 当不存在任何根节点时
|
||||
*/
|
||||
SysGroup resolveRootGroup();
|
||||
|
||||
/**
|
||||
* 解析组归属的 schoolId(身份归属):沿该组的祖先链(含自身)——<br>
|
||||
* ① 最近的学校组(SCHOOL_GROUP)→ 该校组 id;<br>
|
||||
* ② 无学校组,则最近的区域组(AREA_GROUP)→ 该区域组 id;<br>
|
||||
* ③ 学校组、区域组都没有 → 根节点 id。<br>
|
||||
* 同校多组解析到同一 schoolId;schoolId 恒为真实组 id(原表注释「区域/学段/根=0」约定废弃)。<br>
|
||||
* 该规则属后端职责,前端只按接口返回的 schoolId 加载/保存,不感知解析逻辑。
|
||||
*
|
||||
* @param groupId 组ID
|
||||
* @return 归属 schoolId
|
||||
*/
|
||||
Integer resolveSchoolId(Integer groupId);
|
||||
|
||||
/**
|
||||
* 批量解析组归属 schoolId(身份归属),语义与 {@link #resolveSchoolId} 完全一致:
|
||||
* 一次全量加载 sys_group 建内存表后沿祖先链解析(O(树深)),避免 N 次逐级 findById。<br>
|
||||
* 供列表页/导出的批量主身份解析(IdentityService.resolveMainIdentityByUser)使用;
|
||||
* 组表量级 = 学校数×层级(千级),与 searchUsers buildAllGroupMap 全表加载先例成本同量级。<br>
|
||||
* 组不存在(悬挂关系)按 {@link #resolveSchoolId} 同规则回落根节点 id。
|
||||
*
|
||||
* @param groupIds 待解析组ID集合
|
||||
* @return groupId → schoolId 映射(入参全部有值)
|
||||
*/
|
||||
Map<Integer, Integer> resolveSchoolIds(Collection<Integer> groupIds);
|
||||
|
||||
List<SysGroupVo> list(SysGroupQuery query);
|
||||
|
||||
/** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserQuery;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserVo;
|
||||
import com.yunhai.userbase.model.sysuser.UserIdentityVo;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -102,14 +103,30 @@ public interface ISysUserService {
|
||||
*/
|
||||
List<Integer> findGroupIdsByUserId(Integer userId);
|
||||
|
||||
/**
|
||||
* 查询用户身份(管理端「用户身份」回显):后端按所在组解析 schoolId 后返回 {schoolId, roleIds};<br>
|
||||
* 同一学校多组返回同一 schoolId 与角色并集。前端回显唯一来源(roles 列已停写,不再作为真源)。<br>
|
||||
* 编辑请求提交时原样携带返回的 {schoolId, roleIds},由后端按 schoolId 全量覆盖写库并对齐。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param groupId 所在组ID(Select 当前选中项;schoolId 解析规则见 {@link com.yunhai.userbase.service.ISysGroupService#resolveSchoolId})
|
||||
* @return {schoolId, roleIds}
|
||||
*/
|
||||
UserIdentityVo getUserIdentity(Integer userId, Integer groupId);
|
||||
|
||||
/**
|
||||
* 批量调整用户所属组:覆盖式更新 sys_user_group_rel。<br>
|
||||
* 同一批用户统一设置为目标组列表,第一个组设为主归属,其余为兼职。
|
||||
* 主归属组按三级降级确定:<br>
|
||||
* ① mainGroupId 显式传入(须 ∈ groupIds,否则 400)→ 所有用户统一置主;<br>
|
||||
* ② 未传 → 逐用户沿用旧主组(旧主组 ∈ 新 groupIds);<br>
|
||||
* ③ 旧主组不在新集合 → 取 groupIds 首项(现状行为兜底)。<br>
|
||||
* ② 级为行为增强:只要用户没把主组勾掉,调整组后主组不动(旧实现按数组顺序随机重指)。
|
||||
*
|
||||
* @param userIds 待调整用户ID列表
|
||||
* @param groupIds 目标组ID列表
|
||||
* @param mainGroupId 主归属组ID(可选;null 走 ②③ 级降级)
|
||||
*/
|
||||
void changeGroups(List<Integer> userIds, List<Integer> groupIds);
|
||||
void changeGroups(List<Integer> userIds, List<Integer> groupIds, Integer mainGroupId);
|
||||
|
||||
/**
|
||||
* 开放接口专用:在学校(组)范围内移动用户。<br>
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||
import com.yunhai.userbase.service.impl.UserCenterService;
|
||||
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
@@ -90,6 +91,9 @@ public class GroupAuthzService implements IGroupAuthzService {
|
||||
@Autowired
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
|
||||
@Autowired
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
|
||||
@Autowired
|
||||
private SysUserRepository sysUserRepository;
|
||||
|
||||
@@ -745,18 +749,8 @@ public class GroupAuthzService implements IGroupAuthzService {
|
||||
for (SysUser user : users) {
|
||||
userIds.add(user.getId());
|
||||
}
|
||||
Map<Integer, SysUserGroupRel> mainRelMap = new HashMap<>();
|
||||
for (SysUserGroupRel rel : sysUserGroupRelRepository.findAll()) {
|
||||
if (rel.getUserId() == null || !userIds.contains(rel.getUserId())) {
|
||||
continue;
|
||||
}
|
||||
SysUserGroupRel existing = mainRelMap.get(rel.getUserId());
|
||||
// 优先取主归属(isMain=1),否则保留第一个
|
||||
if (existing == null || (existing.getIsMain() == null || existing.getIsMain() != 1)
|
||||
&& rel.getIsMain() != null && rel.getIsMain() == 1) {
|
||||
mainRelMap.put(rel.getUserId(), rel);
|
||||
}
|
||||
}
|
||||
// 批量解析主归属组(isMain=1 取 id 最小,无主兜底 id 最小),一次 IN 查询,消灭全表扫描
|
||||
Map<Integer, SysUserGroupRel> mainRelMap = mainGroupResolver.resolveByUserIds(new ArrayList<>(userIds));
|
||||
|
||||
List<UserPermissionUserVo> result = new ArrayList<>();
|
||||
for (SysUser user : users) {
|
||||
@@ -1026,23 +1020,16 @@ public class GroupAuthzService implements IGroupAuthzService {
|
||||
if (!sysUserRepository.existsById(userId)) {
|
||||
throw new DException("用户不存在");
|
||||
}
|
||||
// 1. 用户直接归属的组;主归属组(isMain=1)用于「本组」标记,无主归属时取第一个兜底(与 searchUsers 一致)
|
||||
// 1. 用户直接归属的组;主归属组(isMain=1,无主兜底 id 最小)用于「本组」标记
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserId(userId);
|
||||
Set<Integer> selfGroupIds = new LinkedHashSet<>();
|
||||
Integer mainGroupId = null;
|
||||
boolean hasMainGroup = false;
|
||||
SysUserGroupRel mainRel = mainGroupResolver.resolve(rels);
|
||||
Integer mainGroupId = mainRel != null ? mainRel.getGroupId() : null;
|
||||
for (SysUserGroupRel rel : rels) {
|
||||
if (rel.getGroupId() == null) {
|
||||
continue;
|
||||
}
|
||||
selfGroupIds.add(rel.getGroupId());
|
||||
if (!hasMainGroup && rel.getIsMain() != null && rel.getIsMain() == 1) {
|
||||
mainGroupId = rel.getGroupId();
|
||||
hasMainGroup = true;
|
||||
}
|
||||
if (mainGroupId == null) {
|
||||
mainGroupId = rel.getGroupId();
|
||||
}
|
||||
}
|
||||
if (selfGroupIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.enums.IEnum;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.entity.SysUserCurrentRole;
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||
import com.yunhai.platform.utils.enums.IEnum;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.enums.SysRoleEnum;
|
||||
import com.yunhai.userbase.model.identity.IdentityCurrentVo;
|
||||
import com.yunhai.userbase.model.identity.IdentityOptionVo;
|
||||
import com.yunhai.userbase.model.identity.IdentitySwitchRequest;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
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;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户身份服务:管理「平台身份角色 × 学校」维度的当前激活状态与可选清单。<br><br>
|
||||
@@ -40,63 +47,57 @@ public class IdentityService {
|
||||
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
|
||||
@Autowired
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
@Autowired
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
@Autowired
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
@Autowired
|
||||
private ISysGroupService sysGroupService;
|
||||
|
||||
/**
|
||||
* 用户全部可选身份(正常态),用于「切换身份」页渲染。
|
||||
* 排序:主身份优先 → sortNo 升序 → id 升序(「未切换默认第一个」的确定性保证)。
|
||||
*/
|
||||
public List<IdentityOptionVo> listAvailable(Integer userId) {
|
||||
List<SysUserRoleRel> rels = sysUserRoleRelRepository
|
||||
.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
||||
List<IdentityOptionVo> result = new ArrayList<>(rels.size());
|
||||
for (SysUserRoleRel rel : rels) {
|
||||
IdentityOptionVo vo = new IdentityOptionVo();
|
||||
vo.setRoleId(rel.getRoleId());
|
||||
vo.setRoleName(roleName(rel.getRoleId()));
|
||||
vo.setSchoolId(rel.getSchoolId());
|
||||
vo.setSchoolName(schoolName(rel.getSchoolId()));
|
||||
vo.setIsMain(rel.getIsMain() != null && rel.getIsMain() == 1);
|
||||
result.add(vo);
|
||||
public List<BoaRoleInfo> listAvailable(Integer userId) {
|
||||
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
||||
SysUserRoleRel main = resolveMainIdentity(userId);
|
||||
List<SysUserRoleRel> sorted = rels.stream()
|
||||
.sorted(Comparator.comparingInt((SysUserRoleRel r) ->
|
||||
main != null && Objects.equals(r.getId(), main.getId()) ? 0 : 1)
|
||||
.thenComparingInt(SysUserRoleRel::getSortNo)
|
||||
.thenComparingInt(SysUserRoleRel::getId))
|
||||
.collect(Collectors.toList());
|
||||
List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
|
||||
for (SysUserRoleRel rel : sorted) {
|
||||
result.add(toBoaRoleInfo(rel.getRoleId(), rel.getSchoolId()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(isMain=1)。
|
||||
* 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(动态解析,替代 is_main 物化字段)。
|
||||
* 连主身份都没有时返回 null(前端引导创建身份)。
|
||||
*/
|
||||
public IdentityCurrentVo getCurrent(Integer userId) {
|
||||
public BoaRoleInfo getCurrent(Integer userId) {
|
||||
Optional<SysUserCurrentRole> currentOpt = sysUserCurrentRoleRepository.findByUserId(userId);
|
||||
IdentityCurrentVo vo = new IdentityCurrentVo();
|
||||
if (currentOpt.isPresent()) {
|
||||
SysUserCurrentRole current = currentOpt.get();
|
||||
vo.setRoleId(current.getRoleId());
|
||||
vo.setRoleName(roleName(current.getRoleId()));
|
||||
vo.setSchoolId(current.getSchoolId());
|
||||
vo.setSchoolName(current.getSchoolName());
|
||||
vo.setFallback(false);
|
||||
return vo;
|
||||
return toBoaRoleInfo(current.getRoleId(), current.getSchoolId(), current.getSchoolName());
|
||||
}
|
||||
// 兜底:主身份整条记录(isMain=1)
|
||||
SysUserRoleRel mainRel = sysUserRoleRelRepository.findByUserIdAndIsMain(userId, 1).orElse(null);
|
||||
// 兜底:主身份整条记录(主组 → 主校 → 该校 sortNo/id 最小行)
|
||||
SysUserRoleRel mainRel = resolveMainIdentity(userId);
|
||||
if (mainRel != null) {
|
||||
vo.setRoleId(mainRel.getRoleId());
|
||||
vo.setRoleName(roleName(mainRel.getRoleId()));
|
||||
vo.setSchoolId(mainRel.getSchoolId());
|
||||
vo.setSchoolName(schoolName(mainRel.getSchoolId()));
|
||||
vo.setFallback(true);
|
||||
return vo;
|
||||
return toBoaRoleInfo(mainRel.getRoleId(), mainRel.getSchoolId());
|
||||
}
|
||||
// 连主身份都没有(测试期初数据尚未建立):返回空对象,前端引导创建身份
|
||||
vo.setFallback(true);
|
||||
return vo;
|
||||
// 连主身份都没有(测试期初数据尚未建立):返回 null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换身份:校验 (userId, roleId, schoolId) 在 sys_user_role_rel 正常态中存在,
|
||||
* 再 UPSERT sys_user_current_role(按 userId 唯一)。
|
||||
*/
|
||||
public IdentityCurrentVo switchIdentity(Integer userId, IdentitySwitchRequest request) {
|
||||
Integer roleId = request.getRoleId();
|
||||
Integer schoolId = request.getSchoolId();
|
||||
public BoaRoleInfo switchIdentity(Integer userId, Integer roleId, Integer schoolId) {
|
||||
// 校验目标身份确属该用户且正常态,防越权切换
|
||||
SysUserRoleRel rel = sysUserRoleRelRepository
|
||||
.findByUserIdAndSchoolIdAndStatus(userId, schoolId, StatusEnum.NORMAL)
|
||||
@@ -119,13 +120,77 @@ public class IdentityService {
|
||||
current.setSwitchTime(LocalDateTime.now());
|
||||
sysUserCurrentRoleRepository.save(current);
|
||||
|
||||
IdentityCurrentVo vo = new IdentityCurrentVo();
|
||||
vo.setRoleId(roleId);
|
||||
vo.setRoleName(roleName(roleId));
|
||||
vo.setSchoolId(schoolId);
|
||||
vo.setSchoolName(current.getSchoolName());
|
||||
vo.setFallback(false);
|
||||
return vo;
|
||||
return toBoaRoleInfo(roleId, schoolId, current.getSchoolName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 单用户主身份(替代 sys_user_role_rel.is_main 物化字段的读时计算,语义与原「主校首行置 1」一致):<br>
|
||||
* 主组(MainGroupResolver,isMain=1 取 id 最小,无主兜底 id 最小)→ resolveSchoolId(主组) →
|
||||
* 该校正常态身份行中 sortNo 最小、并列取 id 最小的一条。<br>
|
||||
* 无组关系 / 主校无身份行 → null(getCurrent 兜底与 UserCenterService 共用)。
|
||||
*/
|
||||
public SysUserRoleRel resolveMainIdentity(Integer userId) {
|
||||
SysUserGroupRel mainGroup = mainGroupResolver.resolve(userId).orElse(null);
|
||||
if (mainGroup == null) {
|
||||
return null;
|
||||
}
|
||||
Integer mainSchoolId = sysGroupService.resolveSchoolId(mainGroup.getGroupId());
|
||||
return pickMainIdentity(mainSchoolId, sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量主身份解析:列表页身份名列 + 导出用,全程 2 次组查询 + 1 次身份行 IN 查询(无 N+1)。<br>
|
||||
* 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。
|
||||
*/
|
||||
public Map<Integer, SysUserRoleRel> resolveMainIdentityByUser(List<Integer> userIds) {
|
||||
Map<Integer, SysUserRoleRel> result = new HashMap<>();
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Integer, SysUserGroupRel> mainGroupMap = mainGroupResolver.resolveByUserIds(userIds);
|
||||
if (mainGroupMap.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Integer, Integer> schoolByGroup = sysGroupService.resolveSchoolIds(
|
||||
mainGroupMap.values().stream().map(SysUserGroupRel::getGroupId).collect(Collectors.toList()));
|
||||
Map<Integer, List<SysUserRoleRel>> roleRelsByUser = sysUserRoleRelRepository
|
||||
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream()
|
||||
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
|
||||
for (Map.Entry<Integer, SysUserGroupRel> e : mainGroupMap.entrySet()) {
|
||||
Integer mainSchoolId = schoolByGroup.get(e.getValue().getGroupId());
|
||||
if (mainSchoolId == null) {
|
||||
continue;
|
||||
}
|
||||
SysUserRoleRel main = pickMainIdentity(mainSchoolId,
|
||||
roleRelsByUser.getOrDefault(e.getKey(), Collections.emptyList()));
|
||||
if (main != null) {
|
||||
result.put(e.getKey(), main);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 在身份行列表中取主校行:schoolId 匹配主校,sortNo 最小、并列取 id 最小;无匹配返回 null */
|
||||
private SysUserRoleRel pickMainIdentity(Integer mainSchoolId, List<SysUserRoleRel> rels) {
|
||||
return rels.stream()
|
||||
.filter(r -> Objects.equals(r.getSchoolId(), mainSchoolId))
|
||||
.min(Comparator.comparingInt(SysUserRoleRel::getSortNo)
|
||||
.thenComparingInt(SysUserRoleRel::getId))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** 组装 BoaRoleInfo(childId/childName 家长-孩子维度本期不做,恒空) */
|
||||
private BoaRoleInfo toBoaRoleInfo(Integer roleId, Integer schoolId) {
|
||||
return toBoaRoleInfo(roleId, schoolId, schoolName(schoolId));
|
||||
}
|
||||
|
||||
private BoaRoleInfo toBoaRoleInfo(Integer roleId, Integer schoolId, String schoolName) {
|
||||
BoaRoleInfo info = new BoaRoleInfo();
|
||||
info.setRoleId(roleId);
|
||||
info.setRoleName(roleName(roleId));
|
||||
info.setSchId(schoolId);
|
||||
info.setSchName(schoolName);
|
||||
return info;
|
||||
}
|
||||
|
||||
private String roleName(Integer roleId) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.transaction.Transactional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
@@ -743,7 +744,8 @@ public class SysGroupService implements ISysGroupService {
|
||||
* @return 根节点实体
|
||||
* @throws DException 当不存在任何根节点时
|
||||
*/
|
||||
private SysGroup resolveRootGroup() {
|
||||
@Override
|
||||
public SysGroup resolveRootGroup() {
|
||||
List<SysGroup> roots = sysGroupRepository.findByParentIdOrderBySortAsc(ROOT_PARENT_ID);
|
||||
if (roots.isEmpty()) {
|
||||
throw new DException("根节点不存在,无法在根下建组");
|
||||
@@ -754,6 +756,86 @@ public class SysGroupService implements ISysGroupService {
|
||||
.orElse(roots.get(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析组归属的 schoolId(身份归属):沿祖先链(含自身)找最近的学校组;
|
||||
* 无学校组则找最近的区域组;都没有则回落根节点 id(详见 {@link ISysGroupService#resolveSchoolId})。<br>
|
||||
* 供用户身份写入(SysUserService 保存/对齐)、改组对齐、回填脚本共用。
|
||||
*/
|
||||
@Override
|
||||
public Integer resolveSchoolId(Integer groupId) {
|
||||
if (groupId == null) {
|
||||
throw new DException("组ID不能为空");
|
||||
}
|
||||
Integer fallbackAreaId = null;
|
||||
Integer cursorId = groupId;
|
||||
while (cursorId != null && cursorId != ROOT_PARENT_ID) {
|
||||
SysGroup g = sysGroupRepository.findById(cursorId).orElse(null);
|
||||
if (g == null) {
|
||||
break;
|
||||
}
|
||||
if (g.getType() == GroupTypeEnum.SCHOOL_GROUP) {
|
||||
return g.getId();
|
||||
}
|
||||
// 沿链向上过程中记录最近(首个命中)的区域组;学校组优先于区域组
|
||||
if (fallbackAreaId == null && g.getType() == GroupTypeEnum.AREA_GROUP) {
|
||||
fallbackAreaId = g.getId();
|
||||
}
|
||||
cursorId = g.getParentId();
|
||||
}
|
||||
if (fallbackAreaId != null) {
|
||||
return fallbackAreaId;
|
||||
}
|
||||
// 学校组、区域组都没有:直属根节点,归属根节点 id
|
||||
return resolveRootGroup().getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解析 schoolId:一次全量加载 sys_group 建 id→组 内存表,逐组沿祖先链解析(与
|
||||
* {@link #resolveSchoolId} 同规则);组不存在时按「链断」处理回落根节点 id(语义一致)。
|
||||
* 根节点取 parentId=0 中 id 最小(与 {@link #resolveRootGroup()} 约定一致),内存表无根时才回退单查。
|
||||
*/
|
||||
@Override
|
||||
public Map<Integer, Integer> resolveSchoolIds(Collection<Integer> groupIds) {
|
||||
Map<Integer, Integer> result = new HashMap<>();
|
||||
if (groupIds == null || groupIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Integer, SysGroup> groupMap = new HashMap<>();
|
||||
for (SysGroup g : sysGroupRepository.findAll()) {
|
||||
groupMap.put(g.getId(), g);
|
||||
}
|
||||
Integer rootId = groupMap.values().stream()
|
||||
.filter(g -> g.getParentId() != null && g.getParentId() == ROOT_PARENT_ID)
|
||||
.min(Comparator.comparingInt(SysGroup::getId))
|
||||
.map(SysGroup::getId)
|
||||
.orElseGet(() -> resolveRootGroup().getId());
|
||||
for (Integer groupId : groupIds) {
|
||||
result.put(groupId, resolveSchoolIdInMemory(groupId, groupMap, rootId));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 内存版单组 schoolId 解析(学校组优先 → 区域组 → 根节点),与 resolveSchoolId 逐级 findById 版本行为一致 */
|
||||
private Integer resolveSchoolIdInMemory(Integer groupId, Map<Integer, SysGroup> groupMap, Integer rootId) {
|
||||
Integer fallbackAreaId = null;
|
||||
Integer cursorId = groupId;
|
||||
while (cursorId != null && cursorId != ROOT_PARENT_ID) {
|
||||
SysGroup g = groupMap.get(cursorId);
|
||||
if (g == null) {
|
||||
break;
|
||||
}
|
||||
if (g.getType() == GroupTypeEnum.SCHOOL_GROUP) {
|
||||
return g.getId();
|
||||
}
|
||||
// 沿链向上过程中记录最近(首个命中)的区域组;学校组优先于区域组
|
||||
if (fallbackAreaId == null && g.getType() == GroupTypeEnum.AREA_GROUP) {
|
||||
fallbackAreaId = g.getId();
|
||||
}
|
||||
cursorId = g.getParentId();
|
||||
}
|
||||
return fallbackAreaId != null ? fallbackAreaId : rootId;
|
||||
}
|
||||
|
||||
private SysGroupVo convertToVo(SysGroup entity) {
|
||||
SysGroupVo vo = new SysGroupVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
|
||||
@@ -11,19 +11,26 @@ import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
|
||||
import com.yunhai.userbase.enums.UserTypeEnum;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.entity.SysUserAppRoleRel;
|
||||
import com.yunhai.userbase.entity.SysUserCurrentRole;
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||
import com.yunhai.userbase.model.sysuser.BatchImportResult;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserQuery;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserVo;
|
||||
import com.yunhai.userbase.model.sysuser.UserIdentityVo;
|
||||
import com.yunhai.userbase.repository.SysAppRoleRepository;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import com.yunhai.userbase.service.ISysUserService;
|
||||
import com.yunhai.userbase.service.impl.IdentityService;
|
||||
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||
import com.yunhai.userbase.event.DataSyncEvent;
|
||||
import com.yunhai.userbase.common.context.CallerAppContext;
|
||||
import com.yunhai.userbase.model.push.PushInterfaceType;
|
||||
@@ -104,6 +111,18 @@ public class SysUserService implements ISysUserService {
|
||||
@Autowired
|
||||
private SysAppRoleRepository sysAppRoleRepository;
|
||||
|
||||
@Autowired
|
||||
private SysUserRoleRelRepository sysUserRoleRelRepository;
|
||||
|
||||
@Autowired
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
|
||||
@Autowired
|
||||
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
|
||||
|
||||
@Autowired
|
||||
private IdentityService identityService;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@@ -115,7 +134,7 @@ public class SysUserService implements ISysUserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户并写入组关联。
|
||||
* 创建用户并写入组关联与身份。
|
||||
*
|
||||
* @param enforcePasswordStrength 是否强制密码强度校验:
|
||||
* - 单条新增 = true(前端已交互式校验);
|
||||
@@ -143,9 +162,8 @@ public class SysUserService implements ISysUserService {
|
||||
entity.setEmail(request.getEmail());
|
||||
entity.setGender(request.getGender());
|
||||
entity.setProfilePicture(request.getProfilePicture());
|
||||
entity.setRoles(request.getRoles());
|
||||
// 用户类型推导:超级管理员账号固定为 1;其余含 ADMIN(7) 角色 -> 2 学校管理员;默认 0 普通用户
|
||||
entity.setType(resolveUserType(entity.getAccount(), entity.getRoles()));
|
||||
// roles 列停写(见《用户身份-学校维度落地方案》§8):身份改由 sys_user_role_rel 承载,
|
||||
// 新增用户身份仍必填(≥1),经下方 saveSchoolIdentity 落库
|
||||
// disabled 用枚举存储:不传时默认 NORMAL(正常/未禁用)
|
||||
entity.setDisabled(request.getDisabled() != null ? resolveStatus(request.getDisabled()) : StatusEnum.NORMAL);
|
||||
entity.setExpired(request.getExpired() != null ? request.getExpired() : false);
|
||||
@@ -163,6 +181,16 @@ public class SysUserService implements ISysUserService {
|
||||
rel.setCreateTime(LocalDateTime.now());
|
||||
sysUserGroupRelRepository.save(rel);
|
||||
|
||||
// 身份写入:schoolId 未传时按创建目标组解析(前端不感知解析逻辑)
|
||||
Integer schoolId = request.getSchoolId() != null
|
||||
? validateIdentitySchoolId(request.getSchoolId())
|
||||
: sysGroupService.resolveSchoolId(request.getGroupId());
|
||||
saveSchoolIdentity(saved.getId(), schoolId, request.getRoleIds());
|
||||
|
||||
// type 重算时序:先写身份再推导(数据源 = role_rel 正常态并集,超管账号特判保留)
|
||||
saved.setType(resolveUserType(saved.getAccount(), saved.getId()));
|
||||
sysUserRepository.save(saved);
|
||||
|
||||
return saved.getId();
|
||||
}
|
||||
|
||||
@@ -188,11 +216,7 @@ public class SysUserService implements ISysUserService {
|
||||
entity.setEmail(request.getEmail());
|
||||
entity.setGender(request.getGender());
|
||||
entity.setProfilePicture(request.getProfilePicture());
|
||||
entity.setRoles(request.getRoles());
|
||||
// 用户类型推导:仅当本次传入了角色时才重算,避免编辑时未传角色把 type 误重置为 0
|
||||
if (request.getRoles() != null) {
|
||||
entity.setType(resolveUserType(entity.getAccount(), entity.getRoles()));
|
||||
}
|
||||
// roles 列停写(见 §8):身份改由 role_rel 承载,type 一律在身份落库后重算(不再有 roles 守卫)
|
||||
if (request.getDisabled() != null) {
|
||||
entity.setDisabled(resolveStatus(request.getDisabled()));
|
||||
}
|
||||
@@ -202,6 +226,16 @@ public class SysUserService implements ISysUserService {
|
||||
entity.setUpdateUser(SYSTEM_USER_ID);
|
||||
sysUserRepository.save(entity);
|
||||
publishUserEvent(PushInterfaceType.UPDATE_USER, id);
|
||||
|
||||
// 身份按 schoolId 全量覆盖保存(编辑请求总是携带 {schoolId, roleIds},未修改也提交原值);
|
||||
// 未携带 schoolId 的调用(如旧版 oapi edit)跳过身份写库,保持原身份不变
|
||||
if (request.getSchoolId() != null) {
|
||||
saveSchoolIdentity(id, validateIdentitySchoolId(request.getSchoolId()), request.getRoleIds());
|
||||
}
|
||||
|
||||
// type 重算时序:身份落库之后按 role_rel 正常态并集推导(超管账号特判保留)
|
||||
entity.setType(resolveUserType(entity.getAccount(), id));
|
||||
sysUserRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -242,9 +276,11 @@ public class SysUserService implements ISysUserService {
|
||||
for (Integer userId : userIds) {
|
||||
// 删除当前组关联
|
||||
sysUserGroupRelRepository.deleteByUserIdAndGroupId(userId, groupId);
|
||||
// 若用户已无其它组关联,则级联删除用户本身及其角色绑定
|
||||
// 若用户已无其它组关联,则级联删除用户本身、业务角色绑定,并清理身份行与当前激活身份(防幽灵身份/残留切换记录)
|
||||
if (sysUserGroupRelRepository.countByUserId(userId) == 0) {
|
||||
sysUserAppRoleRelRepository.deleteByUserIdBulk(userId);
|
||||
sysUserRoleRelRepository.deleteByUserIdBulk(userId);
|
||||
sysUserCurrentRoleRepository.deleteByUserIdBulk(userId);
|
||||
sysUserRepository.deleteById(userId);
|
||||
}
|
||||
}
|
||||
@@ -383,46 +419,66 @@ public class SysUserService implements ISysUserService {
|
||||
}
|
||||
// 源学校必须真实存在且为学校组(目标学校在 assertGroupWithinSchool 内一并校验)
|
||||
sysGroupService.assertSchoolId(fromSchoolId);
|
||||
if (fromSchoolId.equals(toSchoolId)) {
|
||||
throw new DException("原学校与目标学校不能相同");
|
||||
}
|
||||
if (!sysUserRepository.existsById(userId)) {
|
||||
throw new DException("系统用户不存在");
|
||||
}
|
||||
// 目标组必须属于目标学校子树且不等于学校根组(复用移动/删除的边界校验)
|
||||
assertGroupWithinSchool(groupId, toSchoolId);
|
||||
|
||||
boolean sameSchool = fromSchoolId.equals(toSchoolId);
|
||||
List<Integer> fromScope = sysGroupService.listSelfAndDescendantIds(fromSchoolId);
|
||||
List<Integer> toScope = sysGroupService.listSelfAndDescendantIds(toSchoolId);
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserId(userId);
|
||||
|
||||
// ① 前置:用户在源学校范围必须存在至少一条「转出」状态的组关系
|
||||
boolean hasTransferOut = rels.stream().anyMatch(r ->
|
||||
r.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT && fromScope.contains(r.getGroupId()));
|
||||
if (!hasTransferOut) {
|
||||
// ① 前置:用户在源学校范围必须存在至少一条「转出」状态的组关系(原组由转出标记隐含携带)
|
||||
List<SysUserGroupRel> transferOutRels = rels.stream()
|
||||
.filter(r -> r.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT && fromScope.contains(r.getGroupId()))
|
||||
.collect(Collectors.toList());
|
||||
if (transferOutRels.isEmpty()) {
|
||||
throw new DException("该用户在原学校不存在转出状态的组关系,禁止转入");
|
||||
}
|
||||
// ② 目标学校范围不得已有组关系(转入语义为进入全新学校,避免覆盖歧义)
|
||||
|
||||
List<SysUserGroupRel> toDelete;
|
||||
int newRelIsMain;
|
||||
if (sameSchool) {
|
||||
// 同校转组:仅当原组(源校转出组)与目标组一致才拒绝;否则删除原组关系、新增目标组关系(同校其他组与他校关系保留)
|
||||
boolean sameGroup = transferOutRels.stream().anyMatch(r -> r.getGroupId().equals(groupId));
|
||||
if (sameGroup) {
|
||||
throw new DException("原学校与目标学校相同且目标组与原组相同,请指定其他目标组");
|
||||
}
|
||||
// 目标组不得已有任何关系(任意状态),避免重复/合并歧义
|
||||
boolean existsInTargetGroup = rels.stream().anyMatch(r -> r.getGroupId().equals(groupId));
|
||||
if (existsInTargetGroup) {
|
||||
throw new DException("用户已在该组存在组关系,禁止重复转入");
|
||||
}
|
||||
toDelete = transferOutRels;
|
||||
// 主归属继承原组:原组是主组则目标组为主组,否则维持非主,避免同校场景制造双主组
|
||||
newRelIsMain = transferOutRels.stream().anyMatch(r -> Objects.equals(r.getIsMain(), IS_MAIN)) ? IS_MAIN : 0;
|
||||
} else {
|
||||
// 跨校转入(原逻辑不变):目标学校范围不得已有组关系,删除源校范围全部关系,目标组设为主归属
|
||||
boolean existsInTarget = rels.stream().anyMatch(r -> toScope.contains(r.getGroupId()));
|
||||
if (existsInTarget) {
|
||||
throw new DException("用户已在该学校存在组关系,禁止重复转入");
|
||||
}
|
||||
// ③ 删除源学校范围内全部关系行(仅源学校,其他学校保留)
|
||||
List<SysUserGroupRel> toDelete = rels.stream()
|
||||
toDelete = rels.stream()
|
||||
.filter(r -> fromScope.contains(r.getGroupId()))
|
||||
.collect(Collectors.toList());
|
||||
newRelIsMain = IS_MAIN;
|
||||
}
|
||||
if (!toDelete.isEmpty()) {
|
||||
sysUserGroupRelRepository.deleteAll(toDelete);
|
||||
}
|
||||
// ④ 建立目标学校关系:目标组设为主归属,状态恢复正常(转出流程结束)
|
||||
// 建立目标组关系:状态恢复正常(转出流程结束)
|
||||
SysUserGroupRel rel = new SysUserGroupRel();
|
||||
rel.setUserId(userId);
|
||||
rel.setGroupId(groupId);
|
||||
rel.setIsMain(IS_MAIN);
|
||||
rel.setIsMain(newRelIsMain);
|
||||
rel.setStatus(UserGroupRelStatusEnum.NORMAL);
|
||||
rel.setCreateTime(LocalDateTime.now());
|
||||
sysUserGroupRelRepository.save(rel);
|
||||
// ⑤ 推送编辑用户订阅
|
||||
// 转入/转组后身份即时对齐:源校组关系已物理删除,旧校身份行清出集合(主身份为读时计算,无物化列需重指向)
|
||||
alignIdentity(userId);
|
||||
// 推送编辑用户订阅
|
||||
publishUserEvent(PushInterfaceType.UPDATE_USER, userId);
|
||||
}
|
||||
|
||||
@@ -431,12 +487,47 @@ public class SysUserService implements ISysUserService {
|
||||
// 组上下文(选中具体组):走原生 SQL 按「组内最小 sort_no」排序的专用分页,
|
||||
// 规避 MySQL 的 DISTINCT+ORDER BY 限制以及 Hibernate 不支持 ORDER BY 标量子查询的限制。
|
||||
if (query.getGroupId() != null) {
|
||||
return listByGroup(query, pageable);
|
||||
return attachMainIdentityNames(listByGroup(query, pageable));
|
||||
}
|
||||
// 根节点 / 未选组:用 Specification 过滤,按主键 id 升序(聚簇索引顺序,无 filesort)。
|
||||
Specification<SysUser> spec = buildSpecification(query);
|
||||
Page<SysUser> page = sysUserRepository.findAll(spec, pageable);
|
||||
return page.map(u -> convertToVo(u, null, null));
|
||||
return attachMainIdentityNames(page.map(u -> convertToVo(u, null, null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页批量带出各用户主校身份名(动态解析主身份,无 N+1)。
|
||||
* roles 列已停写(冻结历史值),列表「用户身份」列一律切此新字段展示。
|
||||
*/
|
||||
private Page<SysUserVo> attachMainIdentityNames(Page<SysUserVo> page) {
|
||||
List<SysUserVo> content = page.getContent();
|
||||
if (content.isEmpty()) {
|
||||
return page;
|
||||
}
|
||||
List<Integer> userIds = content.stream()
|
||||
.map(SysUserVo::getId)
|
||||
.collect(Collectors.toList());
|
||||
Map<Integer, SysUserRoleRel> mainRelsByUser = resolveMainRoleRelsByUser(userIds);
|
||||
for (SysUserVo vo : content) {
|
||||
SysUserRoleRel mainRel = mainRelsByUser.get(vo.getId());
|
||||
if (mainRel == null) {
|
||||
continue;
|
||||
}
|
||||
SysRoleEnum e = IEnum.getByKey(SysRoleEnum.class, mainRel.getRoleId());
|
||||
String name = e != null ? e.getValue() : null;
|
||||
if (name != null) {
|
||||
vo.setIdentityNames(name);
|
||||
}
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解析 用户 -> 主身份行(动态解析,每用户至多 1 条)映射。
|
||||
* 供列表页主校身份名({@link #attachMainIdentityNames})与导出「用户身份」列共用。
|
||||
*/
|
||||
private Map<Integer, SysUserRoleRel> resolveMainRoleRelsByUser(List<Integer> userIds) {
|
||||
return identityService.resolveMainIdentityByUser(userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -497,15 +588,142 @@ public class SysUserService implements ISysUserService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ===================== 用户身份(SysRoleEnum × 学校) =====================
|
||||
|
||||
@Override
|
||||
public UserIdentityVo getUserIdentity(Integer userId, Integer groupId) {
|
||||
if (userId == null) {
|
||||
throw new DException("用户ID不能为空");
|
||||
}
|
||||
if (groupId == null) {
|
||||
throw new DException("组ID不能为空");
|
||||
}
|
||||
// 后端按 §3 规则解析 schoolId:同一学校多组返回同一 schoolId 与角色并集
|
||||
Integer schoolId = sysGroupService.resolveSchoolId(groupId);
|
||||
List<Integer> roleIds = sysUserRoleRelRepository
|
||||
.findByUserIdAndSchoolIdAndStatus(userId, schoolId, StatusEnum.NORMAL)
|
||||
.stream()
|
||||
.map(SysUserRoleRel::getRoleId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
UserIdentityVo vo = new UserIdentityVo();
|
||||
vo.setSchoolId(schoolId);
|
||||
vo.setRoleIds(roleIds);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验保存身份的 schoolId:非空且必须是存在的组 id。
|
||||
*/
|
||||
private Integer validateIdentitySchoolId(Integer schoolId) {
|
||||
if (schoolId == null) {
|
||||
throw new DException("学校ID不能为空");
|
||||
}
|
||||
if (!sysGroupRepository.existsById(schoolId)) {
|
||||
throw new DException("学校ID必须为合法组ID");
|
||||
}
|
||||
return schoolId;
|
||||
}
|
||||
|
||||
/** 校验身份角色在 SysRoleEnum 内;非法角色抛异常。 */
|
||||
private void validateRoleIds(List<Integer> roleIds) {
|
||||
if (roleIds == null) {
|
||||
return;
|
||||
}
|
||||
for (Integer roleId : roleIds) {
|
||||
if (roleId == null || IEnum.getByKey(SysRoleEnum.class, roleId) == null) {
|
||||
throw new DException("用户身份不存在:" + roleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 schoolId 全量覆盖保存用户身份:删除该 (userId, schoolId) 全部行 + 按 roleIds 重建
|
||||
* (uk(user_id, role_id, school_id) 防重)。空数组/空列表语义 = 清空该校身份(幂等)。<br>
|
||||
* 保存完成后统一对齐(清理学校集合外身份行 + 激活身份滞留检查)。主身份已去字段化,不再写 is_main。
|
||||
*/
|
||||
private void saveSchoolIdentity(Integer userId, Integer schoolId, List<Integer> roleIds) {
|
||||
validateRoleIds(roleIds);
|
||||
sysUserRoleRelRepository.deleteByUserIdAndSchoolId(userId, schoolId);
|
||||
if (roleIds != null && !roleIds.isEmpty()) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<SysUserRoleRel> rows = new ArrayList<>(roleIds.size());
|
||||
// roleIds 去重,避免 uk 冲突
|
||||
for (Integer roleId : roleIds.stream().filter(Objects::nonNull).distinct()
|
||||
.collect(Collectors.toList())) {
|
||||
SysUserRoleRel rel = new SysUserRoleRel();
|
||||
rel.setUserId(userId);
|
||||
rel.setRoleId(roleId);
|
||||
rel.setSchoolId(schoolId);
|
||||
rel.setSortNo(0);
|
||||
rel.setStatus(StatusEnum.NORMAL);
|
||||
rel.setCreateTime(now);
|
||||
rel.setUpdateTime(now);
|
||||
rows.add(rel);
|
||||
}
|
||||
sysUserRoleRelRepository.saveAll(rows);
|
||||
}
|
||||
alignIdentity(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份统一对齐(保存后与改组三入口 add/edit/changeGroups/transferToSchool 共用):<br>
|
||||
* ① 学校集合推导:全部组关系(含「转出」标记行——软标记期保留原校身份)逐条解析 schoolId 去重;<br>
|
||||
* ② 删除不在集合内的身份行(孤儿清理:退组/转校后身份行同步清出);<br>
|
||||
* ③ 当前激活身份滞留检查:切换记录指向的学校已不在集合(退组/转学)→ 删除该记录,
|
||||
* 避免 getCurrent/boa.school 继续返回旧校(「激活身份不滞留旧校」)。<br>
|
||||
* 主身份已去字段化(读时计算),此处不再维护 is_main 物化列。
|
||||
*/
|
||||
private void alignIdentity(Integer userId) {
|
||||
Set<Integer> schoolSet = deriveSchoolSet(userId);
|
||||
|
||||
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserId(userId);
|
||||
List<SysUserRoleRel> remaining = new ArrayList<>(rels.size());
|
||||
for (SysUserRoleRel rel : rels) {
|
||||
if (schoolSet.contains(rel.getSchoolId())) {
|
||||
remaining.add(rel);
|
||||
} else {
|
||||
sysUserRoleRelRepository.delete(rel);
|
||||
}
|
||||
}
|
||||
if (!remaining.isEmpty()) {
|
||||
sysUserRoleRelRepository.saveAll(remaining);
|
||||
}
|
||||
|
||||
// 激活身份滞留检查:切换记录指向的学校已不在集合 → 删除(schoolId 为 null 的旧约定行不在此列)
|
||||
sysUserCurrentRoleRepository.findByUserId(userId).ifPresent(current -> {
|
||||
if (current.getSchoolId() != null && !schoolSet.contains(current.getSchoolId())) {
|
||||
sysUserCurrentRoleRepository.delete(current);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校集合推导(身份对齐/孤儿清理的数据基准):取该用户全部组关系逐条按 §3 解析 schoolId 去重。<br>
|
||||
* 不过滤 {@code sys_user_group_rel.status}:「转出」仅是流程标记,转入完成(transferToSchool
|
||||
* 物理删除源校关系)后才视为真正离开;标记期内保留原校身份与业务现状一致。
|
||||
*/
|
||||
private Set<Integer> deriveSchoolSet(Integer userId) {
|
||||
Set<Integer> set = new HashSet<>();
|
||||
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) {
|
||||
set.add(sysGroupService.resolveSchoolId(rel.getGroupId()));
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void changeGroups(List<Integer> userIds, List<Integer> groupIds) {
|
||||
public void changeGroups(List<Integer> userIds, List<Integer> groupIds, Integer mainGroupId) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
throw new DException("待调整用户不能为空");
|
||||
}
|
||||
if (groupIds == null || groupIds.isEmpty()) {
|
||||
throw new DException("请至少保留一个组");
|
||||
}
|
||||
// 显式主组校验:必须落在目标组列表中(否则 400)
|
||||
if (mainGroupId != null && !groupIds.contains(mainGroupId)) {
|
||||
throw new DException("主归属组必须在目标组列表中");
|
||||
}
|
||||
|
||||
List<SysUser> users = sysUserRepository.findAllById(userIds);
|
||||
if (users.size() != userIds.size()) {
|
||||
@@ -518,7 +736,7 @@ public class SysUserService implements ISysUserService {
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (Integer userId : userIds) {
|
||||
// 覆盖式重建前先读旧关系,用于继承组内状态(转出标记):
|
||||
// 覆盖式重建前先读旧关系,用于继承组内状态(转出标记)与旧主组解析:
|
||||
// 行级继承——同组照抄旧状态(管理端重设组不误改);用户级继承——新增组在用户处于转出流程中(存在任一转出行)时继承「转出」
|
||||
List<SysUserGroupRel> oldRels = sysUserGroupRelRepository.findByUserId(userId);
|
||||
Map<Integer, UserGroupRelStatusEnum> oldStatusByGroup = new HashMap<>();
|
||||
@@ -529,14 +747,16 @@ public class SysUserService implements ISysUserService {
|
||||
inTransferFlow = true;
|
||||
}
|
||||
}
|
||||
// 主归属组三级降级:① 显式 mainGroupId → ② 旧主组 ∈ 新集合沿用 → ③ groupIds 首项
|
||||
Integer effectiveMainGroupId = decideEffectiveMainGroupId(mainGroupId, groupIds,
|
||||
mainGroupResolver.resolve(oldRels));
|
||||
sysUserGroupRelRepository.deleteByUserId(userId);
|
||||
List<SysUserGroupRel> rels = new ArrayList<>(groupIds.size());
|
||||
for (int i = 0; i < groupIds.size(); i++) {
|
||||
Integer groupId = groupIds.get(i);
|
||||
for (Integer groupId : groupIds) {
|
||||
SysUserGroupRel rel = new SysUserGroupRel();
|
||||
rel.setUserId(userId);
|
||||
rel.setGroupId(groupId);
|
||||
rel.setIsMain(i == 0 ? IS_MAIN : 0);
|
||||
rel.setIsMain(Objects.equals(groupId, effectiveMainGroupId) ? IS_MAIN : 0);
|
||||
rel.setCreateTime(now);
|
||||
rel.setStatus(oldStatusByGroup.containsKey(groupId)
|
||||
? oldStatusByGroup.get(groupId)
|
||||
@@ -544,10 +764,30 @@ public class SysUserService implements ISysUserService {
|
||||
rels.add(rel);
|
||||
}
|
||||
sysUserGroupRelRepository.saveAll(rels);
|
||||
// 改组后身份即时对齐:学校集合变化可能清掉旧校身份行,主校变更后动态主身份随之切换,
|
||||
// 消除「组已变更而激活身份仍指旧校」窗口期(跨校转学场景依赖此兜底)
|
||||
alignIdentity(userId);
|
||||
}
|
||||
publishUserEvent(PushInterfaceType.UPDATE_USER, userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主归属组三级降级决策(纯函数,便于单测):<br>
|
||||
* ① explicitMainGroupId 非空 → 直接返回(调用方已校验 ∈ groupIds);<br>
|
||||
* ② oldMainRel 非空且其组 ∈ groupIds → 沿用旧主组(只要用户没把主组勾掉,主组不动);<br>
|
||||
* ③ 否则 → groupIds 首项(现状行为兜底)。<br>
|
||||
* groupIds 非空由调用方保证。
|
||||
*/
|
||||
static Integer decideEffectiveMainGroupId(Integer explicitMainGroupId, List<Integer> groupIds, SysUserGroupRel oldMainRel) {
|
||||
if (explicitMainGroupId != null) {
|
||||
return explicitMainGroupId;
|
||||
}
|
||||
if (oldMainRel != null && groupIds.contains(oldMainRel.getGroupId())) {
|
||||
return oldMainRel.getGroupId();
|
||||
}
|
||||
return groupIds.get(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void moveUserWithinSchool(Integer userId, Integer groupId, Integer schoolId) {
|
||||
@@ -580,8 +820,8 @@ public class SysUserService implements ISysUserService {
|
||||
throw new DException("目标组不属于该学校范围,禁止跨校移动");
|
||||
}
|
||||
|
||||
// 复用覆盖式改组逻辑(目标设为主组)
|
||||
changeGroups(Collections.singletonList(userId), Collections.singletonList(groupId));
|
||||
// 复用覆盖式改组逻辑(单组场景:目标组即主组)
|
||||
changeGroups(Collections.singletonList(userId), Collections.singletonList(groupId), groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -734,6 +974,9 @@ public class SysUserService implements ISysUserService {
|
||||
// 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列
|
||||
Map<Integer, Integer> userMainGroup = resolveUserMainGroup(userIds);
|
||||
|
||||
// 2.1 批量取每个用户的主校身份行(动态解析主身份),用于填充「用户身份」列(roles 列已停写,不再作为真源)
|
||||
Map<Integer, SysUserRoleRel> mainRelsByUser = resolveMainRoleRelsByUser(userIds);
|
||||
|
||||
// 3. 批量取组名
|
||||
Map<Integer, String> groupNameMap = sysGroupRepository.findAllById(userMainGroup.values())
|
||||
.stream().collect(Collectors.toMap(SysGroup::getId, SysGroup::getName));
|
||||
@@ -752,7 +995,8 @@ public class SysUserService implements ISysUserService {
|
||||
row.setPhone(u.getPhone());
|
||||
row.setEmail(u.getEmail());
|
||||
row.setNature(u.getType() == null ? null : u.getType().getKey());
|
||||
row.setIdentity(u.getRoles() == null || u.getRoles().isEmpty() ? null : u.getRoles().get(0));
|
||||
SysUserRoleRel mainRel = mainRelsByUser.get(u.getId());
|
||||
row.setIdentity(mainRel == null ? null : mainRel.getRoleId());
|
||||
row.setGender(u.getGender());
|
||||
row.setBirthday(u.getBirthday() == null ? "" : u.getBirthday().toString());
|
||||
row.setUserId(u.getId());
|
||||
@@ -767,24 +1011,15 @@ public class SysUserService implements ISysUserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解析 用户 -> 主组(groupId) 映射:优先取 isMain=1 的组,否则取第一个关联组。
|
||||
* 批量解析 用户 -> 主组(groupId) 映射:isMain=1 取 id 最小,无主兜底 id 最小(统一走 MainGroupResolver)。
|
||||
*/
|
||||
private Map<Integer, Integer> resolveUserMainGroup(List<Integer> userIds) {
|
||||
Map<Integer, Integer> mainGroup = new HashMap<>();
|
||||
if (userIds.isEmpty()) {
|
||||
return mainGroup;
|
||||
}
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserIdIn(userIds);
|
||||
Map<Integer, List<SysUserGroupRel>> byUser = rels.stream()
|
||||
.collect(Collectors.groupingBy(SysUserGroupRel::getUserId));
|
||||
for (Map.Entry<Integer, List<SysUserGroupRel>> e : byUser.entrySet()) {
|
||||
Integer gid = e.getValue().stream()
|
||||
.filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN)
|
||||
.map(SysUserGroupRel::getGroupId)
|
||||
.findFirst()
|
||||
.orElse(e.getValue().get(0).getGroupId());
|
||||
mainGroup.put(e.getKey(), gid);
|
||||
}
|
||||
mainGroupResolver.resolveByUserIds(userIds)
|
||||
.forEach((userId, rel) -> mainGroup.put(userId, rel.getGroupId()));
|
||||
return mainGroup;
|
||||
}
|
||||
|
||||
@@ -910,43 +1145,47 @@ public class SysUserService implements ISysUserService {
|
||||
if (userId == null) {
|
||||
throw new DException("用户ID不能为空");
|
||||
}
|
||||
List<BoaRoleInfo> result = new ArrayList<>();
|
||||
// 主归属组(学校/部门)上下文:schId/schName
|
||||
Integer mainGroupId = null;
|
||||
String mainGroupName = null;
|
||||
List<SysUserGroupRel> groupRels = sysUserGroupRelRepository.findByUserId(userId);
|
||||
for (SysUserGroupRel gr : groupRels) {
|
||||
if (gr.getIsMain() != null && gr.getIsMain() == IS_MAIN) {
|
||||
mainGroupId = gr.getGroupId();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mainGroupId != null) {
|
||||
SysGroup mainGroup = sysGroupRepository.findById(mainGroupId).orElse(null);
|
||||
if (mainGroup != null) {
|
||||
mainGroupName = mainGroup.getName();
|
||||
}
|
||||
}
|
||||
// 业务角色:用户-角色关系 → 角色名称
|
||||
List<SysUserAppRoleRel> rels = sysUserAppRoleRelRepository.findByUserId(userId);
|
||||
for (SysUserAppRoleRel rel : rels) {
|
||||
if (rel.getRoleId() == null) {
|
||||
continue;
|
||||
}
|
||||
SysAppRole role = sysAppRoleRepository.findById(rel.getRoleId()).orElse(null);
|
||||
if (role == null) {
|
||||
continue;
|
||||
}
|
||||
// 数据源:sys_user_role_rel(平台身份角色权威源),内存排序 主身份优先 → sortNo → id
|
||||
// (修正:此前误读 sys_user_app_role_rel 业务权限角色 + 主归属组当学校,导致「本组」bug)
|
||||
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
||||
SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId);
|
||||
List<SysUserRoleRel> sorted = rels.stream()
|
||||
.sorted(Comparator.comparingInt((SysUserRoleRel r) ->
|
||||
mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1)
|
||||
.thenComparingInt(SysUserRoleRel::getSortNo)
|
||||
.thenComparingInt(SysUserRoleRel::getId))
|
||||
.collect(Collectors.toList());
|
||||
List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
|
||||
for (SysUserRoleRel rel : sorted) {
|
||||
BoaRoleInfo info = new BoaRoleInfo();
|
||||
info.setRoleId(role.getId());
|
||||
info.setRoleName(role.getName());
|
||||
info.setSchId(mainGroupId);
|
||||
info.setSchName(mainGroupName);
|
||||
info.setRoleId(rel.getRoleId());
|
||||
info.setRoleName(roleName(rel.getRoleId()));
|
||||
info.setSchId(rel.getSchoolId());
|
||||
info.setSchName(groupName(rel.getSchoolId()));
|
||||
// childId/childName 家长-孩子维度本期不做,恒空
|
||||
result.add(info);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 由角色 id 反查角色名(SysRoleEnum.value);未知角色返回 null。 */
|
||||
private String roleName(Integer roleId) {
|
||||
if (roleId == null) {
|
||||
return null;
|
||||
}
|
||||
SysRoleEnum e = IEnum.getByKey(SysRoleEnum.class, roleId);
|
||||
return e != null ? e.getValue() : null;
|
||||
}
|
||||
|
||||
/** 由组 id 反查组名;未知/非正 id 返回 null。 */
|
||||
private String groupName(Integer groupId) {
|
||||
if (groupId == null || groupId <= 0) {
|
||||
return null;
|
||||
}
|
||||
SysGroup g = sysGroupRepository.findById(groupId).orElse(null);
|
||||
return g != null ? g.getName() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void changePassword(Integer userId, String oldPassword, String newPassword) {
|
||||
@@ -1011,14 +1250,17 @@ public class SysUserService implements ISysUserService {
|
||||
/**
|
||||
* 推导用户类型(见 {@link UserTypeEnum}):
|
||||
* - 内置超级管理员账号 -> 1 超级管理员;
|
||||
* - 其余账号若拥有 ADMIN(7) 角色 -> 2 学校管理员;
|
||||
* - 其余账号 sys_user_role_rel 正常态 roleId 并集含 ADMIN(7) -> 2 学校管理员;
|
||||
* - 其他 -> 0 普通用户。
|
||||
* 数据源为 role_rel(sys_user.roles 列已停写),须在身份落库之后调用。
|
||||
*/
|
||||
private UserTypeEnum resolveUserType(String account, List<Integer> roles) {
|
||||
private UserTypeEnum resolveUserType(String account, Integer userId) {
|
||||
if (account != null && SUPER_ADMIN_ACCOUNTS.contains(account)) {
|
||||
return UserTypeEnum.SUPER_ADMIN;
|
||||
}
|
||||
if (roles != null && roles.contains(SysRoleEnum.ADMIN.getKey())) {
|
||||
List<Integer> roleIds = sysUserRoleRelRepository
|
||||
.findDistinctRoleIdsByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
||||
if (roleIds != null && roleIds.contains(SysRoleEnum.ADMIN.getKey())) {
|
||||
return UserTypeEnum.SCHOOL_ADMIN;
|
||||
}
|
||||
return UserTypeEnum.NORMAL;
|
||||
@@ -1090,7 +1332,8 @@ public class SysUserService implements ISysUserService {
|
||||
|
||||
Integer nature = parseInteger(getCellString(row, IDX_NATURE));
|
||||
Integer identity = parseInteger(getCellString(row, IDX_IDENTITY));
|
||||
request.setRoles(buildImportRoles(nature, identity));
|
||||
// 身份列改走身份写入:createUser 按导入目标组解析 schoolId 落 sys_user_role_rel(roles 列停写)
|
||||
request.setRoleIds(buildImportRoles(nature, identity));
|
||||
|
||||
request.setGender(parseGender(getCellString(row, IDX_GENDER)));
|
||||
request.setBirthday(parseBirthday(row.getCell(IDX_BIRTHDAY)));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.enums.IEnum;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
@@ -9,11 +11,14 @@ import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.enums.SysRoleEnum;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
|
||||
import com.yunhai.userbase.service.impl.IdentityService;
|
||||
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -23,6 +28,7 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户中心核心服务:把 UserBase 自身的 sys_user / sys_group 组装成基础平台统一的
|
||||
@@ -47,6 +53,10 @@ public class UserCenterService {
|
||||
private SysUserRoleRelRepository sysUserRoleRelRepository;
|
||||
@Autowired
|
||||
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
|
||||
@Autowired
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
@Autowired
|
||||
private IdentityService identityService;
|
||||
|
||||
public BoaUser getUserInfoWithDeptByAccount(String account) {
|
||||
if (account == null) {
|
||||
@@ -101,7 +111,7 @@ public class UserCenterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装用户组织链:取主归属组(isMain=1,否则首个关联),沿 parentId 向上回溯到根,
|
||||
* 组装用户组织链:取主归属组(isMain=1 取 id 最小,无主兜底 id 最小),沿 parentId 向上回溯到根,
|
||||
* 顺序为「主组 -> ... -> 根方向」(近 -> 远)。
|
||||
*/
|
||||
private List<BoaGroup> buildGroups(Integer userId) {
|
||||
@@ -109,11 +119,8 @@ public class UserCenterService {
|
||||
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();
|
||||
SysUserGroupRel mainRel = mainGroupResolver.resolve(rels);
|
||||
Integer groupId = mainRel != null ? mainRel.getGroupId() : null;
|
||||
if (groupId == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
@@ -161,23 +168,29 @@ public class UserCenterService {
|
||||
/**
|
||||
* 从身份关联推导当前激活身份与可选学校。school 字段语义=当前激活学校:<br>
|
||||
* ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);<br>
|
||||
* ② 无切换记录时兜底取 {@code sys_user_role_rel.is_main=1} 的整条记录(保证是合法存在的身份,
|
||||
* 避免「主组+主角色」组合出 B 学校老师的非法身份);<br>
|
||||
* ③ schools:用户在全部学校下的可选身份去重 schoolId(>0)集合。
|
||||
* ② 无切换记录时兜底取主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行,
|
||||
* 保证是合法存在的身份,避免「主组+主角色」组合出 B 学校老师的非法身份);<br>
|
||||
* ③ currentRoleId:优先当前激活记录 roleId,兜底主身份行 roleId(本期补码,此前从未填充);<br>
|
||||
* ④ schools:用户在全部学校下的可选身份去重 schoolId(>0)集合组装 BoaGroup 列表(本期补码)。<br>
|
||||
* school 类型守卫:仅当身份 schoolId 对应组类型=SCHOOL_GROUP 时回填 boa.school,
|
||||
* 区域组/根直属用户保持 null——等价旧约定「填 0 不回填」的行为,避免子应用读到 school=区域组 对象。
|
||||
*/
|
||||
private void deriveIdentity(Integer userId, BoaUser boa) {
|
||||
Integer currentRoleId = null;
|
||||
Integer currentSchoolId = null;
|
||||
String currentSchoolName = null;
|
||||
|
||||
SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null);
|
||||
if (current != null && current.getRoleId() != null) {
|
||||
currentRoleId = current.getRoleId();
|
||||
currentSchoolId = current.getSchoolId();
|
||||
currentSchoolName = current.getSchoolName();
|
||||
} else {
|
||||
// 兜底:直接取主身份整条记录(isMain=1),而不是「主组+主角色」组合
|
||||
SysUserRoleRel mainRel = sysUserRoleRelRepository
|
||||
.findByUserIdAndIsMain(userId, 1).orElse(null);
|
||||
// 兜底:主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行),
|
||||
// 而不是「主组+主角色」组合(避免组合出 B 学校老师的非法身份)
|
||||
SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId);
|
||||
if (mainRel != null) {
|
||||
currentRoleId = mainRel.getRoleId();
|
||||
currentSchoolId = mainRel.getSchoolId();
|
||||
if (currentSchoolId != null && currentSchoolId > 0) {
|
||||
SysGroup sch = sysGroupRepository.findById(currentSchoolId).orElse(null);
|
||||
@@ -186,9 +199,43 @@ public class UserCenterService {
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSchoolId != null && currentSchoolId > 0) {
|
||||
// 当前激活学校:仅当对应组为学校组时回填(区域组/根直属保持 null)
|
||||
if (currentSchoolId != null && currentSchoolId > 0 && isSchoolGroup(currentSchoolId)) {
|
||||
boa.setSchool(toBoaGroup(currentSchoolId, currentSchoolName));
|
||||
}
|
||||
// 当前激活身份(优先切换记录,兜底主身份)——组装为 BoaRoleInfo 富对象(childId/childName 本期不做,恒空)
|
||||
BoaRoleInfo currentRole = new BoaRoleInfo();
|
||||
currentRole.setRoleId(currentRoleId);
|
||||
currentRole.setRoleName(resolveRoleName(currentRoleId));
|
||||
currentRole.setSchId(currentSchoolId);
|
||||
currentRole.setSchName(currentSchoolName);
|
||||
boa.setCurrentRole(currentRole);
|
||||
|
||||
// 可选学校集合:全部正常态身份行的 schoolId 去重(>0 过滤,恒为真实组 id)
|
||||
List<Integer> schoolIds = sysUserRoleRelRepository
|
||||
.findDistinctSchoolIdsByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
||||
if (schoolIds != null && !schoolIds.isEmpty()) {
|
||||
List<BoaGroup> schools = schoolIds.stream()
|
||||
.filter(sid -> sid != null && sid > 0)
|
||||
.map(sid -> toBoaGroup(sid, null))
|
||||
.collect(Collectors.toList());
|
||||
boa.setSchools(schools);
|
||||
}
|
||||
}
|
||||
|
||||
/** 该组是否为学校组(SCHOOL_GROUP);组不存在视为 false。 */
|
||||
private boolean isSchoolGroup(Integer groupId) {
|
||||
SysGroup g = sysGroupRepository.findById(groupId).orElse(null);
|
||||
return g != null && g.getType() == GroupTypeEnum.SCHOOL_GROUP;
|
||||
}
|
||||
|
||||
/** 由角色 id 反查角色名(SysRoleEnum.value);未知角色返回 null。 */
|
||||
private String resolveRoleName(Integer roleId) {
|
||||
if (roleId == null) {
|
||||
return null;
|
||||
}
|
||||
SysRoleEnum role = IEnum.getByKey(SysRoleEnum.class, roleId);
|
||||
return role != null ? role.getValue() : null;
|
||||
}
|
||||
|
||||
/** 组装一个 BoaGroup(仅填充 id 与 name;name 为 null 时回源查 sys_group) */
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.yunhai.userbase.model.push.dto.PushGroupDTO;
|
||||
import com.yunhai.userbase.model.push.dto.PushUserDTO;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -33,7 +34,6 @@ import java.util.stream.Collectors;
|
||||
public class PushPayloadAssembler {
|
||||
|
||||
private static final int ROOT_PARENT_ID = 0;
|
||||
private static final int IS_MAIN = 1;
|
||||
|
||||
@Autowired
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
@@ -41,6 +41,8 @@ public class PushPayloadAssembler {
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
@Autowired
|
||||
private GroupScopeHelper groupScopeHelper;
|
||||
@Autowired
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
|
||||
// ===================== 组 =====================
|
||||
|
||||
@@ -135,17 +137,13 @@ public class PushPayloadAssembler {
|
||||
return users.stream().map(this::toUserDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** 取用户主组(isMain=1,否则首个关联)所属学校范围 id。 */
|
||||
/** 取用户主组(isMain=1 取 id 最小,无主兜底 id 最小)所属学校范围 id。 */
|
||||
private Integer resolveMainSchoolId(List<SysUserGroupRel> rels) {
|
||||
if (rels == null || rels.isEmpty()) {
|
||||
SysUserGroupRel mainRel = mainGroupResolver.resolve(rels);
|
||||
if (mainRel == null) {
|
||||
return null;
|
||||
}
|
||||
Integer mainGroupId = rels.stream()
|
||||
.filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN)
|
||||
.map(SysUserGroupRel::getGroupId)
|
||||
.findFirst()
|
||||
.orElse(rels.get(0).getGroupId());
|
||||
return groupScopeHelper.resolveSchoolId(mainGroupId);
|
||||
return groupScopeHelper.resolveSchoolId(mainRel.getGroupId());
|
||||
}
|
||||
|
||||
// ===================== 公共工具 =====================
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.yunhai.userbase.service.resolve;
|
||||
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 主归属组统一解析器:全项目唯一入口,兜底语义固定为
|
||||
* 「isMain=1 取 id 最小;无 isMain=1 取 id 最小」,消除各处「第一条」顺序依赖。<br>
|
||||
* 纯解析 + 单仓储依赖,不解析 schoolId(避免与 SysGroupService 循环依赖)。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/27
|
||||
*/
|
||||
@Component
|
||||
public class MainGroupResolver {
|
||||
|
||||
/** 主归属标识:1=本职主学校/部门(与 sys_user_group_rel.is_main 约定一致) */
|
||||
private static final int IS_MAIN = 1;
|
||||
|
||||
@Autowired
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
|
||||
/**
|
||||
* 已持有组关系列表时的纯函数解析(零查询),供已查列表的调用方。<br>
|
||||
* 契约:
|
||||
* <ol>
|
||||
* <li>rels 为 null/空 → null;</li>
|
||||
* <li>存在 isMain=1 → 取其中 id 最小的一条(脏数据多主时行为确定);</li>
|
||||
* <li>无 isMain=1 → 取 id 最小的一条(数据异常兜底)。</li>
|
||||
* </ol>
|
||||
* groupId 为 null 的关系行视为脏数据,不参与选择(避免下游 NPE)。
|
||||
*/
|
||||
public SysUserGroupRel resolve(List<SysUserGroupRel> rels) {
|
||||
if (rels == null || rels.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<SysUserGroupRel> valid = rels.stream()
|
||||
.filter(r -> r.getGroupId() != null)
|
||||
.collect(Collectors.toList());
|
||||
if (valid.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<SysUserGroupRel> mains = valid.stream()
|
||||
.filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN)
|
||||
.collect(Collectors.toList());
|
||||
List<SysUserGroupRel> candidates = mains.isEmpty() ? valid : mains;
|
||||
return candidates.stream()
|
||||
.min(Comparator.comparingInt(r -> r.getId() == null ? Integer.MAX_VALUE : r.getId()))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** 单用户解析:无组关系返回 Optional.empty() */
|
||||
public Optional<SysUserGroupRel> resolve(Integer userId) {
|
||||
if (userId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(resolve(sysUserGroupRelRepository.findByUserId(userId)));
|
||||
}
|
||||
|
||||
/** 批量解析(一次 IN 查询):searchUsers / 列表 / 导出用,无 N+1;无组关系的用户不出现在结果中 */
|
||||
public Map<Integer, SysUserGroupRel> resolveByUserIds(List<Integer> userIds) {
|
||||
Map<Integer, SysUserGroupRel> result = new HashMap<>();
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserIdIn(userIds);
|
||||
Map<Integer, List<SysUserGroupRel>> byUser = rels.stream()
|
||||
.filter(r -> r.getUserId() != null)
|
||||
.collect(Collectors.groupingBy(SysUserGroupRel::getUserId));
|
||||
for (Map.Entry<Integer, List<SysUserGroupRel>> e : byUser.entrySet()) {
|
||||
SysUserGroupRel main = resolve(e.getValue());
|
||||
if (main != null) {
|
||||
result.put(e.getKey(), main);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.yunhai.userbase.task;
|
||||
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||
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.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 存量身份一次性回填:把 {@code sys_user.roles}(旧机制 JSON 列)迁移到 {@code sys_user_role_rel}
|
||||
* (用户身份-学校三元关联权威表)。见《用户身份-学校维度落地方案》§8。<br><br>
|
||||
*
|
||||
* 规则:遍历 sys_user,解析 roles 数组 → 取主归属组(MainGroupResolver 统一语义)按 schoolId 解析规则
|
||||
* 得到身份归属 → 写入 role_rel(roles 全集)。主身份已去字段化(读时计算),不再写 is_main。<br>
|
||||
* 非主学校身份留空,由后续编辑按校补充;无组关系用户跳过并计数(理论上不存在:退组即删户)。<br><br>
|
||||
*
|
||||
* 启用方式(默认关闭,勿写进配置文件):<br>
|
||||
* {@code java -jar userbase.jar --userbase.identity-backfill.enabled=true}<br>
|
||||
* 执行顺序:先测试环境执行并核对日志中主组与身份归属一致,再上生产执行。幂等:已有 role_rel 行的用户跳过。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/27
|
||||
*/
|
||||
@Component
|
||||
public class IdentityBackfillTask implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IdentityBackfillTask.class);
|
||||
|
||||
@Autowired
|
||||
private SysUserRepository sysUserRepository;
|
||||
@Autowired
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
@Autowired
|
||||
private SysUserRoleRelRepository sysUserRoleRelRepository;
|
||||
@Autowired
|
||||
private ISysGroupService sysGroupService;
|
||||
@Autowired
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${userbase.identity-backfill.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
log.info("[IdentityBackfill] 身份回填开始(sys_user.roles -> sys_user_role_rel)");
|
||||
List<SysUser> users = sysUserRepository.findAll();
|
||||
int inserted = 0;
|
||||
int skippedNoRoles = 0;
|
||||
int skippedNoGroup = 0;
|
||||
int skippedExisting = 0;
|
||||
|
||||
// 已有身份行的用户集合(幂等:重复执行不覆盖)
|
||||
List<Integer> existingUserIds = sysUserRoleRelRepository.findAll().stream()
|
||||
.map(SysUserRoleRel::getUserId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
java.util.Set<Integer> existingSet = new java.util.HashSet<>(existingUserIds);
|
||||
|
||||
// 主组名批量取,用于核对日志
|
||||
Map<Integer, String> groupNameMap = java.util.Collections.emptyMap();
|
||||
com.yunhai.platform.utils.model.BoaGroup groups = sysGroupService.getGroups();
|
||||
if (groups != null) {
|
||||
groupNameMap = flattenGroupNames(groups);
|
||||
}
|
||||
|
||||
for (SysUser user : users) {
|
||||
List<Integer> roles = user.getRoles();
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
skippedNoRoles++;
|
||||
continue;
|
||||
}
|
||||
if (existingSet.contains(user.getId())) {
|
||||
skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
List<SysUserGroupRel> groupRels = sysUserGroupRelRepository.findByUserId(user.getId());
|
||||
if (groupRels.isEmpty()) {
|
||||
skippedNoGroup++;
|
||||
log.warn("[IdentityBackfill] 用户 {}({})无组关系,跳过(异常数据,正常退组即删户)",
|
||||
user.getAccount(), user.getId());
|
||||
continue;
|
||||
}
|
||||
// 主归属组:isMain=1 取 id 最小,无主兜底 id 最小(统一走 MainGroupResolver)
|
||||
SysUserGroupRel mainRel = mainGroupResolver.resolve(groupRels);
|
||||
if (mainRel == null) {
|
||||
skippedNoGroup++;
|
||||
log.warn("[IdentityBackfill] 用户 {}({})组关系均无有效 groupId,跳过(异常数据)",
|
||||
user.getAccount(), user.getId());
|
||||
continue;
|
||||
}
|
||||
Integer schoolId = sysGroupService.resolveSchoolId(mainRel.getGroupId());
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<SysUserRoleRel> rows = new ArrayList<>(roles.size());
|
||||
for (Integer roleId : roles) {
|
||||
if (roleId == null) {
|
||||
continue;
|
||||
}
|
||||
SysUserRoleRel rel = new SysUserRoleRel();
|
||||
rel.setUserId(user.getId());
|
||||
rel.setRoleId(roleId);
|
||||
rel.setSchoolId(schoolId);
|
||||
rel.setSortNo(0);
|
||||
rel.setStatus(StatusEnum.NORMAL);
|
||||
rel.setCreateTime(now);
|
||||
rel.setUpdateTime(now);
|
||||
rows.add(rel);
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
skippedNoRoles++;
|
||||
continue;
|
||||
}
|
||||
sysUserRoleRelRepository.saveAll(rows);
|
||||
inserted++;
|
||||
log.info("[IdentityBackfill] 用户 {}({})→ schoolId={}(主组 {}「{}」)roles={}",
|
||||
user.getAccount(), user.getId(), schoolId, mainRel.getGroupId(),
|
||||
groupNameMap.getOrDefault(mainRel.getGroupId(), "?"), roles);
|
||||
}
|
||||
log.info("[IdentityBackfill] 回填完成:写入 {} 人,跳过(roles 为空){} 人,跳过(无组关系){} 人,"
|
||||
+ "跳过(已有身份行){} 人。请核对上方日志主组与身份归属一致。",
|
||||
inserted, skippedNoRoles, skippedNoGroup, skippedExisting);
|
||||
}
|
||||
|
||||
/** 组树拍平为 id -> name(仅用于核对日志,不含路径)。 */
|
||||
private Map<Integer, String> flattenGroupNames(com.yunhai.platform.utils.model.BoaGroup root) {
|
||||
Map<Integer, String> map = new java.util.HashMap<>();
|
||||
java.util.ArrayDeque<com.yunhai.platform.utils.model.BoaGroup> queue =
|
||||
new java.util.ArrayDeque<>();
|
||||
queue.add(root);
|
||||
while (!queue.isEmpty()) {
|
||||
com.yunhai.platform.utils.model.BoaGroup g = queue.poll();
|
||||
if (g.getId() != null) {
|
||||
map.put(g.getId(), g.getName());
|
||||
}
|
||||
if (g.getChildren() != null) {
|
||||
queue.addAll(g.getChildren());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.yunhai.userbase.task;
|
||||
|
||||
import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||
import com.yunhai.userbase.service.impl.IdentityService;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 主身份一致性对账(P3 阶段 2):对比「sys_user_role_rel.is_main 物化旧值」与「动态解析新值」,
|
||||
* 差异清零(或逐条人工确认)后才允许下版本执行 DROP COLUMN is_main。<br>
|
||||
* 实体与仓储已删 is_main(去字段化),本任务用 JdbcTemplate 原生 SQL 直读遗留列(阶段 1-2 列仍在)。<br><br>
|
||||
*
|
||||
* 启用方式(默认关闭,勿写进配置文件):<br>
|
||||
* {@code java -jar userbase.jar --userbase.main-identity-check.enabled=true}<br>
|
||||
* 执行顺序:先测试环境执行并清零/确认差异,再上生产;生产差异清零后才排期删列。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/27
|
||||
*/
|
||||
@Component
|
||||
public class MainIdentityConsistencyCheckTask implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MainIdentityConsistencyCheckTask.class);
|
||||
|
||||
/** 每批对账用户数(内存友好) */
|
||||
private static final int PAGE_SIZE = 50;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
@Autowired
|
||||
private IdentityService identityService;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${userbase.main-identity-check.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
log.info("[MainIdentityCheck] 主身份对账开始(物化 is_main 列 vs 动态解析)");
|
||||
List<Integer> userIds = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT user_id FROM sys_user_role_rel", Integer.class);
|
||||
log.info("[MainIdentityCheck] 有身份行的用户 {} 人", userIds.size());
|
||||
|
||||
int totalMismatch = 0;
|
||||
int oldMainCount = 0;
|
||||
int dynMainCount = 0;
|
||||
for (int from = 0; from < userIds.size(); from += PAGE_SIZE) {
|
||||
List<Integer> page = userIds.subList(from, Math.min(from + PAGE_SIZE, userIds.size()));
|
||||
Map<Integer, Integer[]> oldMains = queryOldMain(page);
|
||||
Map<Integer, SysUserRoleRel> dynMains = identityService.resolveMainIdentityByUser(page);
|
||||
oldMainCount += oldMains.size();
|
||||
dynMainCount += dynMains.size();
|
||||
|
||||
for (Integer userId : page) {
|
||||
Integer[] old = oldMains.get(userId);
|
||||
SysUserRoleRel dyn = dynMains.get(userId);
|
||||
if (old == null && dyn == null) {
|
||||
continue;
|
||||
}
|
||||
if (old == null) {
|
||||
totalMismatch++;
|
||||
log.warn("[MainIdentityCheck] 用户 {}:物化无主,动态有主 dyn(roleId={}, schoolId={})",
|
||||
userId, dyn.getRoleId(), dyn.getSchoolId());
|
||||
} else if (dyn == null) {
|
||||
totalMismatch++;
|
||||
log.warn("[MainIdentityCheck] 用户 {}:物化有主 old(roleId={}, schoolId={}),动态无主",
|
||||
userId, old[0], old[1]);
|
||||
} else if (!Objects.equals(old[0], dyn.getRoleId())
|
||||
|| !Objects.equals(old[1], dyn.getSchoolId())) {
|
||||
totalMismatch++;
|
||||
log.warn("[MainIdentityCheck] 用户 {}:主身份不一致 old(roleId={}, schoolId={}) vs dyn(roleId={}, schoolId={})",
|
||||
userId, old[0], old[1], dyn.getRoleId(), dyn.getSchoolId());
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("[MainIdentityCheck] 对账完成:物化主身份 {} 人,动态主身份 {} 人,差异 {} 人。"
|
||||
+ "差异清零(或逐条人工确认)后才允许执行 DROP COLUMN is_main。",
|
||||
oldMainCount, dynMainCount, totalMismatch);
|
||||
}
|
||||
|
||||
/** 原生 SQL 读 is_main=1 遗留列(实体已删字段,不能走 JPA),返回 userId → [roleId, schoolId] */
|
||||
private Map<Integer, Integer[]> queryOldMain(List<Integer> userIds) {
|
||||
String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?"));
|
||||
List<Object[]> rows = jdbcTemplate.query(
|
||||
"SELECT user_id, role_id, school_id FROM sys_user_role_rel "
|
||||
+ "WHERE user_id IN (" + placeholders + ") AND is_main = 1",
|
||||
(rs, i) -> new Object[]{rs.getInt("user_id"), rs.getInt("role_id"), rs.getInt("school_id")},
|
||||
userIds.toArray());
|
||||
Map<Integer, Integer[]> map = new HashMap<>();
|
||||
for (Object[] row : rows) {
|
||||
map.put(((Number) row[0]).intValue(),
|
||||
new Integer[]{((Number) row[1]).intValue(), ((Number) row[2]).intValue()});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ yh:
|
||||
mobi-api-pattern: /mobiapi/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
identity:
|
||||
# 允许调用 oapi/identity/switch 的应用 appKey 白名单(逗号分隔;100001=UserHome,后期会新增)
|
||||
switch-allowed-app-keys: 100001,100002
|
||||
sign-key-internal:
|
||||
enabled: true
|
||||
ips:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* IdentityService 主身份动态解析(resolveMainIdentity / resolveMainIdentityByUser)单元测试。
|
||||
* 规则:主组 → 主校 → 该校正常态身份行中 sortNo 最小、并列取 id 最小。
|
||||
*/
|
||||
class IdentityServiceTest {
|
||||
|
||||
@InjectMocks
|
||||
private IdentityService identityService;
|
||||
|
||||
@Mock
|
||||
private SysUserRoleRelRepository sysUserRoleRelRepository;
|
||||
@Mock
|
||||
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
|
||||
@Mock
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
@Mock
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
@Mock
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
@Mock
|
||||
private ISysGroupService sysGroupService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
private SysUserGroupRel groupRel(Integer groupId, Integer isMain) {
|
||||
SysUserGroupRel r = new SysUserGroupRel();
|
||||
r.setUserId(1);
|
||||
r.setGroupId(groupId);
|
||||
r.setIsMain(isMain);
|
||||
return r;
|
||||
}
|
||||
|
||||
private SysUserRoleRel roleRel(Integer id, Integer userId, Integer schoolId, Integer sortNo) {
|
||||
SysUserRoleRel r = new SysUserRoleRel();
|
||||
r.setId(id);
|
||||
r.setUserId(userId);
|
||||
r.setSchoolId(schoolId);
|
||||
r.setSortNo(sortNo);
|
||||
r.setStatus(StatusEnum.NORMAL);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ===================== 单用户 resolveMainIdentity =====================
|
||||
|
||||
/** 无组关系 → null */
|
||||
@Test
|
||||
void resolveMainIdentity_noGroup_returnsNull() {
|
||||
when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty());
|
||||
assertNull(identityService.resolveMainIdentity(1));
|
||||
}
|
||||
|
||||
/** 主校无身份行(身份行全在其它学校)→ null */
|
||||
@Test
|
||||
void resolveMainIdentity_mainSchoolHasNoRows_returnsNull() {
|
||||
when(mainGroupResolver.resolve(1)).thenReturn(Optional.of(groupRel(16, 1)));
|
||||
when(sysGroupService.resolveSchoolId(16)).thenReturn(16);
|
||||
when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL))
|
||||
.thenReturn(Collections.singletonList(roleRel(10, 1, 20, 0)));
|
||||
assertNull(identityService.resolveMainIdentity(1));
|
||||
}
|
||||
|
||||
/** 主校多行:sortNo 最小、并列取 id 最小 */
|
||||
@Test
|
||||
void resolveMainIdentity_picksMinSortNoThenMinId() {
|
||||
when(mainGroupResolver.resolve(1)).thenReturn(Optional.of(groupRel(16, 1)));
|
||||
when(sysGroupService.resolveSchoolId(16)).thenReturn(16);
|
||||
List<SysUserRoleRel> rels = Arrays.asList(
|
||||
roleRel(30, 1, 16, 2),
|
||||
roleRel(31, 1, 16, 1),
|
||||
roleRel(25, 1, 16, 1), // 与 31 同 sortNo → 取 id 小者
|
||||
roleRel(10, 1, 20, 0)); // 其它学校,不参与
|
||||
when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL)).thenReturn(rels);
|
||||
SysUserRoleRel main = identityService.resolveMainIdentity(1);
|
||||
assertEquals(Integer.valueOf(25), main.getId());
|
||||
}
|
||||
|
||||
// ===================== 批量 resolveMainIdentityByUser =====================
|
||||
|
||||
/** 批量:3 次查询路径,无主组/主校无行的用户跳过 */
|
||||
@Test
|
||||
void resolveMainIdentityByUser_skipsUsersWithoutMain() {
|
||||
Map<Integer, SysUserGroupRel> mainGroupMap = new HashMap<>();
|
||||
mainGroupMap.put(1, groupRel(16, 1)); // 用户 1:主组 16
|
||||
mainGroupMap.put(2, groupRel(20, 1)); // 用户 2:主组 20
|
||||
when(mainGroupResolver.resolveByUserIds(Arrays.asList(1, 2, 3))).thenReturn(mainGroupMap);
|
||||
Map<Integer, Integer> schoolByGroup = new HashMap<>();
|
||||
schoolByGroup.put(16, 16);
|
||||
schoolByGroup.put(20, 20);
|
||||
when(sysGroupService.resolveSchoolIds(Arrays.asList(16, 20))).thenReturn(schoolByGroup);
|
||||
when(sysUserRoleRelRepository.findByUserIdInAndStatus(Arrays.asList(1, 2, 3), StatusEnum.NORMAL))
|
||||
.thenReturn(Arrays.asList(
|
||||
roleRel(30, 1, 16, 1),
|
||||
roleRel(31, 1, 16, 2),
|
||||
roleRel(40, 2, 99, 0))); // 用户 2 主校 20 无行 → 跳过
|
||||
Map<Integer, SysUserRoleRel> result = identityService.resolveMainIdentityByUser(Arrays.asList(1, 2, 3));
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(Integer.valueOf(30), result.get(1).getId());
|
||||
}
|
||||
|
||||
/** 批量:空入参 → 空映射 */
|
||||
@Test
|
||||
void resolveMainIdentityByUser_emptyInput_returnsEmptyMap() {
|
||||
assertTrue(identityService.resolveMainIdentityByUser(Collections.emptyList()).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* SysGroupService.resolveSchoolId 单元测试(《用户身份-学校维度落地方案》§3 核心规则)。
|
||||
* <br>规则:沿祖先链(含自身)——① 最近的学校组 → 该校组 id;② 无学校组则最近的区域组 → 该区域组 id;
|
||||
* ③ 都没有 → 根节点 id。
|
||||
*/
|
||||
class SysGroupServiceIdentityTest {
|
||||
|
||||
@InjectMocks
|
||||
private SysGroupService sysGroupService;
|
||||
|
||||
@Mock
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
|
||||
private SysGroup group(Integer id, Integer parentId, GroupTypeEnum type) {
|
||||
SysGroup g = new SysGroup();
|
||||
g.setId(id);
|
||||
g.setParentId(parentId);
|
||||
g.setType(type);
|
||||
return g;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.initMocks(this);
|
||||
// 根节点:parent_id=0,id=100(唯一真实根)
|
||||
when(sysGroupRepository.findByParentIdOrderBySortAsc(0)).thenReturn(Collections.singletonList(group(100, 0, null)));
|
||||
}
|
||||
|
||||
private void stubChain(SysGroup... chain) {
|
||||
for (SysGroup g : chain) {
|
||||
when(sysGroupRepository.findById(g.getId())).thenReturn(Optional.of(g));
|
||||
}
|
||||
}
|
||||
|
||||
/** 组自身即学校组 -> 返回自身 id */
|
||||
@Test
|
||||
void resolveSchoolId_selfIsSchool() {
|
||||
stubChain(group(10, 100, GroupTypeEnum.SCHOOL_GROUP));
|
||||
assertEquals(Integer.valueOf(10), sysGroupService.resolveSchoolId(10));
|
||||
}
|
||||
|
||||
/** 链上最近学校组:普通组(7) -> 教师组(8) -> 学校组(10) -> 根 */
|
||||
@Test
|
||||
void resolveSchoolId_nearestSchoolInChain() {
|
||||
stubChain(
|
||||
group(7, 8, GroupTypeEnum.NORMAL_GROUP),
|
||||
group(8, 10, GroupTypeEnum.TEACHER_GROUP),
|
||||
group(10, 100, GroupTypeEnum.SCHOOL_GROUP));
|
||||
assertEquals(Integer.valueOf(10), sysGroupService.resolveSchoolId(7));
|
||||
}
|
||||
|
||||
/** 无学校组但链上有区域组:普通组(7) -> 学段组(6) -> 区域组(5) -> 根 -> 返回区域组 id */
|
||||
@Test
|
||||
void resolveSchoolId_noSchool_fallbackArea() {
|
||||
stubChain(
|
||||
group(7, 6, GroupTypeEnum.NORMAL_GROUP),
|
||||
group(6, 5, GroupTypeEnum.STAGE_GROUP),
|
||||
group(5, 100, GroupTypeEnum.AREA_GROUP));
|
||||
assertEquals(Integer.valueOf(5), sysGroupService.resolveSchoolId(7));
|
||||
}
|
||||
|
||||
/** 学校组优先于其上方的区域组:普通组 -> 学校组 -> 学段组 -> 区域组 -> 返回学校组 id */
|
||||
@Test
|
||||
void resolveSchoolId_schoolWinsOverAreaAbove() {
|
||||
stubChain(
|
||||
group(7, 10, GroupTypeEnum.NORMAL_GROUP),
|
||||
group(10, 6, GroupTypeEnum.SCHOOL_GROUP),
|
||||
group(6, 5, GroupTypeEnum.STAGE_GROUP),
|
||||
group(5, 100, GroupTypeEnum.AREA_GROUP));
|
||||
assertEquals(Integer.valueOf(10), sysGroupService.resolveSchoolId(7));
|
||||
}
|
||||
|
||||
/** 区域组自身:无学校组、最近区域组即自身 -> 返回自身 */
|
||||
@Test
|
||||
void resolveSchoolId_selfIsArea() {
|
||||
stubChain(group(5, 100, GroupTypeEnum.AREA_GROUP));
|
||||
assertEquals(Integer.valueOf(5), sysGroupService.resolveSchoolId(5));
|
||||
}
|
||||
|
||||
/** 直属根节点(无学校/区域)-> 根节点 id */
|
||||
@Test
|
||||
void resolveSchoolId_rootDirectFallbackToRoot() {
|
||||
stubChain(group(7, 100, GroupTypeEnum.NORMAL_GROUP));
|
||||
assertEquals(Integer.valueOf(100), sysGroupService.resolveSchoolId(7));
|
||||
}
|
||||
|
||||
/** 组不存在(找不到实体)-> 兜底根节点 id(与「无学校/区域回根」一致) */
|
||||
@Test
|
||||
void resolveSchoolId_missingGroupFallbackToRoot() {
|
||||
when(sysGroupRepository.findById(anyInt())).thenReturn(Optional.empty());
|
||||
assertEquals(Integer.valueOf(100), sysGroupService.resolveSchoolId(999));
|
||||
}
|
||||
|
||||
/** groupId 为空 -> 抛异常 */
|
||||
@Test
|
||||
void resolveSchoolId_nullGroupThrows() {
|
||||
assertThrows(DException.class, () -> sysGroupService.resolveSchoolId(null));
|
||||
}
|
||||
|
||||
// ===================== 批量 resolveSchoolIds =====================
|
||||
|
||||
/** 批量结果与单查一致:学校组优先 → 区域组 → 根 */
|
||||
@Test
|
||||
void resolveSchoolIds_batchMatchesSingle() {
|
||||
List<SysGroup> all = Arrays.asList(
|
||||
group(100, 0, null),
|
||||
group(5, 100, GroupTypeEnum.AREA_GROUP),
|
||||
group(10, 5, GroupTypeEnum.SCHOOL_GROUP),
|
||||
group(8, 10, GroupTypeEnum.TEACHER_GROUP),
|
||||
group(7, 8, GroupTypeEnum.NORMAL_GROUP),
|
||||
group(20, 100, GroupTypeEnum.SCHOOL_GROUP));
|
||||
when(sysGroupRepository.findAll()).thenReturn(all);
|
||||
Map<Integer, Integer> result = sysGroupService.resolveSchoolIds(Arrays.asList(7, 5, 20));
|
||||
assertEquals(3, result.size());
|
||||
assertEquals(Integer.valueOf(10), result.get(7)); // 教师组 → 学校组 10
|
||||
assertEquals(Integer.valueOf(5), result.get(5)); // 区域组自身
|
||||
assertEquals(Integer.valueOf(20), result.get(20)); // 学校组自身
|
||||
}
|
||||
|
||||
/** 组不存在(悬挂关系)→ 根节点 id(与 resolveSchoolId 单查一致) */
|
||||
@Test
|
||||
void resolveSchoolIds_missingGroupFallbackToRoot() {
|
||||
when(sysGroupRepository.findAll()).thenReturn(Collections.singletonList(group(100, 0, null)));
|
||||
Map<Integer, Integer> result = sysGroupService.resolveSchoolIds(Collections.singletonList(999));
|
||||
assertEquals(Integer.valueOf(100), result.get(999));
|
||||
}
|
||||
|
||||
/** 空入参 → 空映射 */
|
||||
@Test
|
||||
void resolveSchoolIds_emptyInputReturnsEmptyMap() {
|
||||
assertTrue(sysGroupService.resolveSchoolIds(Collections.emptyList()).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.entity.SysUserCurrentRole;
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.entity.SysUserRoleRel;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* SysUserService 单元测试:主归属组三级降级决策(changeGroups)+
|
||||
* 转学后身份对齐回归(P3 去字段化后 ①②③ 保留段仍生效)。
|
||||
*/
|
||||
class SysUserServiceTest {
|
||||
|
||||
@InjectMocks
|
||||
private SysUserService sysUserService;
|
||||
|
||||
@Mock
|
||||
private SysUserRepository sysUserRepository;
|
||||
@Mock
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
@Mock
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
@Mock
|
||||
private SysUserRoleRelRepository sysUserRoleRelRepository;
|
||||
@Mock
|
||||
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
|
||||
@Mock
|
||||
private ISysGroupService sysGroupService;
|
||||
@Mock
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
@Mock
|
||||
private IdentityService identityService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
private SysUserGroupRel rel(Integer groupId, Integer isMain) {
|
||||
SysUserGroupRel r = new SysUserGroupRel();
|
||||
r.setGroupId(groupId);
|
||||
r.setIsMain(isMain);
|
||||
return r;
|
||||
}
|
||||
|
||||
private SysUserRoleRel roleRel(Integer id, Integer userId, Integer schoolId) {
|
||||
SysUserRoleRel r = new SysUserRoleRel();
|
||||
r.setId(id);
|
||||
r.setUserId(userId);
|
||||
r.setSchoolId(schoolId);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ===================== 主归属组三级降级决策 =====================
|
||||
|
||||
/** ① 显式 mainGroupId 优先(不受旧主组影响) */
|
||||
@Test
|
||||
void decide_whenExplicitMainGroupId_shouldUseIt() {
|
||||
Integer main = SysUserService.decideEffectiveMainGroupId(
|
||||
30, Arrays.asList(16, 30), rel(16, 1));
|
||||
assertEquals(Integer.valueOf(30), main);
|
||||
}
|
||||
|
||||
/** ② 未传时沿用旧主组(旧主组 ∈ 新集合)——「主组没被勾掉就不动」 */
|
||||
@Test
|
||||
void decide_whenNoExplicitAndOldMainInNewGroups_shouldKeepOldMain() {
|
||||
Integer main = SysUserService.decideEffectiveMainGroupId(
|
||||
null, Arrays.asList(29, 16), rel(16, 1));
|
||||
assertEquals(Integer.valueOf(16), main);
|
||||
}
|
||||
|
||||
/** ② 未传、无旧主组(脏数据)→ ③ groupIds 首项 */
|
||||
@Test
|
||||
void decide_whenNoExplicitAndNoOldMain_shouldFallbackToFirstGroup() {
|
||||
Integer main = SysUserService.decideEffectiveMainGroupId(
|
||||
null, Arrays.asList(29, 16), null);
|
||||
assertEquals(Integer.valueOf(29), main);
|
||||
}
|
||||
|
||||
/** ③ 旧主组被勾掉(不在新集合)→ groupIds 首项(现状行为兜底) */
|
||||
@Test
|
||||
void decide_whenOldMainRemovedFromNewGroups_shouldFallbackToFirstGroup() {
|
||||
Integer main = SysUserService.decideEffectiveMainGroupId(
|
||||
null, Arrays.asList(29, 30), rel(16, 1));
|
||||
assertEquals(Integer.valueOf(29), main);
|
||||
}
|
||||
|
||||
// ===================== 转学后身份对齐回归(P3 去字段化后 ①②③ 保留段) =====================
|
||||
|
||||
/**
|
||||
* changeGroups 转学场景(新组 30 替代旧组 16/20):删除 ④ is_main 置位段后,
|
||||
* ① 学校集合推导、② 孤儿身份行清理(旧校 16 身份行删)、③ 激活身份滞留清理
|
||||
* (current 指向旧校 16 删)必须仍生效;主校 30 身份行保留。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void changeGroups_afterTransfer_shouldKeepOrphanAndCurrentStaleCleanup() {
|
||||
SysUser user = new SysUser();
|
||||
user.setId(1);
|
||||
SysGroup group30 = new SysGroup();
|
||||
group30.setId(30);
|
||||
|
||||
SysUserGroupRel oldMain = rel(16, 1);
|
||||
oldMain.setId(1);
|
||||
SysUserGroupRel oldExtra = rel(20, 0);
|
||||
oldExtra.setId(2);
|
||||
List<SysUserGroupRel> oldRels = Arrays.asList(oldMain, oldExtra);
|
||||
SysUserGroupRel newRel = new SysUserGroupRel();
|
||||
newRel.setUserId(1);
|
||||
newRel.setGroupId(30);
|
||||
List<SysUserGroupRel> newRels = Collections.singletonList(newRel);
|
||||
|
||||
when(sysUserRepository.findAllById(Collections.singletonList(1)))
|
||||
.thenReturn(Collections.singletonList(user));
|
||||
when(sysGroupRepository.findAllById(Collections.singletonList(30)))
|
||||
.thenReturn(Collections.singletonList(group30));
|
||||
// 改组前读旧关系(status 继承)→ 对齐时读新关系(schoolSet 推导)
|
||||
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(oldRels, newRels);
|
||||
when(mainGroupResolver.resolve(oldRels)).thenReturn(oldMain);
|
||||
when(sysGroupService.resolveSchoolId(30)).thenReturn(30);
|
||||
|
||||
SysUserRoleRel orphan = roleRel(10, 1, 16);
|
||||
SysUserRoleRel kept = roleRel(11, 1, 30);
|
||||
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Arrays.asList(orphan, kept));
|
||||
|
||||
SysUserCurrentRole current = new SysUserCurrentRole();
|
||||
current.setUserId(1);
|
||||
current.setSchoolId(16);
|
||||
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.of(current));
|
||||
|
||||
sysUserService.changeGroups(Collections.singletonList(1), Collections.singletonList(30), 30);
|
||||
|
||||
// ① 新组关系落库:目标组 30 置主(显式 mainGroupId)
|
||||
ArgumentCaptor<List<SysUserGroupRel>> groupRelCaptor = ArgumentCaptor.forClass(List.class);
|
||||
verify(sysUserGroupRelRepository).saveAll(groupRelCaptor.capture());
|
||||
List<SysUserGroupRel> savedRels = groupRelCaptor.getValue();
|
||||
assertEquals(1, savedRels.size());
|
||||
assertEquals(Integer.valueOf(1), savedRels.get(0).getIsMain());
|
||||
|
||||
// ② 孤儿身份行(旧校 16)删除、主校 30 行保留(identity 断言,规避实体 equals 恒真问题)
|
||||
ArgumentCaptor<SysUserRoleRel> deletedCaptor = ArgumentCaptor.forClass(SysUserRoleRel.class);
|
||||
verify(sysUserRoleRelRepository).delete(deletedCaptor.capture());
|
||||
assertSame(orphan, deletedCaptor.getValue());
|
||||
ArgumentCaptor<List<SysUserRoleRel>> roleRelCaptor = ArgumentCaptor.forClass(List.class);
|
||||
verify(sysUserRoleRelRepository).saveAll(roleRelCaptor.capture());
|
||||
List<SysUserRoleRel> remaining = roleRelCaptor.getValue();
|
||||
assertEquals(1, remaining.size());
|
||||
assertSame(kept, remaining.get(0));
|
||||
|
||||
// ③ 激活身份滞留清理:current 指向旧校 16(不在新 schoolSet {30})→ 删除
|
||||
verify(sysUserCurrentRoleRepository).delete(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.yunhai.userbase.service.resolve;
|
||||
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* MainGroupResolver 单元测试:验证统一兜底契约
|
||||
* 「isMain=1 取 id 最小;无 isMain=1 取 id 最小;空列表 → null」。
|
||||
*/
|
||||
class MainGroupResolverTest {
|
||||
|
||||
@InjectMocks
|
||||
private MainGroupResolver mainGroupResolver;
|
||||
|
||||
@Mock
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
/** 构造关联行(id、userId、groupId、isMain) */
|
||||
private SysUserGroupRel rel(Integer id, Integer userId, Integer groupId, Integer isMain) {
|
||||
SysUserGroupRel r = new SysUserGroupRel();
|
||||
r.setId(id);
|
||||
r.setUserId(userId);
|
||||
r.setGroupId(groupId);
|
||||
r.setIsMain(isMain);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ===================== 纯函数 resolve(List) =====================
|
||||
|
||||
/** 空列表 → null(resolve(null) 显式转 List 消除重载歧义) */
|
||||
@Test
|
||||
void resolve_whenEmptyList_shouldReturnNull() {
|
||||
assertNull(mainGroupResolver.resolve(Collections.emptyList()));
|
||||
assertNull(mainGroupResolver.resolve((List<SysUserGroupRel>) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_whenNoMain_shouldPickMinId() {
|
||||
// 全部非主:取 id 最小(第一条顺序依赖被消除)
|
||||
List<SysUserGroupRel> rels = Arrays.asList(
|
||||
rel(7, 1, 30, 0),
|
||||
rel(3, 1, 16, 0),
|
||||
rel(9, 1, 29, 0));
|
||||
SysUserGroupRel main = mainGroupResolver.resolve(rels);
|
||||
assertEquals(Integer.valueOf(3), main.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_whenMultipleMain_shouldPickMinIdAmongMains() {
|
||||
// 脏数据多主:isMain=1 中取 id 最小(id=3 优先于 id=9)
|
||||
List<SysUserGroupRel> rels = Arrays.asList(
|
||||
rel(5, 1, 30, 0),
|
||||
rel(9, 1, 29, 1),
|
||||
rel(3, 1, 16, 1));
|
||||
SysUserGroupRel main = mainGroupResolver.resolve(rels);
|
||||
assertEquals(Integer.valueOf(3), main.getId());
|
||||
assertEquals(Integer.valueOf(16), main.getGroupId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_whenNullGroupId_shouldSkipDirtyRow() {
|
||||
// groupId 为 null 的关系行(脏数据)不参与选择
|
||||
List<SysUserGroupRel> rels = Arrays.asList(
|
||||
rel(1, 1, null, 1),
|
||||
rel(10, 1, 29, 0));
|
||||
SysUserGroupRel main = mainGroupResolver.resolve(rels);
|
||||
assertEquals(Integer.valueOf(10), main.getId());
|
||||
|
||||
// 全部为 null groupId → null
|
||||
assertNull(mainGroupResolver.resolve(Collections.singletonList(rel(1, 1, null, 1))));
|
||||
}
|
||||
|
||||
// ===================== 单用户 resolve(Integer) =====================
|
||||
|
||||
@Test
|
||||
void resolve_byUserId_whenNoRels_shouldReturnEmpty() {
|
||||
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
|
||||
assertFalse(mainGroupResolver.resolve(1).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_byUserId_shouldDelegateToPureFunction() {
|
||||
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Arrays.asList(
|
||||
rel(7, 1, 30, 0),
|
||||
rel(2, 1, 16, 1)));
|
||||
Optional<SysUserGroupRel> main = mainGroupResolver.resolve(1);
|
||||
assertTrue(main.isPresent());
|
||||
assertEquals(Integer.valueOf(2), main.get().getId());
|
||||
}
|
||||
|
||||
// ===================== 批量 resolveByUserIds =====================
|
||||
|
||||
@Test
|
||||
void resolveByUserIds_whenEmptyInput_shouldReturnEmptyMap() {
|
||||
assertTrue(mainGroupResolver.resolveByUserIds(Collections.emptyList()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByUserIds_shouldResolvePerUserAndSkipUsersWithoutRels() {
|
||||
when(sysUserGroupRelRepository.findByUserIdIn(Arrays.asList(1, 2, 3))).thenReturn(Arrays.asList(
|
||||
rel(5, 1, 30, 0),
|
||||
rel(1, 1, 16, 1),
|
||||
rel(2, 2, 29, 0)));
|
||||
Map<Integer, SysUserGroupRel> result = mainGroupResolver.resolveByUserIds(Arrays.asList(1, 2, 3));
|
||||
assertEquals(2, result.size());
|
||||
// 用户 1:多主/无主混合,取 isMain=1 中 id 最小
|
||||
assertEquals(Integer.valueOf(1), result.get(1).getId());
|
||||
// 用户 2:无主兜底 id 最小
|
||||
assertEquals(Integer.valueOf(2), result.get(2).getId());
|
||||
// 用户 3:无组关系,不出现
|
||||
assertFalse(result.containsKey(3));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user