From c588dedea75774600454517bd4fc2530fe024bb5 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 27 Aug 2026 20:14:12 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E6=A0=A1=E8=BA=AB=E4=BB=BD=E4=BF=AE?= =?UTF-8?q?=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/IdentityController.java | 62 --- .../controller/api/SysUserController.java | 12 +- .../controller/oapi/IdentityController.java | 93 ++++ .../controller/oapi/UserController.java | 7 +- .../userbase/entity/SysUserCurrentRole.java | 2 +- .../userbase/entity/SysUserRoleRel.java | 10 +- .../model/identity/IdentityCurrentVo.java | 32 -- .../model/identity/IdentityOptionVo.java | 33 -- ...st.java => IdentitySwitchOapiRequest.java} | 13 +- .../model/sysuser/ChangeGroupsRequest.java | 3 + .../model/sysuser/SysUserRequest.java | 11 +- .../userbase/model/sysuser/SysUserVo.java | 5 + .../model/sysuser/UserIdentityVo.java | 23 + .../SysUserCurrentRoleRepository.java | 8 + .../repository/SysUserRoleRelRepository.java | 15 +- .../userbase/service/ISysGroupService.java | 37 ++ .../userbase/service/ISysUserService.java | 27 +- .../service/impl/GroupAuthzService.java | 31 +- .../service/impl/IdentityService.java | 157 +++++-- .../service/impl/SysGroupService.java | 84 +++- .../userbase/service/impl/SysUserService.java | 413 ++++++++++++++---- .../service/impl/UserCenterService.java | 73 +++- .../service/push/PushPayloadAssembler.java | 16 +- .../service/resolve/MainGroupResolver.java | 87 ++++ .../userbase/task/IdentityBackfillTask.java | 162 +++++++ .../MainIdentityConsistencyCheckTask.java | 111 +++++ src/main/resources/application.yml | 3 + .../service/impl/IdentityServiceTest.java | 137 ++++++ .../impl/SysGroupServiceIdentityTest.java | 157 +++++++ .../service/impl/SysUserServiceTest.java | 180 ++++++++ .../resolve/MainGroupResolverTest.java | 135 ++++++ 31 files changed, 1811 insertions(+), 328 deletions(-) delete mode 100644 src/main/java/com/yunhai/userbase/controller/api/IdentityController.java create mode 100644 src/main/java/com/yunhai/userbase/controller/oapi/IdentityController.java delete mode 100644 src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java delete mode 100644 src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java rename src/main/java/com/yunhai/userbase/model/identity/{IdentitySwitchRequest.java => IdentitySwitchOapiRequest.java} (53%) create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/UserIdentityVo.java create mode 100644 src/main/java/com/yunhai/userbase/service/resolve/MainGroupResolver.java create mode 100644 src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java create mode 100644 src/main/java/com/yunhai/userbase/task/MainIdentityConsistencyCheckTask.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIdentityTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/resolve/MainGroupResolverTest.java diff --git a/src/main/java/com/yunhai/userbase/controller/api/IdentityController.java b/src/main/java/com/yunhai/userbase/controller/api/IdentityController.java deleted file mode 100644 index 4c3c669..0000000 --- a/src/main/java/com/yunhai/userbase/controller/api/IdentityController.java +++ /dev/null @@ -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; - -/** - * 用户身份(平台身份角色 × 学校)管理接口。

- * 走 CAS 会话路线({@code /{platform}/identity/}),当前用户从 CAS 会话注入的 - * {@link BoaUser#getId()} 取,不接受外部传入 userId(防越权)。
- * 三个端点对应「切换身份」业务:
- * ① {@code GET current} 当前激活身份(无切换记录则兜底主身份);
- * ② {@code GET available} 全部可选身份清单;
- * ③ {@code POST switch} 切换到指定 (roleId, schoolId),服务端校验属主后写入当前激活状态。 - * - * @author chenx - * @date 2026/8/18 - */ -@RestController -@RequestMapping(Config.API_PATH + "/identity/") -public class IdentityController { - - @Autowired - private IdentityService identityService; - - /** - * 当前激活身份。 - */ - @GetMapping("current") - public IdentityCurrentVo current(BoaUser authUserWithDept) { - return identityService.getCurrent(authUserWithDept.getId()); - } - - /** - * 全部可选身份(正常态),供「切换身份」页渲染。 - */ - @GetMapping("available") - public List available(BoaUser authUserWithDept) { - return identityService.listAvailable(authUserWithDept.getId()); - } - - /** - * 切换身份:校验属主后在 sys_user_current_role 写当前激活状态。 - */ - @PostMapping("switch") - public IdentityCurrentVo switchIdentity(BoaUser authUserWithDept, - @RequestBody @Validated IdentitySwitchRequest request) { - return identityService.switchIdentity(authUserWithDept.getId(), request); - } -} diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java index 84c23bc..b48ea0f 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -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 与并集。
+ * 「用户身份」回显唯一来源(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) { diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/IdentityController.java b/src/main/java/com/yunhai/userbase/controller/oapi/IdentityController.java new file mode 100644 index 0000000..dac5fe5 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/oapi/IdentityController.java @@ -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/...})。
+ *
+ * 契约统一 {@link BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName), + * 与 {@code /oapi/user/roles}、{@code BoaUser.currentRole} 完全同构,前端零转换。
+ * 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制;
+ * 切换(switch)额外受 {@code yh.identity.switch-allowed-app-keys} 白名单约束(AppKey 级闸门), + * 且目标用户必须真实存在于其身份集({@code sys_user_role_rel} 三元组校验,防越权切换他人)。
+ * 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 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 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()); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index 245cc4f..59c178b 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -262,10 +262,11 @@ public class UserController { } /** - * 跨校转入(转出后迁移到其他学校)。
+ * 跨校转入(转出后迁移到其他学校),同时支持同校转组(原学校与目标学校相同时,从原组转到另一组)。
* 前置:用户已在原学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系(先经 groupState 置转出);
- * 操作成功后:删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系 - * (主归属、状态恢复正常),并推送编辑用户订阅。
+ * 原组即该转出状态组关系,仅当原学校与原组都与目标一致(fromSchoolId==schoolId 且转出组==groupId)才拒绝。
+ * 操作成功后:同校转组删除原组关系、新增目标组关系(主归属继承原组,其他组与他校关系保留); + * 跨校转入删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系(主归属、状态恢复正常),并推送编辑用户订阅。
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。 */ @PostMapping("transferToSchool") diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java b/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java index 7935603..4939982 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java @@ -37,7 +37,7 @@ public class SysUserCurrentRole { private Integer userId; /** - * 用户切换选中的学校ID(根级group,数值即学校组 sys_group.id;区域/学段/根直属=0或null) + * 用户切换选中的身份归属学校(恒为真实组 id:学校组/区域组/根直属=根节点 id;原「区域/学段/根直属=0或null」约定废弃) */ private Integer schoolId; /** diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java index 07cb6a6..53def95 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java @@ -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; - /** * 排序号:越小越靠前 */ diff --git a/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java b/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java deleted file mode 100644 index 60adcaa..0000000 --- a/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.yunhai.userbase.model.identity; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 当前激活身份(来自 {@code sys_user_current_role};无切换记录时兜底主身份)。
- * - * @author chenx - * @date 2026/8/18 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class IdentityCurrentVo { - - /** 当前激活角色ID(SysRoleEnum.key) */ - private Integer roleId; - - /** 当前激活角色名 */ - private String roleName; - - /** 当前激活学校ID(=学校组 sys_group.id;无学校时为 0) */ - private Integer schoolId; - - /** 当前激活学校名 */ - private String schoolName; - - /** 是否兜底(无主动切换记录时为 true,提示前端可引导用户显式切换) */ - private Boolean fallback; -} diff --git a/src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java b/src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java deleted file mode 100644 index c06e657..0000000 --- a/src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.yunhai.userbase.model.identity; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 用户可选身份(一条 = 某学校下的某平台角色)。
- * 数据源:{@code sys_user_role_rel}(平台身份角色权威源)。
- * - * @author chenx - * @date 2026/8/18 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class IdentityOptionVo { - - /** 角色ID(SysRoleEnum.key,1-7) */ - private Integer roleId; - - /** 角色名(老师/校领导/学生…,查枚举快取) */ - private String roleName; - - /** 学校ID(=学校组 sys_group.id;区域/学段/直属无学校时为 0) */ - private Integer schoolId; - - /** 学校名(冗余,免 join sys_group) */ - private String schoolName; - - /** 是否主身份(isMain=1) */ - private Boolean isMain; -} diff --git a/src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchRequest.java b/src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchOapiRequest.java similarity index 53% rename from src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchRequest.java rename to src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchOapiRequest.java index f7f88d7..28912d1 100644 --- a/src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchRequest.java +++ b/src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchOapiRequest.java @@ -5,15 +5,20 @@ import lombok.Data; import javax.validation.constraints.NotNull; /** - * 切换身份入参:指定目标学校与该学校下的角色。
- * 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态,
+ * 开放接口(oapi)切换身份入参:目标用户 + 目标学校与该学校下的角色。
+ * 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态, * 不存在则拒绝(防止越权切换到非授权身份)。
+ * 调用方应用须在 {@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不能为空") diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java index 5c15b38..457cc6d 100644 --- a/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java +++ b/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java @@ -20,4 +20,7 @@ public class ChangeGroupsRequest { /** 目标组ID列表(覆盖式) */ @NotEmpty(message = "请至少保留一个组") private List groupIds; + + /** 主归属组ID(可选):不传时后端按「旧主组保留」策略降级(旧主组勾掉才落到 groupIds 首项) */ + private Integer mainGroupId; } diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java index ccdf606..74899f9 100644 --- a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java @@ -38,7 +38,16 @@ public class SysUserRequest { private String email; private Integer gender; private String profilePicture; - private List roles; + /** + * 身份归属学校(schoolId 解析后的真实组 id:学校组/区域组/根节点)。
+ * 编辑时必传(随 Select 当前选中组提交原值);新增可不传,后端按 groupId 解析。 + */ + private Integer schoolId; + /** + * 用户身份角色(SysRoleEnum.key 1-7),按 schoolId 全量覆盖保存;空数组=清空该校身份。
+ * 替代旧 roles 字段:sys_user.roles 列已停写(仅历史兼容读取)。 + */ + private List roleIds; /** 启用状态:对应 StatusEnum 的 key(0=正常/未禁用,1=禁用);不传则默认启用 */ private Integer disabled; private Boolean expired; diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java index efca624..f7a3ef4 100644 --- a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java @@ -31,6 +31,11 @@ public class SysUserVo { private Integer sortNo; /** 组内状态:UserGroupRelStatusEnum 的 key(0=正常,1=转出);仅组上下文列表有值,根节点/未选组为 null */ private Integer groupStatus; + /** + * 主校身份名(如「老师/校领导」):列表页按页批量带出(动态解析主身份,无 N+1)。
+ * roles 列已停写(冻结历史值),「用户身份」列展示一律切此字段。 + */ + private String identityNames; private Boolean expired; private LocalDateTime createTime; private LocalDateTime updateTime; diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/UserIdentityVo.java b/src/main/java/com/yunhai/userbase/model/sysuser/UserIdentityVo.java new file mode 100644 index 0000000..25dd1f0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/UserIdentityVo.java @@ -0,0 +1,23 @@ +package com.yunhai.userbase.model.sysuser; + +import lombok.Data; + +import java.util.List; + +/** + * 用户身份回显 VO(GET /sysuser/UserIdentity)。
+ * 后端按所在组解析 schoolId 后返回;同一学校多组返回同一 schoolId 与角色并集。
+ * 编辑请求提交时原样携带 {schoolId, roleIds},由后端按 schoolId 全量覆盖写库并对齐。 + * + * @author chenx + * @date 2026/8/27 + */ +@Data +public class UserIdentityVo { + + /** 身份归属学校(解析后的真实组 id:学校组/区域组/根节点) */ + private Integer schoolId; + + /** 该校用户身份角色(SysRoleEnum.key 1-7,并集去重) */ + private List roleIds; +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java index bd79400..a24a9c8 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java @@ -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, JpaSpecificationExecutor { Optional findByUserId(Integer userId); + + /** 删除用户当前激活身份记录(级联删用户时清理残留切换记录) */ + @Modifying + @Query("DELETE FROM SysUserCurrentRole r WHERE r.userId = :userId") + void deleteByUserIdBulk(@Param("userId") Integer userId); } diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java index 44b7af7..ca4bccf 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java @@ -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 findByUserIdAndStatus(Integer userId, StatusEnum status); - /** 用户主身份(isMain=1),最多 1 条 */ - Optional findByUserIdAndIsMain(Integer userId, Integer isMain); + /** 批量取用户在指定状态下的全部身份行(列表页/导出批量主身份解析用,替代已删除的 findByUserIdInAndIsMain) */ + List findByUserIdInAndStatus(List userIds, StatusEnum status); /** 用户在某学校下、某状态下的全部角色关联 */ List 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 findDistinctRoleIdsByUserIdAndStatus(@Param("userId") Integer userId, @Param("status") StatusEnum status); diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java index 37d665a..524c795 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -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()} 约定一致)。
+ * 供「parentId/pid 为 0 或 null 表示挂根下」场景与身份归属解析({@link #resolveSchoolId})共用。 + * + * @return 根节点实体 + * @throws com.yunhai.platform.utils.exception.DException 当不存在任何根节点时 + */ + SysGroup resolveRootGroup(); + + /** + * 解析组归属的 schoolId(身份归属):沿该组的祖先链(含自身)——
+ * ① 最近的学校组(SCHOOL_GROUP)→ 该校组 id;
+ * ② 无学校组,则最近的区域组(AREA_GROUP)→ 该区域组 id;
+ * ③ 学校组、区域组都没有 → 根节点 id。
+ * 同校多组解析到同一 schoolId;schoolId 恒为真实组 id(原表注释「区域/学段/根=0」约定废弃)。
+ * 该规则属后端职责,前端只按接口返回的 schoolId 加载/保存,不感知解析逻辑。 + * + * @param groupId 组ID + * @return 归属 schoolId + */ + Integer resolveSchoolId(Integer groupId); + + /** + * 批量解析组归属 schoolId(身份归属),语义与 {@link #resolveSchoolId} 完全一致: + * 一次全量加载 sys_group 建内存表后沿祖先链解析(O(树深)),避免 N 次逐级 findById。
+ * 供列表页/导出的批量主身份解析(IdentityService.resolveMainIdentityByUser)使用; + * 组表量级 = 学校数×层级(千级),与 searchUsers buildAllGroupMap 全表加载先例成本同量级。
+ * 组不存在(悬挂关系)按 {@link #resolveSchoolId} 同规则回落根节点 id。 + * + * @param groupIds 待解析组ID集合 + * @return groupId → schoolId 映射(入参全部有值) + */ + Map resolveSchoolIds(Collection groupIds); + List list(SysGroupQuery query); /** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */ diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index da543a7..95e1aaf 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -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; @@ -103,13 +104,29 @@ public interface ISysUserService { List findGroupIdsByUserId(Integer userId); /** - * 批量调整用户所属组:覆盖式更新 sys_user_group_rel。
- * 同一批用户统一设置为目标组列表,第一个组设为主归属,其余为兼职。 + * 查询用户身份(管理端「用户身份」回显):后端按所在组解析 schoolId 后返回 {schoolId, roleIds};
+ * 同一学校多组返回同一 schoolId 与角色并集。前端回显唯一来源(roles 列已停写,不再作为真源)。
+ * 编辑请求提交时原样携带返回的 {schoolId, roleIds},由后端按 schoolId 全量覆盖写库并对齐。 * - * @param userIds 待调整用户ID列表 - * @param groupIds 目标组ID列表 + * @param userId 用户ID + * @param groupId 所在组ID(Select 当前选中项;schoolId 解析规则见 {@link com.yunhai.userbase.service.ISysGroupService#resolveSchoolId}) + * @return {schoolId, roleIds} */ - void changeGroups(List userIds, List groupIds); + UserIdentityVo getUserIdentity(Integer userId, Integer groupId); + + /** + * 批量调整用户所属组:覆盖式更新 sys_user_group_rel。
+ * 主归属组按三级降级确定:
+ * ① mainGroupId 显式传入(须 ∈ groupIds,否则 400)→ 所有用户统一置主;
+ * ② 未传 → 逐用户沿用旧主组(旧主组 ∈ 新 groupIds);
+ * ③ 旧主组不在新集合 → 取 groupIds 首项(现状行为兜底)。
+ * ② 级为行为增强:只要用户没把主组勾掉,调整组后主组不动(旧实现按数组顺序随机重指)。 + * + * @param userIds 待调整用户ID列表 + * @param groupIds 目标组ID列表 + * @param mainGroupId 主归属组ID(可选;null 走 ②③ 级降级) + */ + void changeGroups(List userIds, List groupIds, Integer mainGroupId); /** * 开放接口专用:在学校(组)范围内移动用户。
diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index eec531b..ac03bf6 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -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 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 mainRelMap = mainGroupResolver.resolveByUserIds(new ArrayList<>(userIds)); List 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 rels = sysUserGroupRelRepository.findByUserId(userId); Set 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(); diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java index 16992b1..0abc224 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java @@ -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; /** * 用户身份服务:管理「平台身份角色 × 学校」维度的当前激活状态与可选清单。

@@ -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 listAvailable(Integer userId) { - List rels = sysUserRoleRelRepository - .findByUserIdAndStatus(userId, StatusEnum.NORMAL); - List result = new ArrayList<>(rels.size()); - for (SysUserRoleRel rel : rels) { - IdentityOptionVo vo = new IdentityOptionVo(); - vo.setRoleId(rel.getRoleId()); - vo.setRoleName(roleName(rel.getRoleId())); - vo.setSchoolId(rel.getSchoolId()); - vo.setSchoolName(schoolName(rel.getSchoolId())); - vo.setIsMain(rel.getIsMain() != null && rel.getIsMain() == 1); - result.add(vo); + public List listAvailable(Integer userId) { + List rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL); + SysUserRoleRel main = resolveMainIdentity(userId); + List 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 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 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」一致):
+ * 主组(MainGroupResolver,isMain=1 取 id 最小,无主兜底 id 最小)→ resolveSchoolId(主组) → + * 该校正常态身份行中 sortNo 最小、并列取 id 最小的一条。
+ * 无组关系 / 主校无身份行 → 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)。
+ * 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。 + */ + public Map resolveMainIdentityByUser(List userIds) { + Map result = new HashMap<>(); + if (userIds == null || userIds.isEmpty()) { + return result; + } + Map mainGroupMap = mainGroupResolver.resolveByUserIds(userIds); + if (mainGroupMap.isEmpty()) { + return result; + } + Map schoolByGroup = sysGroupService.resolveSchoolIds( + mainGroupMap.values().stream().map(SysUserGroupRel::getGroupId).collect(Collectors.toList())); + Map> roleRelsByUser = sysUserRoleRelRepository + .findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream() + .collect(Collectors.groupingBy(SysUserRoleRel::getUserId)); + for (Map.Entry 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 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) { diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 8eb0b5e..0e57a3d 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -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 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})。
+ * 供用户身份写入(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 resolveSchoolIds(Collection groupIds) { + Map result = new HashMap<>(); + if (groupIds == null || groupIds.isEmpty()) { + return result; + } + Map 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 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); diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 6adf2fe..5d7178f 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -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 fromScope = sysGroupService.listSelfAndDescendantIds(fromSchoolId); List toScope = sysGroupService.listSelfAndDescendantIds(toSchoolId); List rels = sysUserGroupRelRepository.findByUserId(userId); - // ① 前置:用户在源学校范围必须存在至少一条「转出」状态的组关系 - boolean hasTransferOut = rels.stream().anyMatch(r -> - r.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT && fromScope.contains(r.getGroupId())); - if (!hasTransferOut) { + // ① 前置:用户在源学校范围必须存在至少一条「转出」状态的组关系(原组由转出标记隐含携带) + List transferOutRels = rels.stream() + .filter(r -> r.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT && fromScope.contains(r.getGroupId())) + .collect(Collectors.toList()); + if (transferOutRels.isEmpty()) { throw new DException("该用户在原学校不存在转出状态的组关系,禁止转入"); } - // ② 目标学校范围不得已有组关系(转入语义为进入全新学校,避免覆盖歧义) - boolean existsInTarget = rels.stream().anyMatch(r -> toScope.contains(r.getGroupId())); - if (existsInTarget) { - throw new DException("用户已在该学校存在组关系,禁止重复转入"); + + List 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("用户已在该学校存在组关系,禁止重复转入"); + } + toDelete = rels.stream() + .filter(r -> fromScope.contains(r.getGroupId())) + .collect(Collectors.toList()); + newRelIsMain = IS_MAIN; } - // ③ 删除源学校范围内全部关系行(仅源学校,其他学校保留) - List toDelete = rels.stream() - .filter(r -> fromScope.contains(r.getGroupId())) - .collect(Collectors.toList()); 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 spec = buildSpecification(query); Page 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 attachMainIdentityNames(Page page) { + List content = page.getContent(); + if (content.isEmpty()) { + return page; + } + List userIds = content.stream() + .map(SysUserVo::getId) + .collect(Collectors.toList()); + Map 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 resolveMainRoleRelsByUser(List 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 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 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) 防重)。空数组/空列表语义 = 清空该校身份(幂等)。
+ * 保存完成后统一对齐(清理学校集合外身份行 + 激活身份滞留检查)。主身份已去字段化,不再写 is_main。 + */ + private void saveSchoolIdentity(Integer userId, Integer schoolId, List roleIds) { + validateRoleIds(roleIds); + sysUserRoleRelRepository.deleteByUserIdAndSchoolId(userId, schoolId); + if (roleIds != null && !roleIds.isEmpty()) { + LocalDateTime now = LocalDateTime.now(); + List 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 共用):
+ * ① 学校集合推导:全部组关系(含「转出」标记行——软标记期保留原校身份)逐条解析 schoolId 去重;
+ * ② 删除不在集合内的身份行(孤儿清理:退组/转校后身份行同步清出);
+ * ③ 当前激活身份滞留检查:切换记录指向的学校已不在集合(退组/转学)→ 删除该记录, + * 避免 getCurrent/boa.school 继续返回旧校(「激活身份不滞留旧校」)。
+ * 主身份已去字段化(读时计算),此处不再维护 is_main 物化列。 + */ + private void alignIdentity(Integer userId) { + Set schoolSet = deriveSchoolSet(userId); + + List rels = sysUserRoleRelRepository.findByUserId(userId); + List 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 去重。
+ * 不过滤 {@code sys_user_group_rel.status}:「转出」仅是流程标记,转入完成(transferToSchool + * 物理删除源校关系)后才视为真正离开;标记期内保留原校身份与业务现状一致。 + */ + private Set deriveSchoolSet(Integer userId) { + Set set = new HashSet<>(); + for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) { + set.add(sysGroupService.resolveSchoolId(rel.getGroupId())); + } + return set; + } + @Override @Transactional - public void changeGroups(List userIds, List groupIds) { + public void changeGroups(List userIds, List 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 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 oldRels = sysUserGroupRelRepository.findByUserId(userId); Map 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 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); } + /** + * 主归属组三级降级决策(纯函数,便于单测):
+ * ① explicitMainGroupId 非空 → 直接返回(调用方已校验 ∈ groupIds);
+ * ② oldMainRel 非空且其组 ∈ groupIds → 沿用旧主组(只要用户没把主组勾掉,主组不动);
+ * ③ 否则 → groupIds 首项(现状行为兜底)。
+ * groupIds 非空由调用方保证。 + */ + static Integer decideEffectiveMainGroupId(Integer explicitMainGroupId, List 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 userMainGroup = resolveUserMainGroup(userIds); + // 2.1 批量取每个用户的主校身份行(动态解析主身份),用于填充「用户身份」列(roles 列已停写,不再作为真源) + Map mainRelsByUser = resolveMainRoleRelsByUser(userIds); + // 3. 批量取组名 Map 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 resolveUserMainGroup(List userIds) { Map mainGroup = new HashMap<>(); if (userIds.isEmpty()) { return mainGroup; } - List rels = sysUserGroupRelRepository.findByUserIdIn(userIds); - Map> byUser = rels.stream() - .collect(Collectors.groupingBy(SysUserGroupRel::getUserId)); - for (Map.Entry> e : byUser.entrySet()) { - Integer gid = e.getValue().stream() - .filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN) - .map(SysUserGroupRel::getGroupId) - .findFirst() - .orElse(e.getValue().get(0).getGroupId()); - mainGroup.put(e.getKey(), gid); - } + 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 result = new ArrayList<>(); - // 主归属组(学校/部门)上下文:schId/schName - Integer mainGroupId = null; - String mainGroupName = null; - List groupRels = sysUserGroupRelRepository.findByUserId(userId); - for (SysUserGroupRel gr : groupRels) { - if (gr.getIsMain() != null && gr.getIsMain() == IS_MAIN) { - mainGroupId = gr.getGroupId(); - break; - } - } - if (mainGroupId != null) { - SysGroup mainGroup = sysGroupRepository.findById(mainGroupId).orElse(null); - if (mainGroup != null) { - mainGroupName = mainGroup.getName(); - } - } - // 业务角色:用户-角色关系 → 角色名称 - List rels = sysUserAppRoleRelRepository.findByUserId(userId); - for (SysUserAppRoleRel rel : rels) { - if (rel.getRoleId() == null) { - continue; - } - SysAppRole role = sysAppRoleRepository.findById(rel.getRoleId()).orElse(null); - if (role == null) { - continue; - } + // 数据源:sys_user_role_rel(平台身份角色权威源),内存排序 主身份优先 → sortNo → id + // (修正:此前误读 sys_user_app_role_rel 业务权限角色 + 主归属组当学校,导致「本组」bug) + List rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL); + SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId); + List 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 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 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 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))); diff --git a/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java index f6c5949..8d91adb 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java @@ -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 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 字段语义=当前激活学校:
* ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);
- * ② 无切换记录时兜底取 {@code sys_user_role_rel.is_main=1} 的整条记录(保证是合法存在的身份, - * 避免「主组+主角色」组合出 B 学校老师的非法身份);
- * ③ schools:用户在全部学校下的可选身份去重 schoolId(>0)集合。 + * ② 无切换记录时兜底取主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行, + * 保证是合法存在的身份,避免「主组+主角色」组合出 B 学校老师的非法身份);
+ * ③ currentRoleId:优先当前激活记录 roleId,兜底主身份行 roleId(本期补码,此前从未填充);
+ * ④ schools:用户在全部学校下的可选身份去重 schoolId(>0)集合组装 BoaGroup 列表(本期补码)。
+ * 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 schoolIds = sysUserRoleRelRepository + .findDistinctSchoolIdsByUserIdAndStatus(userId, StatusEnum.NORMAL); + if (schoolIds != null && !schoolIds.isEmpty()) { + List 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) */ diff --git a/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java b/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java index 4596df5..3cc315f 100644 --- a/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java +++ b/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java @@ -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 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()); } // ===================== 公共工具 ===================== diff --git a/src/main/java/com/yunhai/userbase/service/resolve/MainGroupResolver.java b/src/main/java/com/yunhai/userbase/service/resolve/MainGroupResolver.java new file mode 100644 index 0000000..f5d1060 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/resolve/MainGroupResolver.java @@ -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 最小」,消除各处「第一条」顺序依赖。
+ * 纯解析 + 单仓储依赖,不解析 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; + + /** + * 已持有组关系列表时的纯函数解析(零查询),供已查列表的调用方。
+ * 契约: + *
    + *
  1. rels 为 null/空 → null;
  2. + *
  3. 存在 isMain=1 → 取其中 id 最小的一条(脏数据多主时行为确定);
  4. + *
  5. 无 isMain=1 → 取 id 最小的一条(数据异常兜底)。
  6. + *
+ * groupId 为 null 的关系行视为脏数据,不参与选择(避免下游 NPE)。 + */ + public SysUserGroupRel resolve(List rels) { + if (rels == null || rels.isEmpty()) { + return null; + } + List valid = rels.stream() + .filter(r -> r.getGroupId() != null) + .collect(Collectors.toList()); + if (valid.isEmpty()) { + return null; + } + List mains = valid.stream() + .filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN) + .collect(Collectors.toList()); + List 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 resolve(Integer userId) { + if (userId == null) { + return Optional.empty(); + } + return Optional.ofNullable(resolve(sysUserGroupRelRepository.findByUserId(userId))); + } + + /** 批量解析(一次 IN 查询):searchUsers / 列表 / 导出用,无 N+1;无组关系的用户不出现在结果中 */ + public Map resolveByUserIds(List userIds) { + Map result = new HashMap<>(); + if (userIds == null || userIds.isEmpty()) { + return result; + } + List rels = sysUserGroupRelRepository.findByUserIdIn(userIds); + Map> byUser = rels.stream() + .filter(r -> r.getUserId() != null) + .collect(Collectors.groupingBy(SysUserGroupRel::getUserId)); + for (Map.Entry> e : byUser.entrySet()) { + SysUserGroupRel main = resolve(e.getValue()); + if (main != null) { + result.put(e.getKey(), main); + } + } + return result; + } +} diff --git a/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java b/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java new file mode 100644 index 0000000..ab6c313 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java @@ -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。

+ * + * 规则:遍历 sys_user,解析 roles 数组 → 取主归属组(MainGroupResolver 统一语义)按 schoolId 解析规则 + * 得到身份归属 → 写入 role_rel(roles 全集)。主身份已去字段化(读时计算),不再写 is_main。
+ * 非主学校身份留空,由后续编辑按校补充;无组关系用户跳过并计数(理论上不存在:退组即删户)。

+ * + * 启用方式(默认关闭,勿写进配置文件):
+ * {@code java -jar userbase.jar --userbase.identity-backfill.enabled=true}
+ * 执行顺序:先测试环境执行并核对日志中主组与身份归属一致,再上生产执行。幂等:已有 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 users = sysUserRepository.findAll(); + int inserted = 0; + int skippedNoRoles = 0; + int skippedNoGroup = 0; + int skippedExisting = 0; + + // 已有身份行的用户集合(幂等:重复执行不覆盖) + List existingUserIds = sysUserRoleRelRepository.findAll().stream() + .map(SysUserRoleRel::getUserId) + .distinct() + .collect(Collectors.toList()); + java.util.Set existingSet = new java.util.HashSet<>(existingUserIds); + + // 主组名批量取,用于核对日志 + Map 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 roles = user.getRoles(); + if (roles == null || roles.isEmpty()) { + skippedNoRoles++; + continue; + } + if (existingSet.contains(user.getId())) { + skippedExisting++; + continue; + } + List 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 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 flattenGroupNames(com.yunhai.platform.utils.model.BoaGroup root) { + Map map = new java.util.HashMap<>(); + java.util.ArrayDeque 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/task/MainIdentityConsistencyCheckTask.java b/src/main/java/com/yunhai/userbase/task/MainIdentityConsistencyCheckTask.java new file mode 100644 index 0000000..c4db05b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/task/MainIdentityConsistencyCheckTask.java @@ -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。
+ * 实体与仓储已删 is_main(去字段化),本任务用 JdbcTemplate 原生 SQL 直读遗留列(阶段 1-2 列仍在)。

+ * + * 启用方式(默认关闭,勿写进配置文件):
+ * {@code java -jar userbase.jar --userbase.main-identity-check.enabled=true}
+ * 执行顺序:先测试环境执行并清零/确认差异,再上生产;生产差异清零后才排期删列。 + * + * @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 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 page = userIds.subList(from, Math.min(from + PAGE_SIZE, userIds.size())); + Map oldMains = queryOldMain(page); + Map 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 queryOldMain(List userIds) { + String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?")); + List 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 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; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a804672..97e719e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java new file mode 100644 index 0000000..f62a3af --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java @@ -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 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 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 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 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()); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIdentityTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIdentityTest.java new file mode 100644 index 0000000..80fc960 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIdentityTest.java @@ -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 核心规则)。 + *
规则:沿祖先链(含自身)——① 最近的学校组 → 该校组 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 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 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 result = sysGroupService.resolveSchoolIds(Collections.singletonList(999)); + assertEquals(Integer.valueOf(100), result.get(999)); + } + + /** 空入参 → 空映射 */ + @Test + void resolveSchoolIds_emptyInputReturnsEmptyMap() { + assertTrue(sysGroupService.resolveSchoolIds(Collections.emptyList()).isEmpty()); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java new file mode 100644 index 0000000..73c91bc --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java @@ -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 oldRels = Arrays.asList(oldMain, oldExtra); + SysUserGroupRel newRel = new SysUserGroupRel(); + newRel.setUserId(1); + newRel.setGroupId(30); + List 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> groupRelCaptor = ArgumentCaptor.forClass(List.class); + verify(sysUserGroupRelRepository).saveAll(groupRelCaptor.capture()); + List savedRels = groupRelCaptor.getValue(); + assertEquals(1, savedRels.size()); + assertEquals(Integer.valueOf(1), savedRels.get(0).getIsMain()); + + // ② 孤儿身份行(旧校 16)删除、主校 30 行保留(identity 断言,规避实体 equals 恒真问题) + ArgumentCaptor deletedCaptor = ArgumentCaptor.forClass(SysUserRoleRel.class); + verify(sysUserRoleRelRepository).delete(deletedCaptor.capture()); + assertSame(orphan, deletedCaptor.getValue()); + ArgumentCaptor> roleRelCaptor = ArgumentCaptor.forClass(List.class); + verify(sysUserRoleRelRepository).saveAll(roleRelCaptor.capture()); + List remaining = roleRelCaptor.getValue(); + assertEquals(1, remaining.size()); + assertSame(kept, remaining.get(0)); + + // ③ 激活身份滞留清理:current 指向旧校 16(不在新 schoolSet {30})→ 删除 + verify(sysUserCurrentRoleRepository).delete(current); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/resolve/MainGroupResolverTest.java b/src/test/java/com/yunhai/userbase/service/resolve/MainGroupResolverTest.java new file mode 100644 index 0000000..6d74853 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/resolve/MainGroupResolverTest.java @@ -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) null)); + } + + @Test + void resolve_whenNoMain_shouldPickMinId() { + // 全部非主:取 id 最小(第一条顺序依赖被消除) + List 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 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 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 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 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)); + } +}