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/entity/SysUserRoleRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java
new file mode 100644
index 0000000..07cb6a6
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/entity/SysUserRoleRel.java
@@ -0,0 +1,86 @@
+package com.yunhai.userbase.entity;
+
+import com.yunhai.userbase.common.converter.StatusEnumConverter;
+import com.yunhai.userbase.enums.StatusEnum;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.*;
+import javax.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+
+/**
+ * @author chenx
+ * @date 2026/8/18
+ * @description 用户-SysRoleEnum角色-学校 三元关联(平台身份角色的权威源)。
+ * 区别于 {@link SysUserAppRoleRel}(业务权限角色):本表表达"用户在哪个学校下激活哪个平台身份角色"
+ * (如"刘文娟在 A 学校是老师"),角色取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key。
+ * 不继承 BaseEntity:高频读写关系表,自定义字段更轻量,与 {@link SysUserGroupRel} 风格一致。
+ */
+@Data
+@NoArgsConstructor
+@Entity
+@Table(
+ name = "sys_user_role_rel",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"userId", "roleId", "schoolId"}),
+ indexes = {
+ @Index(columnList = "userId"),
+ @Index(columnList = "schoolId"),
+ @Index(columnList = "roleId")
+ }
+)
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class SysUserRoleRel {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @NotNull(groups = Update.class)
+ private Integer id;
+
+ /**
+ * 用户ID
+ */
+ @Column(nullable = false)
+ private Integer userId;
+
+ /**
+ * 角色ID(SysRoleEnum.key,1-7)
+ */
+ @Column(nullable = false)
+ private Integer roleId;
+
+ /**
+ * 学校组ID(=type=SCHOOL_GROUP 的 sys_group.id);区域/学段/根直属用户填 0
+ */
+ @Column(nullable = false)
+ private Integer schoolId;
+
+ /**
+ * 是否主身份标记:每 userId 最多 1 条 isMain=1(应用层校验,不靠 uk)
+ */
+ @Column(nullable = false)
+ private Integer isMain = 0;
+
+ /**
+ * 排序号:越小越靠前
+ */
+ @Column(name = "sort_no", nullable = false)
+ private Integer sortNo = 0;
+
+ /**
+ * 状态:0=禁用 1=正常(StatusEnum)
+ */
+ @Convert(converter = StatusEnumConverter.class)
+ @Column(nullable = false)
+ private StatusEnum status = StatusEnum.NORMAL;
+
+ @Column(name = "create_time", nullable = false, updatable = false)
+ private LocalDateTime createTime;
+
+ @Column(name = "update_time", nullable = false)
+ private LocalDateTime updateTime;
+
+ public @interface Update {
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java b/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java
new file mode 100644
index 0000000..60adcaa
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/identity/IdentityCurrentVo.java
@@ -0,0 +1,32 @@
+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
new file mode 100644
index 0000000..c06e657
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/identity/IdentityOptionVo.java
@@ -0,0 +1,33 @@
+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/IdentitySwitchRequest.java
new file mode 100644
index 0000000..f7f88d7
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/identity/IdentitySwitchRequest.java
@@ -0,0 +1,25 @@
+package com.yunhai.userbase.model.identity;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * 切换身份入参:指定目标学校与该学校下的角色。
+ * 服务端校验 (userId, roleId, schoolId) 在 {@code sys_user_role_rel} 中存在且正常态,
+ * 不存在则拒绝(防止越权切换到非授权身份)。
+ *
+ * @author chenx
+ * @date 2026/8/18
+ */
+@Data
+public class IdentitySwitchRequest {
+
+ /** 目标角色ID(SysRoleEnum.key) */
+ @NotNull(message = "角色ID不能为空")
+ private Integer roleId;
+
+ /** 目标学校ID(=学校组 sys_group.id;无学校维度传 0) */
+ @NotNull(message = "学校ID不能为空")
+ private Integer schoolId;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserMoveRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserMoveRequest.java
new file mode 100644
index 0000000..abad1a7
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserMoveRequest.java
@@ -0,0 +1,24 @@
+package com.yunhai.userbase.model.sysuser;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * @description 开放接口:移动用户到指定组请求(语义同后台「调整组」——覆盖式把用户组归属替换为目标组,目标设为主组)。
+ */
+@Data
+public class SysUserMoveRequest {
+
+ /** 待移动用户ID。 */
+ @NotNull(message = "用户ID不能为空")
+ private Integer userId;
+
+ /** 目标组ID(移动后的主归属组,必须存在)。 */
+ @NotNull(message = "目标组ID不能为空")
+ private Integer groupId;
+
+ /** 学校组ID:限定移动范围,仅允许将用户在同一学校(组)范围内移动。 */
+ @NotNull(message = "学校ID不能为空")
+ private Integer schoolId;
+}
diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java
new file mode 100644
index 0000000..bd79400
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/repository/SysUserCurrentRoleRepository.java
@@ -0,0 +1,17 @@
+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 java.util.Optional;
+
+/**
+ * @author chenx
+ * @date 2026/8/18
+ * @description 用户当前激活身份仓储(一对一:user_id 唯一)
+ */
+public interface SysUserCurrentRoleRepository extends JpaRepository, JpaSpecificationExecutor {
+
+ Optional findByUserId(Integer userId);
+}
diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java
new file mode 100644
index 0000000..44b7af7
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java
@@ -0,0 +1,39 @@
+package com.yunhai.userbase.repository;
+
+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.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * @author chenx
+ * @date 2026/8/18
+ * @description 用户-SysRoleEnum角色-学校 关联仓储(平台身份角色权威源)
+ */
+public interface SysUserRoleRelRepository extends JpaRepository, JpaSpecificationExecutor {
+
+ /** 用户全部可选身份(含禁用态由调用方过滤) */
+ List findByUserId(Integer userId);
+
+ /** 用户某状态下的全部可选身份 */
+ List findByUserIdAndStatus(Integer userId, StatusEnum status);
+
+ /** 用户主身份(isMain=1),最多 1 条 */
+ Optional findByUserIdAndIsMain(Integer userId, Integer isMain);
+
+ /** 用户在某学校下、某状态下的全部角色关联 */
+ List findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status);
+
+ /** 用户所有正常态的 roleId 去重列表(用于 BoaUser.roleIds) */
+ @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);
+
+ /** 用户所有正常态的 schoolId 去重列表(用于 BoaUser.schools,schoolId>0 的过滤在调用方做) */
+ @Query("SELECT DISTINCT r.schoolId FROM SysUserRoleRel r WHERE r.userId = :userId AND r.status = :status")
+ List findDistinctSchoolIdsByUserIdAndStatus(@Param("userId") Integer userId, @Param("status") StatusEnum status);
+}
diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java
new file mode 100644
index 0000000..ecbdd3e
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java
@@ -0,0 +1,146 @@
+package com.yunhai.userbase.service.impl;
+
+import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.userbase.entity.SysGroup;
+import com.yunhai.userbase.entity.SysUserCurrentRole;
+import com.yunhai.userbase.entity.SysUserRoleRel;
+import com.yunhai.userbase.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.SysUserRoleRelRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * 用户身份服务:管理「平台身份角色 × 学校」维度的当前激活状态与可选清单。
+ * 身份 = (userId, roleId, schoolId) 三元组,权威源为 {@code sys_user_role_rel};
+ * 当前激活状态存 {@code sys_user_current_role}(1:1,每用户一条)。
+ * 本服务不处理业务权限角色(sys_app_role),那套与学校维度无关。
+ *
+ * @author chenx
+ * @date 2026/8/18
+ */
+@Service
+public class IdentityService {
+
+ @Autowired
+ private SysUserRoleRelRepository sysUserRoleRelRepository;
+ @Autowired
+ private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
+ @Autowired
+ private SysGroupRepository sysGroupRepository;
+
+ /**
+ * 用户全部可选身份(正常态),用于「切换身份」页渲染。
+ */
+ 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);
+ }
+ return result;
+ }
+
+ /**
+ * 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(isMain=1)。
+ */
+ public IdentityCurrentVo 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;
+ }
+ // 兜底:主身份整条记录(isMain=1)
+ SysUserRoleRel mainRel = sysUserRoleRelRepository.findByUserIdAndIsMain(userId, 1).orElse(null);
+ 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;
+ }
+ // 连主身份都没有(测试期初数据尚未建立):返回空对象,前端引导创建身份
+ vo.setFallback(true);
+ return vo;
+ }
+
+ /**
+ * 切换身份:校验 (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();
+ // 校验目标身份确属该用户且正常态,防越权切换
+ SysUserRoleRel rel = sysUserRoleRelRepository
+ .findByUserIdAndSchoolIdAndStatus(userId, schoolId, StatusEnum.NORMAL)
+ .stream()
+ .filter(r -> Objects.equals(r.getRoleId(), roleId))
+ .findFirst()
+ .orElse(null);
+ if (rel == null) {
+ throw new DException("目标身份不存在或非正常态,无法切换");
+ }
+
+ SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null);
+ if (current == null) {
+ current = new SysUserCurrentRole();
+ current.setUserId(userId);
+ }
+ current.setRoleId(roleId);
+ current.setSchoolId(schoolId);
+ current.setSchoolName(schoolName(schoolId));
+ 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;
+ }
+
+ private String roleName(Integer roleId) {
+ if (roleId == null) {
+ return null;
+ }
+ SysRoleEnum e = IEnum.getByKey(SysRoleEnum.class, roleId);
+ return e != null ? e.getValue() : null;
+ }
+
+ private String schoolName(Integer schoolId) {
+ if (schoolId == null || schoolId <= 0) {
+ return null;
+ }
+ SysGroup g = sysGroupRepository.findById(schoolId).orElse(null);
+ return g != null ? g.getName() : null;
+ }
+}
diff --git a/src/main/resources/sql/fix_roles_mapping.sql b/src/main/resources/sql/fix_roles_mapping.sql
new file mode 100644
index 0000000..b224e4b
--- /dev/null
+++ b/src/main/resources/sql/fix_roles_mapping.sql
@@ -0,0 +1,78 @@
+-- ============================================================
+-- 修复 sys_user.roles 旧映射 -> SysRoleEnum 新映射
+-- ------------------------------------------------------------
+-- 背景:
+-- 前端表单与导出注释历史上使用「旧映射」:
+-- 1=管理员 2=老师 3=校领导 4=局领导 5=学生 6=家长 7=工作号
+-- 而后端权威枚举 SysRoleEnum 现为:
+-- 1=老师 2=校领导 3=学生 4=家长 5=局领导 6=工作号 7=管理员
+-- 旧表单勾选「老师」会提交 value=2,被后端按 SysRoleEnum 存成「校领导」,
+-- 于是教师/校领导的 roles 出现 [2,3] 这类错位值。
+--
+-- 本脚本把存量 roles 从「旧映射」逐值改写为 SysRoleEnum 真值。
+-- 前端已改为直接吃 GetSysDict.sysrole(SysRoleEnum 真源),新数据不会再错位。
+--
+-- ⚠️ 重要前提(执行前必读):
+-- 1) 本脚本假设 sys_user.roles 的全部现有值都是「旧映射」格式。
+-- 若部分用户曾通过「批量导入」创建(导入路径一直用 SysRoleEnum 新值),
+-- 这些行已是新格式,执行本脚本会将其再次错位 —— 必须先跑下面的校验 SQL 确认。
+-- 2) 必须先执行备份语句;本脚本不自动执行,请由人工在数据库上运行。
+-- 3) roles 列在 MySQL 中存为 TEXT(内容为 JSON 数组串,如 "[2,3]"),
+-- 下方 UPDATE 用 MySQL 8.0 的 JSON_TABLE / JSON_ARRAYAGG,请确认数据库为 8.0。
+-- ============================================================
+
+-- ---------- 第 0 步:备份(务必先执行,可逆) ----------
+CREATE TABLE sys_user_roles_bak_20260817 AS
+SELECT id, roles, type FROM sys_user;
+
+-- ---------- 第 1 步:只读校验,看清现有格式 ----------
+-- 重点看 distinct roles 的值:
+-- 若看到 [2,3] / [1] / [7] 这类(旧映射含义:老师/管理员/工作号),说明是旧格式;
+-- 若看到 [1,2](新映射含义:老师+校领导),说明已是新格式。
+-- 只有当所有行都符合「旧格式」语义时,才执行第 2 步。
+SELECT roles, COUNT(*) AS cnt
+FROM sys_user
+WHERE roles IS NOT NULL AND roles <> '' AND roles <> '[]'
+GROUP BY roles
+ORDER BY cnt DESC;
+
+-- ---------- 第 2 步:旧映射 -> SysRoleEnum 新映射 逐值替换 ----------
+-- 旧/新 双射:
+-- 1(管理员)->7 2(老师)->1 3(校领导)->2 4(局领导)->5
+-- 5(学生)->3 6(家长)->4 7(工作号)->6
+UPDATE sys_user u
+SET u.roles = (
+ SELECT JSON_ARRAYAGG(
+ CASE j.r
+ WHEN 1 THEN 7 -- 旧:管理员 -> 新:管理员
+ WHEN 2 THEN 1 -- 旧:老师 -> 新:老师
+ WHEN 3 THEN 2 -- 旧:校领导 -> 新:校领导
+ WHEN 4 THEN 5 -- 旧:局领导 -> 新:局领导
+ WHEN 5 THEN 3 -- 旧:学生 -> 新:学生
+ WHEN 6 THEN 4 -- 旧:家长 -> 新:家长
+ WHEN 7 THEN 6 -- 旧:工作号 -> 新:工作号
+ ELSE j.r
+ END
+ )
+ FROM JSON_TABLE(CAST(u.roles AS JSON), '$[*]' COLUMNS (r INT PATH '$')) AS j
+)
+WHERE u.roles IS NOT NULL AND u.roles <> '' AND u.roles <> '[]';
+
+-- ---------- 第 3 步(可选):重算 type 列(用户类型) ----------
+-- type 由 resolveUserType 推导:含 ADMIN(7) -> 2(学校管理员),否则 0(普通用户);
+-- 超管账号(id 10000~10002, type=1)保持不变。旧数据里 type 曾被旧映射带偏,这里一并纠正。
+UPDATE sys_user
+SET type = 2
+WHERE type <> 1
+ AND JSON_CONTAINS(CAST(roles AS JSON), JSON_ARRAY(7));
+
+UPDATE sys_user
+SET type = 0
+WHERE type <> 1
+ AND NOT JSON_CONTAINS(CAST(roles AS JSON), JSON_ARRAY(7));
+
+-- ---------- 回滚(如需) ----------
+-- INSERT INTO sys_user (id, roles, type)
+-- SELECT id, roles, type FROM sys_user_roles_bak_20260817
+-- ON DUPLICATE KEY UPDATE roles = VALUES(roles), type = VALUES(type);
+-- DROP TABLE sys_user_roles_bak_20260817;