feat(dept): CUSTOM 自定义部门与统一部门目录契约(双来源部门二期)
build / build-push (push) Successful in 59s

- 三表 ub_custom_dept_category/ub_custom_department/ub_custom_department_member(DDL 入 service/sql,160 待人工执行)
- 实体/仓库/服务:学校隔离(AccessScopeService 新增 requireSchoolInScope/resolveWritableSchoolId)、
  del_marker=id 软删让位 uk、成员=本校教职工黑名单口径(排除学生组 type=8 与禁用账号)
- 管理端 /api/deptcategory、/api/customdept 共 17 端点(含 CandidateUsers 候选口)
- 开放端 /oapi/departments 四口:目录/分类/resolve-batch/成员(scope 缺省=select 严档),
  应用×学校授权走 sys_group_app_rel(仅验签不放行跨校)
- SysGroupService.changeIncludeChildren 补学校组类型校验
- 新增单测 83 例全绿(部门26/分类12/目录25/schoolguard12/includeChildren8)
This commit is contained in:
chenx
2026-10-22 09:35:37 +08:00
parent bf3f3344c1
commit b1b769a1b8
36 changed files with 4156 additions and 1 deletions
@@ -0,0 +1,38 @@
-- =====================================================================
-- 部门管理 ·「包含子组」落库(方案:部门管理-包含子组落库方案.md)
-- sys_group 增列 include_children:学校节点上的一个开关,
-- 1 = 部门列表连同下级部门一起列出(默认)/ 0 = 只列一级部门
-- 交付文件,不在本机执行;由人工在 160 执行
-- 执行前请备份;JPA 实体已同名映射,执行后重启后端即可生效
-- =====================================================================
-- ---------------------------------------------------------------------------
-- 1. 增列 include_children
-- 粒度:1 学校 : 1 值,与 status 同为节点自身属性,故直接挂在 sys_group,不另建表
-- 默认 1:与升级前的页面表现一致,存量学校无需回填
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动加列,但**不带** NOT NULL/DEFAULT/COMMENT,
-- 存量会留 NULL。因此本脚本建议先执行,保证列定义可控;即使漏执行,前端已按 null = 含子组 兜底
-- ---------------------------------------------------------------------------
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sys_group'
AND COLUMN_NAME = 'include_children');
SET @sql := IF(@col = 0,
'ALTER TABLE sys_group ADD COLUMN include_children TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''包含子组:1=部门列表含下级部门,0=仅本级'' AFTER status',
'SELECT ''include_children already exists'' AS msg');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------------
-- 2. 核验(可选)
-- ---------------------------------------------------------------------------
-- SHOW COLUMNS FROM sys_group LIKE 'include_children';
-- SELECT COUNT(*) AS total, SUM(include_children = 1) AS include_on FROM sys_group;
-- ---------------------------------------------------------------------------
-- 回滚(仅当确认不再需要该能力时执行;只加列不改列,代码回滚无需删列)
-- ---------------------------------------------------------------------------
-- ALTER TABLE sys_group DROP COLUMN include_children;
@@ -0,0 +1,102 @@
-- =====================================================================
-- UserBase 双来源部门 · CUSTOM 侧建表(任务书 USERBASE_DEPARTMENT_QODER_TASK.md T1 / 规格书 UB-1)
-- 三张新表:ub_custom_dept_category(自定义分类)/ ub_custom_department(自定义部门)
-- / ub_custom_department_member(部门成员关系)
-- 交付文件,不在本机执行;由人工在 160 执行
-- 执行前请备份;JPA 实体与列同名映射,执行后重启后端即可生效
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动建表,但**不带** NOT NULL/DEFAULT/COMMENT/唯一键,
-- 且 del_marker 之类参与唯一约束的列必须由本脚本定义,故务必先执行本脚本
-- 口径:school_id = sys_group 中 type=2(学校组)节点的 id,与 AccessScopeService.scopeGroupIds() 同域
-- =====================================================================
-- ---------------------------------------------------------------------------
-- 1. 自定义分类(学校的容器层,本身无启停、无成员)
-- 名称唯一:del_marker 技巧——MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住「软删后再激活同名」,
-- 故软删时把 del_marker 置为该行 id,未删行恒为 0,同校同名只能有一条**有效**行;
-- name 列永不改写(软删保留原名,保证历史解析拿到原名、旧引用不指向新对象)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_dept_category (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(不可变稳定 ID)',
school_id INT NOT NULL COMMENT '学校组 id(sys_group.type=2 的节点 id)',
name VARCHAR(64) NOT NULL COMMENT '分类名称,学校自定;永不改写',
sort_no INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(不是业务状态)',
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键,保证同名只挡有效行',
deleted_at DATETIME NULL COMMENT '软删时间',
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_school_name_marker (school_id, name, del_marker),
KEY idx_school_sort (school_id, is_deleted, sort_no)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门分类(学校级容器,非可选部门)';
-- ---------------------------------------------------------------------------
-- 2. 自定义部门(可配成员、可启停的实际可选对象)
-- 唯一键=同校同分类下未软删的部门名不重复;跨分类、SYSTEM 与 CUSTOM 之间允许重名(A06)
-- enabled 语义直读 1=启用/0=停用,**与 sys_group.status(0=启用/1=停用)方向相反,严禁混用**
-- 软删不级联删成员:成员关系保留在库,由查询侧按部门 is_deleted 过滤(A12)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_department (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(CUSTOM 侧独立 ID,与 SYSTEM 组 id 不同域,引用须带 departmentType)',
school_id INT NOT NULL COMMENT '学校组 id(冗余自分类,写口服务端复核)',
category_id INT NOT NULL COMMENT '所属分类 ub_custom_dept_category.id,必须同校',
name VARCHAR(64) NOT NULL COMMENT '部门名称,学校自定;永不改写',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同分类内展示顺序,越小越靠前',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启停:1=启用 / 0=停用(新建默认启用)',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(删除不是第三种业务状态)',
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键',
deleted_at DATETIME NULL COMMENT '软删时间',
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_school_category_name_marker (school_id, category_id, name, del_marker),
KEY idx_category_list (category_id, is_deleted, sort_no),
KEY idx_school_list (school_id, is_deleted, enabled)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学校自定义部门(与 SYSTEM 组织树完全分离)';
-- ---------------------------------------------------------------------------
-- 3. 自定义部门成员关系(引用 sys_user.id,不复制账号)
-- 移除成员=物理删该关系行(本仓无软删惯例,增删审计靠 @OperationLog),不得残留有效成员
-- uk 挡不住并发重复插入之外的场景(如软删部门后重新激活)由服务端逐个复核兜底
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_department_member (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
school_id INT NOT NULL COMMENT '学校组 id(与部门同校,写口服务端复核)',
department_id INT NOT NULL COMMENT 'ub_custom_department.id',
user_id INT NOT NULL COMMENT 'sys_user.id(本校教职工,排除学生组成员)',
create_user INT NOT NULL COMMENT '添加人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '添加时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_department_user (department_id, user_id),
KEY idx_department (department_id),
KEY idx_school (school_id),
KEY idx_user (user_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门成员关系(一个用户可属同校多个部门)';
-- ---------------------------------------------------------------------------
-- 4. 核验(可选,执行后逐条确认)
-- ---------------------------------------------------------------------------
-- SHOW CREATE TABLE ub_custom_dept_category;
-- SHOW CREATE TABLE ub_custom_department;
-- SHOW CREATE TABLE ub_custom_department_member;
-- SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'ub_custom%';
-- 唯一键生效自检(手工插入应报 1062):
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW());
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW()); -- 期望失败
-- DELETE FROM ub_custom_dept_category WHERE school_id = 1 AND name = '自检分类';
-- ---------------------------------------------------------------------------
-- 回滚(本期未上线,回滚即删表;CUSTOM 数据不与 sys_group 有任何写耦合)
-- ---------------------------------------------------------------------------
-- DROP TABLE IF EXISTS ub_custom_department_member;
-- DROP TABLE IF EXISTS ub_custom_department;
-- DROP TABLE IF EXISTS ub_custom_dept_category;
@@ -0,0 +1,74 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.service.ICustomDeptCategoryService;
import io.swagger.v3.oas.annotations.Operation;
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.PathVariable;
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.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理(CUSTOM 侧,部门管理页左侧平铺列表的数据源)。<br>
* 动词式端点、{@code /api} 前缀与 {@code SysGroupController} 同范式;
* 四级访问范围与跨校拦截在 Service 内统一执行(写口只认登录身份,不认请求体 schoolId)。<br>
* 分类是容器:无启停、无成员;删除只有软删,且非空分类明确拒绝(无级联)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/deptcategory/")
public class CustomDeptCategoryController {
@Autowired
private ICustomDeptCategoryService customDeptCategoryService;
@Operation(operationId = "customDeptCategoryList")
@GetMapping("List")
public List<CustomDeptCategoryVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId) {
return customDeptCategoryService.list(schoolId);
}
@OperationLog("新增自定义部门分类")
@Operation(operationId = "customDeptCategoryAdd")
@PostMapping("Add")
public CustomDeptCategoryVo add(@RequestBody @Validated CustomDeptCategoryRequest request) {
return customDeptCategoryService.add(request);
}
@OperationLog("重命名自定义部门分类")
@Operation(operationId = "customDeptCategoryEdit")
@PostMapping("Edit/{id}")
public CustomDeptCategoryVo edit(@PathVariable Integer id,
@RequestBody @Validated CustomDeptCategoryRequest request) {
return customDeptCategoryService.rename(id, request);
}
@OperationLog("自定义部门分类排序")
@Operation(operationId = "customDeptCategorySort")
@PostMapping("Sort")
public void sort(@RequestBody @Validated CustomDeptCategorySortRequest request) {
customDeptCategoryService.sort(request);
}
/**
* 软删分类:分类下仍有有效部门时明确拒绝(A14),不做任何级联删除。
*/
@OperationLog("删除自定义部门分类")
@Operation(operationId = "customDeptCategoryDelete")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
customDeptCategoryService.softDelete(id);
}
}
@@ -0,0 +1,149 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import com.yunhai.userbase.service.ICustomDepartmentService;
import io.swagger.v3.oas.annotations.Operation;
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.PathVariable;
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.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理(CUSTOM 侧:部门本体 + 启停 + 软删 + 成员)。<br>
* 与 SYSTEM 组织树完全隔离:本控制器不读写 {@code sys_group} 的任何写路径,
* 也不提供删除 SYSTEM 部门的入口(任务书 D1)。<br>
* 跨校/学生成员等校验全部在 Service 服务端执行,前端传值只作核对。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/customdept/")
public class CustomDeptController {
@Autowired
private ICustomDepartmentService customDepartmentService;
@Operation(operationId = "customDeptList")
@GetMapping("List")
public List<CustomDepartmentVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId,
@RequestParam("categoryId") Integer categoryId) {
return customDepartmentService.list(schoolId, categoryId);
}
@OperationLog("新增自定义部门")
@Operation(operationId = "customDeptAdd")
@PostMapping("Add")
public CustomDepartmentVo add(@RequestBody @Validated CustomDepartmentRequest request) {
return customDepartmentService.add(request);
}
@OperationLog("重命名自定义部门")
@Operation(operationId = "customDeptEdit")
@PostMapping("Edit/{id}")
public CustomDepartmentVo edit(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentRequest request) {
return customDepartmentService.rename(id, request);
}
/**
* 移动至本校其他分类:目标分类跨校直接拒绝。
*/
@OperationLog("移动自定义部门")
@Operation(operationId = "customDeptMove")
@PostMapping("Move/{id}")
public CustomDepartmentVo move(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMoveRequest request) {
return customDepartmentService.move(id, request);
}
@OperationLog("自定义部门排序")
@Operation(operationId = "customDeptSort")
@PostMapping("Sort")
public void sort(@RequestBody @Validated CustomDepartmentSortRequest request) {
customDepartmentService.sort(request);
}
/**
* 停用自定义部门:单列 UPDATE enabled=0。<br>
* 停用只影响「选」(新业务选不到),不影响「看」与「办」(历史/在途仍可解析),与 SYSTEM 同口径。
*/
@OperationLog("停用自定义部门")
@Operation(operationId = "customDeptDisable")
@PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) {
customDepartmentService.changeEnabled(id, false);
}
/**
* 启用自定义部门:单列 UPDATE enabled=1,原 id 与成员保持不变(A11)。
*/
@OperationLog("启用自定义部门")
@Operation(operationId = "customDeptEnable")
@PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) {
customDepartmentService.changeEnabled(id, true);
}
/**
* 软删自定义部门:默认列表不再出现,历史引用仍可解析(A12);成员关系保留在库、由查询侧过滤。
*/
@OperationLog("删除自定义部门")
@Operation(operationId = "customDeptDelete")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
customDepartmentService.softDelete(id);
}
@Operation(operationId = "customDeptMembers")
@GetMapping("Members/{id}")
public List<CustomDepartmentMemberVo> members(@PathVariable Integer id) {
return customDepartmentService.members(id);
}
/**
* 可添加的候选教职工:本校教职工(服务端排除学生组)且尚未在该部门。<br>
* 现有 {@code GroupAuthz.GroupUsers} 是组维度、会带出学生组用户,不能当候选源,故单独成口(D4)。
*/
@Operation(operationId = "customDeptCandidateUsers")
@GetMapping("CandidateUsers/{id}")
public List<CustomDepartmentMemberVo> candidateUsers(@PathVariable Integer id,
@RequestParam(value = "keyword", required = false) String keyword) {
return customDepartmentService.candidateUsers(id, keyword);
}
/**
* 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,重复添加按幂等跳过(A07/A08)。
*/
@OperationLog("添加自定义部门成员")
@Operation(operationId = "customDeptAddMembers")
@PostMapping("AddMembers/{id}")
public void addMembers(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMemberRequest request) {
customDepartmentService.addMembers(id, request);
}
/**
* 移除成员:物理删关系行,不残留有效成员。
*/
@OperationLog("移除自定义部门成员")
@Operation(operationId = "customDeptRemoveMembers")
@PostMapping("RemoveMembers/{id}")
public void removeMembers(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMemberRequest request) {
customDepartmentService.removeMembers(id, request);
}
}
@@ -0,0 +1,166 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.service.IDepartmentDirectoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import lombok.extern.slf4j.Slf4j;
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.PathVariable;
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 javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 通用部门目录开放接口({@code /oapi/departments/...},规格书 UB-2)。<br>
* <br>
* 鉴权链路照 {@code /oapi/group} 现行做法:验签由 {@code OpenApiAuthEnforceConfiguration} 的
* {@code RequireAppSignFilter} 在过滤器层强制(无签名直接 401),本层不做验签;
* 但<b>验签只证明「是哪个应用」</b>,能否读这所学校由 {@code sys_group_app_rel}(应用在该校已开通)决定,
* 越校读取抛业务异常(A15)。<br>
* 引用一律 {@code schoolId + departmentType + departmentId} 三件套,禁止以名称做键。
*/
@Slf4j
@RestController
@RequestMapping(Config.OPEN_API_PATH + "departments")
public class DepartmentController {
/** 成员解析上下文:供新的指派/绑定(严档,也是缺省)。 */
private static final String SCOPE_SELECT = "select";
/** 成员解析上下文:已有实例的历史解析(宽档,停用/软删照发)。 */
private static final String SCOPE_HISTORY = "history";
@Autowired
private IDepartmentDirectoryService departmentDirectoryService;
@Autowired
private IApplicationInfoService applicationInfoService;
/**
* 可选部门目录:默认只返回启用且未软删的条目。<br>
* {@code type=SYSTEM|CUSTOM}(缺省两类都返回);{@code categoryId} 只对 CUSTOM 生效;
* {@code withChildren=true} 只对 SYSTEM 生效,连同部门下级一起返回(扁平 + parentId)。
*/
@GetMapping
public List<DepartmentEntryVo> list(@RequestParam("schoolId") Integer schoolId,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "categoryId", required = false) Integer categoryId,
@RequestParam(value = "withChildren", required = false) Boolean withChildren,
HttpServletRequest request) {
DepartmentTypeEnum parsed = parseType(type, true);
return departmentDirectoryService.list(resolveCallerAppId(request), schoolId, parsed,
categoryId, Boolean.TRUE.equals(withChildren));
}
/**
* CUSTOM 分类目录(学校通用,不按调用方应用隔离;分类只是容器,不能指派人员)。
*/
@GetMapping("categories")
public List<CustomDeptCategoryVo> categories(@RequestParam("schoolId") Integer schoolId,
HttpServletRequest request) {
return departmentDirectoryService.categories(resolveCallerAppId(request), schoolId);
}
/**
* 批量解析引用(<b>唯一</b>允许返回停用/软删部门的读口,A12)。<br>
* 一批里任一学校未对本应用开通 → 整批拒绝;解析不到的引用返回 name=null、selectable=false。
*/
@PostMapping("resolve-batch")
public List<DepartmentEntryVo> resolveBatch(@RequestBody @Validated List<DepartmentRefRequest> refs,
HttpServletRequest request) {
return departmentDirectoryService.resolveBatch(resolveCallerAppId(request), refs);
}
/**
* 部门成员解析。{@code scope=select}(缺省)=供新的指派:部门停用/软删直接拒绝;<br>
* {@code scope=history}=已有实例解析:停用/软删照常返回成员,不影响在途(A17)。<br>
* 出参字段与 {@code /oapi/group/users} 同形状(轻量 BoaUser,禁用账号靠 status=0 标识、不静默剔除)。
*/
@GetMapping("{type}/{id}/members")
public List<BoaUser> members(@PathVariable String type,
@PathVariable Integer id,
@RequestParam(value = "scope", required = false) String scope,
HttpServletRequest request) {
DepartmentTypeEnum parsed = parseType(type, false);
return departmentDirectoryService.members(resolveCallerAppId(request), parsed, id, parseScope(scope));
}
// ===================== 私有 =====================
/**
* 解析成员上下文:缺省与 {@code select} 都按「供新的指派」处理(更严的一档做默认),
* 只有明确 {@code history} 才放行停用/软删;其余取值直接拒绝——写错的 scope 不能静默变成宽口径。
*/
private boolean parseScope(String scope) {
String normalized = scope == null ? "" : scope.trim().toLowerCase();
if (normalized.isEmpty() || SCOPE_SELECT.equals(normalized)) {
return true;
}
if (SCOPE_HISTORY.equals(normalized)) {
return false;
}
throw new DException("scope 只允许 select / history");
}
/**
* 解析来源类型:路径段必解析(非法即拒),查询参数缺省表示「两类都要」。
*/
private DepartmentTypeEnum parseType(String raw, boolean allowMissing) {
if (raw == null || raw.trim().isEmpty()) {
if (allowMissing) {
return null;
}
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
DepartmentTypeEnum parsed = DepartmentTypeEnum.of(raw);
if (parsed == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
return parsed;
}
/**
* 从验签属性取 appKey,再映射为 application_info 主键(与 {@code oapi/user} 的 {@code modules} 同一链路)。<br>
* 解析不出应用 → 传 null 给 Service,由其按「无法识别调用方」拒绝:部门目录不能匿名兜底成「全量可读」。
*/
private Integer resolveCallerAppId(HttpServletRequest request) {
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
if (attr == null) {
log.warn("[oapi:departments] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
request.getRequestURI());
return null;
}
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
if (appKeyStr.trim().isEmpty()) {
log.warn("[oapi:departments] AppAuth attr is blank, uri={}", request.getRequestURI());
return null;
}
Integer appKey;
try {
appKey = Integer.parseInt(appKeyStr.trim());
} catch (NumberFormatException e) {
log.warn("[oapi:departments] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
return null;
}
Integer appId = applicationInfoService.findIdByAppKey(appKey);
if (appId == null) {
log.warn("[oapi:departments] appKey={} not found in application_info, uri={}", appKey,
request.getRequestURI());
}
return appId;
}
}
@@ -0,0 +1,71 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/10/21
* @description 学校自定义部门(CUSTOM 侧可指派对象:有成员、有启停、删除为软删)。<br>
* 与 SYSTEM 组织树完全分离:不改组织树、不混入自定义节点;
* 主键独立于 {@code sys_group.id},跨应用引用一律带 {@code departmentType} 消歧,禁止以名称做键。<br>
* 唯一性由 {@code uk_school_category_name_marker(school_id, category_id, name, del_marker)} 保证:
* 同校同分类下未软删的部门名不重复,跨分类与 SYSTEM/CUSTOM 之间允许重名。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_department")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDepartment extends BaseEntity {
/** 学校组 id(冗余自分类,写口由服务端复核,不接受前端传值做授权)。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 所属分类(必须同校,移动只允许在本校其他分类之间)。 */
@Column(name = "category_id", nullable = false)
private Integer categoryId;
/** 部门名称,学校自定;软删也不改写。 */
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/**
* 启用状态:1=启用 / 0=停用。<br>
* ⚠ 语义与 {@code sys_group.status}(0=启用/1=停用)方向相反,严禁混用;接口层统一输出 boolean enabled。
*/
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
/** 软删标记:0=有效 / 1=已软删;软删后成员关系保留在库,由查询侧过滤。 */
@Column(name = "is_deleted", nullable = false)
private Integer isDeleted = 0;
/** 软删时置为该行 id,参与唯一键。 */
@Column(name = "del_marker", nullable = false)
private Long delMarker = 0L;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Integer deletedBy;
public boolean isDeletedRow() {
return isDeleted != null && isDeleted == 1;
}
public boolean isEnabledRow() {
return enabled != null && enabled == 1;
}
}
@@ -0,0 +1,36 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门成员关系(引用 {@code sys_user.id},不复制账号)。<br>
* 一个用户可属同校多个 CUSTOM 部门;{@code uk_department_user(department_id, user_id)} 挡有效重复。<br>
* 移除成员=<b>物理删</b>该关系行(本仓无软删惯例,增删审计由 {@code @OperationLog} 承担),
* 因此不会残留「已移除但仍有效」的成员。<br>
* 成员范围=本校教职工且排除学生组(type=8)下的用户,由服务端逐个复核,不接受前端传入即认定。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_department_member")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDepartmentMember extends BaseEntity {
/** 学校组 id(与部门同校,写口服务端复核)。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
@Column(name = "department_id", nullable = false)
private Integer departmentId;
@Column(name = "user_id", nullable = false)
private Integer userId;
}
@@ -0,0 +1,56 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/10/21
* @description 学校自定义部门分类(CUSTOM 侧容器层)。<br>
* 分类只是管理容器:<b>没有启停状态、不能配成员</b>,不是可指派的业务部门。<br>
* 唯一性由 {@code uk_school_name_marker(school_id, name, del_marker)} 保证:
* 未软删行 {@code del_marker=0},软删时置为该行 id,因此同校同名只挡有效行;
* {@code name} 永不改写,保证历史解析拿到原名、旧引用不指向新对象。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_dept_category")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDeptCategory extends BaseEntity {
/** 学校组 id(sys_group 中 type=2 节点),与 AccessScopeService.scopeGroupIds() 同域。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 分类名称,学校自定;软删也不改写。 */
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 软删标记:0=有效 / 1=已软删。删除不是第三种业务状态,与 enabled 维度分离。 */
@Column(name = "is_deleted", nullable = false)
private Integer isDeleted = 0;
/** 软删时置为该行 id,参与唯一键(MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住重复激活)。 */
@Column(name = "del_marker", nullable = false)
private Long delMarker = 0L;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Integer deletedBy;
public boolean isDeletedRow() {
return isDeleted != null && isDeleted == 1;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/10/21
* @description 部门来源类型(跨业务统一引用的消歧位)。<br>
* 只有 {@code SYSTEM}(原组织架构组)与 {@code CUSTOM}(学校自定义部门)两种,
* <b>不得</b>扩成 FLOW/SVC/AUDIT 等业务类型;引用一律「schoolId + departmentType + departmentId」,禁止以名称做键。
*/
@Getter
@AllArgsConstructor
public enum DepartmentTypeEnum implements IEnum<String> {
SYSTEM("SYSTEM", "系统部门"),
CUSTOM("CUSTOM", "自定义部门");
private final String key;
private final String value;
@Override
public String getKey() {
return key;
}
@Override
public String getValue() {
return value;
}
/** 宽松解析(大小写/空白不敏感);非 SYSTEM/CUSTOM 返回 null,由调用方拒绝。 */
public static DepartmentTypeEnum of(String raw) {
if (raw == null) {
return null;
}
String normalized = raw.trim().toUpperCase();
for (DepartmentTypeEnum type : values()) {
if (type.key.equals(normalized)) {
return type;
}
}
return null;
}
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 部门成员批量添加/移除请求。<br>
* 服务端<b>逐个</b>复核每个 userId:本校 + 教职工(排除学生组 type=8 下的用户)+ 有效账号;
* 任一不合格整批拒绝(不返回「部分成功」,避免前端以为已生效)。
*/
@Data
public class CustomDepartmentMemberRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
@NotEmpty(message = "成员列表不能为空")
private List<Integer> userIds;
}
@@ -0,0 +1,20 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 部门成员列表项(管理端弹窗用,字段最小化:不返回手机号/身份证等敏感列)。
*/
@Data
public class CustomDepartmentMemberVo {
private Integer userId;
/** 账号(sys_user.username)。 */
private String account;
/** 姓名(sys_user.real_name)。 */
private String name;
}
@@ -0,0 +1,21 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 部门移动请求:只允许移动到<b>本校</b>其他分类;目标分类跨校直接拒绝。
*/
@Data
public class CustomDepartmentMoveRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 目标分类 id。 */
@NotNull(message = "目标分类不能为空")
private Integer categoryId;
}
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门创建/改名请求。新建默认 enabled=1,成员可为空(先建后配)。
*/
@Data
public class CustomDepartmentRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 归属分类 id:创建必填;改名不生效(以库内归属为准,防止前端传错分类绕过重名范围)。 */
private Integer categoryId;
@NotBlank(message = "部门名称不能为空")
@Size(max = 64, message = "部门名称不能超过 64 个字符")
private String name;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 部门排序请求:把某分类下的有效部门按给定 ids 顺序重排(sort_no 从 1 起)。
*/
@Data
public class CustomDepartmentSortRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 排序作用范围所在分类 id。 */
private Integer categoryId;
@NotEmpty(message = "部门ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,31 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门列表项(管理端卡片;停用可见、软删默认不出现)。<br>
* {@code enabled} 对外统一 boolean,库内是 1/0 单列,避免与 sys_group.status 的方向混淆。
*/
@Data
public class CustomDepartmentVo {
private Integer id;
private Integer schoolId;
private Integer categoryId;
private String categoryName;
private String name;
private Integer sortNo;
/** 是否启用(true=启用)。 */
private Boolean enabled;
/** 有效成员数。 */
private Long memberCount;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义分类创建/改名请求。<br>
* schoolId 只作与服务端身份范围的一致性核对,<b>不作为授权依据</b>(授权见
* {@code AccessScopeService#resolveWritableSchoolId})。
*/
@Data
public class CustomDeptCategoryRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
@NotBlank(message = "分类名称不能为空")
@Size(max = 64, message = "分类名称不能超过 64 个字符")
private String name;
}
@@ -0,0 +1,22 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 分类排序请求:把本校有效分类按给定 ids 顺序重排(范式同 SysGroupSortRequest,落库 sort_no 从 1 起)。
*/
@Data
public class CustomDeptCategorySortRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 期望顺序的分类 id 列表,必须覆盖本校全部有效分类且不得含别校分类。 */
@NotEmpty(message = "分类ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义分类列表项(管理端左侧平铺列表;软删分类不出现在列表里)。
*/
@Data
public class CustomDeptCategoryVo {
private Integer id;
private Integer schoolId;
private String name;
private Integer sortNo;
/** 该分类下<b>有效</b>部门数:前端据此对非空分类禁用「删除」。 */
private Long departmentCount;
}
@@ -0,0 +1,44 @@
package com.yunhai.userbase.model.dept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录条目(规格书 §4.2 统一响应条目,SYSTEM/CUSTOM 共用同一形状)。<br>
* 引用必须三件套齐全:{@code schoolId + departmentType + departmentId};
* SYSTEM 的 {@code categoryId/categoryName} 恒为 null,层级用 {@code parentId}(照组契约)。
*/
@Data
public class DepartmentEntryVo {
/** 学校组 id。 */
private Integer schoolId;
/** 来源:SYSTEM / CUSTOM(消歧位,禁止靠名称或 ID 单独判断)。 */
private String departmentType;
/** SYSTEM= sys_group.id;CUSTOM= ub_custom_department.id。两域各自独立,可数值相同但语义不同。 */
private Integer departmentId;
private String name;
private Integer categoryId;
private String categoryName;
/** 是否启用(CUSTOM 读 enabled 列,SYSTEM 读 status≠停用)。 */
private Boolean enabled;
/** 是否已软删(仅历史解析口会为 true;SYSTEM 不支持删除,恒 false)。 */
private Boolean deleted;
/** 是否可用于<b>新的</b>选择/绑定:停用或已软删 → false(A10/A12)。 */
private Boolean selectable;
/** SYSTEM=该组直属用户数;CUSTOM=有效成员数。 */
private Long memberCount;
/** 上级部门 id(仅 SYSTEM;withChildren=true 时用于重建层级,CUSTOM 为 null)。 */
private Integer parentId;
}
@@ -0,0 +1,26 @@
package com.yunhai.userbase.model.dept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 部门引用({@code POST /oapi/departments/resolve-batch} 的入参项)。<br>
* 业务侧存的就是这三件套;这里<b>不</b>按名称回填,名称只作为解析结果返回。
*/
@Data
public class DepartmentRefRequest {
@NotNull(message = "schoolId 不能为空")
private Integer schoolId;
/** SYSTEM / CUSTOM(大小写不敏感)。 */
@NotBlank(message = "departmentType 不能为空")
private String departmentType;
@NotNull(message = "departmentId 不能为空")
private Integer departmentId;
}
@@ -0,0 +1,38 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
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.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门成员关系仓储。
*/
public interface UbCustomDepartmentMemberRepository
extends JpaRepository<UbCustomDepartmentMember, Integer>, JpaSpecificationExecutor<UbCustomDepartmentMember> {
List<UbCustomDepartmentMember> findByDepartmentId(Integer departmentId);
/** 批量取多部门成员(列表页成员数统计用,调用方自行分组计数)。 */
List<UbCustomDepartmentMember> findByDepartmentIdIn(List<Integer> departmentIds);
/** 某用户在 CUSTOM 侧的全部归属(跨部门合法性判定用)。 */
List<UbCustomDepartmentMember> findByUserId(Integer userId);
/**
* 移除成员=物理删关系行(bulk delete 直接发 SQL,避免 Hibernate flush 先 insert 后 delete 撞唯一键,
* 与 {@code SysGroupAppRelRepository#deleteByGroupIdBulk} 同理由)。
*/
@Modifying
@Query("DELETE FROM UbCustomDepartmentMember m WHERE m.departmentId = :departmentId AND m.userId IN :userIds")
int deleteByDepartmentIdAndUserIds(@Param("departmentId") Integer departmentId,
@Param("userIds") List<Integer> userIds);
long countByDepartmentId(Integer departmentId);
}
@@ -0,0 +1,50 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDepartment;
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.List;
import java.util.Optional;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门仓储。<br>
* 与 {@code SysGroupRepository#updateStatus} 同范式:启停走单列 {@code @Modifying} JPQL,
* 绕过实体监听器与审计列(这是本仓既定的单列翻转特例);常规增删改仍走实体保存。
*/
public interface UbCustomDepartmentRepository
extends JpaRepository<UbCustomDepartment, Integer>, JpaSpecificationExecutor<UbCustomDepartment> {
/**
* 启用/停用单列 UPDATE:严格只动 enabled 一列。<br>
* ⚠ enabled 语义 1=启用/0=停用,与 sys_group.status 方向相反,调用方不要按 status 传值。
*/
@Modifying
@Query("UPDATE UbCustomDepartment d SET d.enabled = :enabled WHERE d.id = :id")
int updateEnabled(@Param("id") Integer id, @Param("enabled") Integer enabled);
/** 某分类下的有效部门(按排序)。 */
List<UbCustomDepartment> findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(Integer categoryId, Integer isDeleted);
/** 某校全部有效部门(T2 目录/分类计数用)。 */
List<UbCustomDepartment> findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(Integer schoolId, Integer isDeleted);
/** 同校同分类下有效同名部门(重名前置校验)。 */
Optional<UbCustomDepartment> findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(
Integer schoolId, Integer categoryId, String name, Integer isDeleted);
boolean existsBySchoolIdAndCategoryIdAndNameAndIsDeleted(
Integer schoolId, Integer categoryId, String name, Integer isDeleted);
/** 分类下有效部门数(非空分类禁止软删,A14)。 */
long countByCategoryIdAndIsDeleted(Integer categoryId, Integer isDeleted);
/** 同分类内当前最大排序号(新建部门排到最后;无有效部门返回 null)。 */
@Query("SELECT MAX(d.sortNo) FROM UbCustomDepartment d WHERE d.categoryId = :categoryId AND d.isDeleted = 0")
Integer findMaxSortNoByCategory(@Param("categoryId") Integer categoryId);
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
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/10/21
* @description 自定义部门分类仓储。<br>
* 查询一律带 {@code isDeleted} 维度:软删行默认不出现,历史解析(T2)单独按 id 取。
*/
public interface UbCustomDeptCategoryRepository
extends JpaRepository<UbCustomDeptCategory, Integer>, JpaSpecificationExecutor<UbCustomDeptCategory> {
/** 分类列表:按学校 + 排序(sort_no 相同时按 id 稳定兜底)。 */
List<UbCustomDeptCategory> findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(Integer schoolId, Integer isDeleted);
/** 同校有效分类是否同名(uk 的服务端前置校验,给友好报错)。 */
Optional<UbCustomDeptCategory> findFirstBySchoolIdAndNameAndIsDeleted(Integer schoolId, String name, Integer isDeleted);
boolean existsBySchoolIdAndNameAndIsDeleted(Integer schoolId, String name, Integer isDeleted);
long countBySchoolIdAndIsDeleted(Integer schoolId, Integer isDeleted);
/** 本校当前最大分类排序号(新建分类排到最后;无有效分类返回 null)。 */
@Query("SELECT MAX(c.sortNo) FROM UbCustomDeptCategory c WHERE c.schoolId = :schoolId AND c.isDeleted = 0")
Integer findMaxSortNoBySchool(@Param("schoolId") Integer schoolId);
}
@@ -0,0 +1,58 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理服务(CUSTOM 侧,规格书 UB-1)。<br>
* 部门只有「启用/停用」两态;删除是软删(不是第三种状态);成员引用 sys_user.id,不复制账号。
*/
public interface ICustomDepartmentService {
/** 某分类下的有效部门列表(含成员数;停用可见、软删不出现)。 */
List<CustomDepartmentVo> list(Integer schoolId, Integer categoryId);
/** 新建部门:默认 enabled=1,成员可为空(先建后配);同校同分类重名拒绝。 */
CustomDepartmentVo add(CustomDepartmentRequest request);
/** 改名:归属分类以库内为准;同校同分类重名拒绝。 */
CustomDepartmentVo rename(Integer id, CustomDepartmentRequest request);
/** 移动至本校其他分类;目标分类跨校直接拒绝。 */
CustomDepartmentVo move(Integer id, CustomDepartmentMoveRequest request);
/** 同分类内排序:ids 必须全部是本校有效部门。 */
void sort(CustomDepartmentSortRequest request);
/** 启用/停用(单列 UPDATE,两态翻转;软删行不可翻转)。 */
void changeEnabled(Integer id, boolean enabled);
/** 软删部门:成员关系保留在库、由查询侧过滤;不物理删、不释放 id。 */
void softDelete(Integer id);
/** 有效成员列表。 */
List<CustomDepartmentMemberVo> members(Integer id);
/**
* 可添加的候选人员:本校教职工(D4,排除学生组)且<b>尚未</b>在该部门内,按账号/姓名模糊过滤。<br>
* 现有 {@code /api/groupauthz/GroupUsers} 是「组维度」口径、会带出学生组用户,不能直接当候选源,
* 故候选必须走这条与写口同一判定的接口。
*
* @param keyword 账号/姓名关键字,空表示不过滤
*/
List<CustomDepartmentMemberVo> candidateUsers(Integer id, String keyword);
/** 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,已存在的按幂等跳过。 */
void addMembers(Integer id, CustomDepartmentMemberRequest request);
/** 移除成员:物理删关系行(不残留有效成员)。 */
void removeMembers(Integer id, CustomDepartmentMemberRequest request);
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理服务(CUSTOM 侧,规格书 UB-1)。<br>
* 分类是纯容器:无启停、无成员;删除只有软删,且<b>禁止任何级联</b>。
*/
public interface ICustomDeptCategoryService {
/** 本校有效分类列表(按 sort_no、id 升序;软删分类不出现)。 */
List<CustomDeptCategoryVo> list(Integer schoolId);
/** 新建分类:同校有效分类重名直接拒绝。 */
CustomDeptCategoryVo add(CustomDeptCategoryRequest request);
/** 分类改名:同校其他有效分类重名拒绝;name 改写只发生在有效行,软删行的历史名称永不覆盖。 */
CustomDeptCategoryVo rename(Integer id, CustomDeptCategoryRequest request);
/** 分类排序:ids 必须全部是本校有效分类。 */
void sort(CustomDeptCategorySortRequest request);
/**
* 软删分类:仍存在有效子部门时<b>明确拒绝</b>(A14),不做任何级联删除。<br>
* 软删写 is_deleted/del_marker/deleted_at/deleted_by,保留原 id 且不复用。
*/
void softDelete(Integer id);
}
@@ -0,0 +1,44 @@
package com.yunhai.userbase.service;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录契约服务(规格书 UB-2,供 flow-center / svc-hall 等消费方经 {@code /oapi} 读取)。<br>
* 与后台 {@code ICustomDepartmentService} 的关键差别:本服务<b>不感知登录态</b>
* ({@code AccessScopeService} 只服务管理端),授权单位是「调用方应用 × 学校」:
* 应用必须在目标学校已开通({@code sys_group_app_rel}),<b>仅验签不等于可读任意学校</b>(A15)。<br>
* 只读;不写任何数据;本期直查不加缓存。
*/
public interface IDepartmentDirectoryService {
/**
* 可选部门目录:默认只返回<b>启用且未软删</b>的条目。<br>
* type=null 时 SYSTEM + CUSTOM 一并返回;categoryId 只对 CUSTOM 生效;
* withChildren 只对 SYSTEM 生效(true=含部门下级,层级靠 parentId 还原)。
*/
List<DepartmentEntryVo> list(Integer appId, Integer schoolId, DepartmentTypeEnum type,
Integer categoryId, boolean withChildren);
/** CUSTOM 分类目录(按学校授权;不混入 SYSTEM 组织树)。 */
List<CustomDeptCategoryVo> categories(Integer appId, Integer schoolId);
/**
* 批量历史解析:<b>唯一</b>允许返回停用/软删部门的读口(A12)。<br>
* 解析不到(对象不存在,或 SYSTEM 组已被组织模块删除)时按「不可选」返回:name=null、deleted=true。
*/
List<DepartmentEntryVo> resolveBatch(Integer appId, List<DepartmentRefRequest> refs);
/**
* 部门成员解析。{@code forNewAssignment=true}(供新指派)时要求部门启用且未软删,否则拒绝;
* {@code forNewAssignment=false}(已有实例解析)时停用/软删照常返回成员,不改变在途语义(A17)。
*/
List<BoaUser> members(Integer appId, DepartmentTypeEnum type, Integer departmentId, boolean forNewAssignment);
}
@@ -0,0 +1,479 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.IDepartmentDirectoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录契约实现(UB-2)。<br>
* 授权链路照 {@code /oapi/group} 现行做法:签名由过滤器强制,本层只做「应用 × 学校」可见性——
* 权威事实是 {@code sys_group_app_rel}(该应用在该学校已开通),与
* {@code SchoolAppOverrideService#editAppForSchool} 同一口径,不新建 ACL 表。<br>
* 直查不加缓存(任务书 §0.2 铁律);{@code AccessScopeService} 只服务管理端,本类不引用。
*/
@Service
public class DepartmentDirectoryService implements IDepartmentDirectoryService {
private static final int NOT_DELETED = 0;
/** sys_group.status:0=启用 / 1=停用(方向与 CUSTOM 的 enabled 相反)。 */
private static final int GROUP_STATUS_DISABLED = 1;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private UbCustomDepartmentMemberRepository memberRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private SysGroupAppRelRepository sysGroupAppRelRepository;
// ===================== 目录 =====================
@Override
public List<DepartmentEntryVo> list(Integer appId, Integer schoolId, DepartmentTypeEnum type,
Integer categoryId, boolean withChildren) {
assertAppCanReadSchool(appId, schoolId);
List<DepartmentEntryVo> result = new ArrayList<>();
if (type == null || type == DepartmentTypeEnum.SYSTEM) {
if (categoryId != null) {
throw new DException("categoryId 仅对 CUSTOM 生效");
}
result.addAll(systemDepartments(schoolId, withChildren, true));
}
if (type == null || type == DepartmentTypeEnum.CUSTOM) {
result.addAll(customDepartments(schoolId, categoryId, true));
}
return result;
}
@Override
public List<CustomDeptCategoryVo> categories(Integer appId, Integer schoolId) {
assertAppCanReadSchool(appId, schoolId);
List<CustomDeptCategoryVo> result = new ArrayList<>();
for (UbCustomDeptCategory category :
categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED)) {
CustomDeptCategoryVo vo = new CustomDeptCategoryVo();
vo.setId(category.getId());
vo.setSchoolId(category.getSchoolId());
vo.setName(category.getName());
vo.setSortNo(category.getSortNo());
vo.setDepartmentCount(
departmentRepository.countByCategoryIdAndIsDeleted(category.getId(), NOT_DELETED));
result.add(vo);
}
return result;
}
// ===================== 历史解析 =====================
@Override
public List<DepartmentEntryVo> resolveBatch(Integer appId, List<DepartmentRefRequest> refs) {
if (refs == null || refs.isEmpty()) {
return Collections.emptyList();
}
// 逐校校验授权:一批里出现未开通的学校 → 整批拒绝(不做「能解析多少给多少」)
Set<Integer> schools = new LinkedHashSet<>();
for (DepartmentRefRequest ref : refs) {
if (ref == null || ref.getSchoolId() == null) {
throw new DException("引用项缺少 schoolId");
}
schools.add(ref.getSchoolId());
}
for (Integer schoolId : schools) {
assertAppCanReadSchool(appId, schoolId);
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (DepartmentRefRequest ref : refs) {
result.add(resolveOne(ref));
}
return result;
}
private DepartmentEntryVo resolveOne(DepartmentRefRequest ref) {
DepartmentTypeEnum type = DepartmentTypeEnum.of(ref.getDepartmentType());
if (type == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
if (type == DepartmentTypeEnum.CUSTOM) {
return resolveCustom(ref.getSchoolId(), ref.getDepartmentId());
}
return resolveSystem(ref.getSchoolId(), ref.getDepartmentId());
}
/** CUSTOM 历史解析:软删/停用照常返回名称与状态,仅 selectable=false。 */
private DepartmentEntryVo resolveCustom(Integer schoolId, Integer departmentId) {
UbCustomDepartment department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || !schoolId.equals(department.getSchoolId())) {
return unresolvable(schoolId, DepartmentTypeEnum.CUSTOM, departmentId);
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(department.getSchoolId());
entry.setDepartmentType(DepartmentTypeEnum.CUSTOM.getKey());
entry.setDepartmentId(department.getId());
entry.setName(department.getName());
entry.setCategoryId(department.getCategoryId());
entry.setCategoryName(categoryRepository.findById(department.getCategoryId())
.map(UbCustomDeptCategory::getName).orElse(null));
entry.setDeleted(department.isDeletedRow());
entry.setEnabled(department.isEnabledRow());
entry.setSelectable(!department.isDeletedRow() && department.isEnabledRow());
entry.setMemberCount(memberRepository.countByDepartmentId(department.getId()));
return entry;
}
/** SYSTEM 历史解析:组不存在、或不属于引用所称学校 → 视为解析不到(不把别校同名组当历史值)。 */
private DepartmentEntryVo resolveSystem(Integer schoolId, Integer groupId) {
SysGroup group = sysGroupRepository.findById(groupId).orElse(null);
if (group == null || !schoolId.equals(schoolOfGroup(group))) {
return unresolvable(schoolId, DepartmentTypeEnum.SYSTEM, groupId);
}
boolean enabled = isSystemEnabled(group);
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(DepartmentTypeEnum.SYSTEM.getKey());
entry.setDepartmentId(group.getId());
entry.setName(group.getName());
entry.setParentId(group.getParentId());
entry.setDeleted(false);
entry.setEnabled(enabled);
entry.setSelectable(enabled);
entry.setMemberCount(sysUserGroupRelRepository.countByGroupId(group.getId()));
return entry;
}
private DepartmentEntryVo unresolvable(Integer schoolId, DepartmentTypeEnum type, Integer departmentId) {
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(type.getKey());
entry.setDepartmentId(departmentId);
entry.setDeleted(true);
entry.setEnabled(null);
entry.setSelectable(false);
entry.setMemberCount(0L);
return entry;
}
// ===================== 成员解析 =====================
@Override
public List<BoaUser> members(Integer appId, DepartmentTypeEnum type, Integer departmentId,
boolean forNewAssignment) {
if (type == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
if (departmentId == null) {
throw new DException("部门ID不能为空");
}
if (type == DepartmentTypeEnum.CUSTOM) {
UbCustomDepartment department = departmentRepository.findById(departmentId).orElse(null);
if (department == null) {
throw new DException("自定义部门不存在");
}
assertAppCanReadSchool(appId, department.getSchoolId());
if (forNewAssignment && department.isDeletedRow()) {
throw new DException("部门已删除,不可用于新的指派");
}
if (forNewAssignment && !department.isEnabledRow()) {
throw new DException("部门已停用,不可用于新的指派");
}
return customMembers(departmentId);
}
SysGroup group = sysGroupRepository.findById(departmentId).orElse(null);
if (group == null) {
throw new DException("系统组不存在");
}
Integer schoolId = schoolOfGroup(group);
if (schoolId == null) {
throw new DException("该组织节点不属于任何学校");
}
assertAppCanReadSchool(appId, schoolId);
if (forNewAssignment && !isSystemEnabled(group)) {
throw new DException("部门已停用,不可用于新的指派");
}
return systemMembers(departmentId);
}
private List<BoaUser> customMembers(Integer departmentId) {
List<UbCustomDepartmentMember> rows = memberRepository.findByDepartmentId(departmentId);
if (rows.isEmpty()) {
return Collections.emptyList();
}
List<Integer> userIds = new ArrayList<>();
for (UbCustomDepartmentMember row : rows) {
userIds.add(row.getUserId());
}
return toBoaUsers(userIds);
}
private List<BoaUser> systemMembers(Integer groupId) {
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByGroupId(groupId);
if (rels.isEmpty()) {
return Collections.emptyList();
}
Set<Integer> userIds = new LinkedHashSet<>();
for (SysUserGroupRel rel : rels) {
userIds.add(rel.getUserId());
}
return toBoaUsers(new ArrayList<>(userIds));
}
/**
* 出参形状与 {@code /oapi/group/users} 保持一致:只给 id/账号/姓名/type/status,敏感列不外泄(§4.2 末条)。<br>
* <b>不禁用账号</b>:现行组用户接口就是「照发 + status 标 0」由消费方判({@code toLightBoaUser}),
* 这里若私自过滤,历史解析会静默少人。「人已离校」的处置策略归 flow-center 现行规则(§8.7),UserBase 不猜。<br>
* 只有关系行指向的物理不存在账号(孤儿行)跳过。
*/
private List<BoaUser> toBoaUsers(List<Integer> userIds) {
Map<Integer, SysUser> byId = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
byId.put(user.getId(), user);
}
List<BoaUser> result = new ArrayList<>();
for (Integer userId : userIds) {
SysUser user = byId.get(userId);
if (user == null) {
continue;
}
BoaUser boa = new BoaUser();
boa.setId(user.getId());
boa.setAccount(user.getAccount());
boa.setName(user.getRealName());
boa.setType(user.getType() != null ? user.getType().getKey() : null);
// 状态语义与组用户接口逐字对齐:disabled(NORMAL=0/ DISABLED=1) → BoaUser(1 正常 / 0 禁用)
boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0);
result.add(boa);
}
return result;
}
// ===================== SYSTEM 目录装配 =====================
/**
* SYSTEM 部门口径与部门管理页一致:<b>教师组(type=7)的直接子节点</b>;
* 学生组(type=8)下的节点不算部门。<br>
* withChildren=true 时连同部门下级一起给出(扁平 + parentId,由消费方重建层级)。<br>
* 停用的部门只在历史解析里出现(本方法由 {@code onlySelectable=true} 调用时会整枝剪掉)。
*/
private List<DepartmentEntryVo> systemDepartments(Integer schoolId, boolean withChildren, boolean onlySelectable) {
List<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, List<SysGroup>> childrenMap = new HashMap<>();
for (SysGroup group : all) {
Integer parent = group.getParentId() == null ? 0 : group.getParentId();
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(group);
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (SysGroup child : childrenMap.getOrDefault(schoolId, Collections.<SysGroup>emptyList())) {
if (child.getType() != GroupTypeEnum.TEACHER_GROUP) {
continue;
}
for (SysGroup department : childrenMap.getOrDefault(child.getId(), Collections.<SysGroup>emptyList())) {
boolean appended = appendSystemEntry(result, schoolId, department, onlySelectable);
if (withChildren && appended) {
appendSystemDescendants(result, schoolId, department.getId(), childrenMap, onlySelectable);
}
}
}
return result;
}
private void appendSystemDescendants(List<DepartmentEntryVo> result, Integer schoolId, Integer parentId,
Map<Integer, List<SysGroup>> childrenMap, boolean onlySelectable) {
Deque<Integer> frontier = new ArrayDeque<>();
frontier.add(parentId);
Set<Integer> visited = new HashSet<>();
while (!frontier.isEmpty()) {
Integer cursor = frontier.poll();
for (SysGroup child : childrenMap.getOrDefault(cursor, Collections.<SysGroup>emptyList())) {
if (!visited.add(child.getId())) {
continue;
}
appendSystemEntry(result, schoolId, child, onlySelectable);
frontier.add(child.getId());
}
}
}
/**
* 追加一个 SYSTEM 节点为目录条目。<br>
* 学生组本身永不作为部门;停用节点在 {@code onlySelectable} 下直接跳过。<br>
* 下级节点的组类型(普通组/年级组…)就是组树语义,不做二次筛选,照 parentId 原样下发。
*
* @return 该节点是否进入结果(false 表示被过滤,其下级不再展开)
*/
private boolean appendSystemEntry(List<DepartmentEntryVo> result, Integer schoolId, SysGroup group,
boolean onlySelectable) {
if (group.getType() == GroupTypeEnum.STUDENT_GROUP) {
return false;
}
boolean enabled = isSystemEnabled(group);
if (onlySelectable && !enabled) {
return false;
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(DepartmentTypeEnum.SYSTEM.getKey());
entry.setDepartmentId(group.getId());
entry.setName(group.getName());
entry.setParentId(group.getParentId());
entry.setEnabled(enabled);
entry.setDeleted(false);
entry.setSelectable(enabled);
entry.setMemberCount(sysUserGroupRelRepository.countByGroupId(group.getId()));
result.add(entry);
return true;
}
/** sys_group.status:0=启用(含存量 null)/ 1=停用。方向与 CUSTOM 的 enabled 相反。 */
private boolean isSystemEnabled(SysGroup group) {
return group.getStatus() == null || group.getStatus() != GROUP_STATUS_DISABLED;
}
// ===================== CUSTOM 目录装配 =====================
private List<DepartmentEntryVo> customDepartments(Integer schoolId, Integer categoryId, boolean onlySelectable) {
Map<Integer, String> categoryNames = new LinkedHashMap<>();
for (UbCustomDeptCategory category :
categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED)) {
if (categoryId != null && !categoryId.equals(category.getId())) {
continue;
}
categoryNames.put(category.getId(), category.getName());
}
if (categoryNames.isEmpty()) {
if (categoryId != null) {
throw new DException("分类不存在或不属于该校");
}
return Collections.emptyList();
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (Integer liveCategoryId : categoryNames.keySet()) {
List<UbCustomDepartment> rows =
departmentRepository.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(liveCategoryId, NOT_DELETED);
Map<Integer, Long> memberCounts = countMembers(rows);
for (UbCustomDepartment row : rows) {
boolean enabled = row.isEnabledRow();
if (onlySelectable && !enabled) {
continue;
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(row.getSchoolId());
entry.setDepartmentType(DepartmentTypeEnum.CUSTOM.getKey());
entry.setDepartmentId(row.getId());
entry.setName(row.getName());
entry.setCategoryId(row.getCategoryId());
entry.setCategoryName(categoryNames.get(row.getCategoryId()));
entry.setEnabled(enabled);
entry.setDeleted(false);
entry.setSelectable(enabled);
entry.setMemberCount(memberCounts.getOrDefault(row.getId(), 0L));
result.add(entry);
}
}
return result;
}
private Map<Integer, Long> countMembers(List<UbCustomDepartment> rows) {
List<Integer> ids = new ArrayList<>();
for (UbCustomDepartment row : rows) {
ids.add(row.getId());
}
Map<Integer, Long> counts = new HashMap<>();
if (ids.isEmpty()) {
return counts;
}
for (UbCustomDepartmentMember member : memberRepository.findByDepartmentIdIn(ids)) {
counts.merge(member.getDepartmentId(), 1L, Long::sum);
}
return counts;
}
// ===================== 授权与锚点 =====================
/**
* 应用 × 学校可见性:签名过滤器只证明「是哪个应用」,能不能读这所学校由开通关系决定(A15)。
*/
private void assertAppCanReadSchool(Integer appId, Integer schoolId) {
if (appId == null) {
throw new DException("无法识别调用方应用,禁止读取部门目录");
}
if (schoolId == null) {
throw new DException("schoolId 不能为空");
}
SysGroup school = sysGroupRepository.findById(schoolId).orElse(null);
if (school == null || school.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("学校不存在或不是学校组");
}
if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(schoolId, appId)) {
throw new DException("该应用未在本校开通,无权读取该校部门目录");
}
}
/** 沿 parentId 上溯最近的学校组(CUSTOM 无此需求,只给 SYSTEM 组用);带 visited 防环。 */
private Integer schoolOfGroup(SysGroup group) {
Map<Integer, SysGroup> byId = new HashMap<>();
for (SysGroup each : sysGroupRepository.findAll()) {
byId.put(each.getId(), each);
}
Set<Integer> visited = new HashSet<>();
SysGroup cursor = group;
while (cursor != null && visited.add(cursor.getId())) {
if (cursor.getType() == GroupTypeEnum.SCHOOL_GROUP) {
return cursor.getId();
}
Integer parentId = cursor.getParentId();
if (parentId == null || parentId == 0) {
return null;
}
cursor = byId.get(parentId);
}
return null;
}
}
@@ -251,15 +251,21 @@ public class SysGroupService implements ISysGroupService {
/**
* 「包含子组」翻转(2026-10-21 部门线):严格单列 UPDATE,范式与 {@link #changeStatus} 完全一致。<br>
* 与 changeStatus 的唯一差别:本属性只在**学校组**节点上有意义(对齐 SysGroup 字段注释「仅学校节点使用」),
* 故在此强制校验类型,非学校组直接拒绝——前端只在学校行放开关,服务端不能只靠它。<br>
* 必须发 {@link GroupChangedEvent}:GetTree / oapi 组树都吃 groupSnapshot 缓存,
* 不发则页面读到的 includeChildren 最多滞后 30min。
*/
@Override
@Transactional
public void changeIncludeChildren(Integer id, boolean include) {
if (id == null || sysGroupRepository.findById(id).orElse(null) == null) {
SysGroup group = (id == null) ? null : sysGroupRepository.findById(id).orElse(null);
if (group == null) {
throw new DException("系统组不存在");
}
if (group.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("包含子组仅可设置在学校组节点");
}
int updated = sysGroupRepository.updateIncludeChildren(id, include ? 1 : 0);
if (updated == 0) {
throw new DException("系统组不存在");
@@ -0,0 +1,467 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.ICustomDepartmentService;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理实现(CUSTOM 侧权威写口)。<br>
* 权限:schoolId 全部由 {@link AccessScopeService} 从登录身份解析,请求体只作核对;
* 每个写口先定位对象、再校验其所属学校落在当前范围内(跨校一律拒绝,A09)。<br>
* 成员范围(D4):本校 + 教职工,排除学生组(type=8)子树下的用户。<br>
* 本期 CUSTOM 目录不加缓存(任务书 §0.2),写口不发驱逐事件;如后续加缓存须全部写口 100% 驱逐。
*/
@Service
public class UbCustomDepartmentService implements ICustomDepartmentService {
private static final int NOT_DELETED = 0;
/** 候选教职工一次最多返回多少条(人多时靠关键字缩小范围)。 */
private static final int CANDIDATE_LIMIT = 200;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentMemberRepository memberRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private ISysGroupService sysGroupService;
@Autowired
private AccessScopeService accessScopeService;
// ===================== 列表 =====================
@Override
public List<CustomDepartmentVo> list(Integer schoolId, Integer categoryId) {
Integer scoped = accessScopeService.resolveWritableSchoolId(schoolId);
UbCustomDeptCategory category = requireLiveCategory(categoryId);
if (!scoped.equals(category.getSchoolId())) {
throw new DException("分类不属于该学校范围");
}
List<UbCustomDepartment> rows = departmentRepository
.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(category.getId(), NOT_DELETED);
if (rows.isEmpty()) {
return Collections.emptyList();
}
Map<Integer, Long> memberCounts = countMembersByDepartment(rows);
List<CustomDepartmentVo> result = new ArrayList<>();
for (UbCustomDepartment row : rows) {
CustomDepartmentVo vo = toVo(row, category.getName());
vo.setMemberCount(memberCounts.getOrDefault(row.getId(), 0L));
result.add(vo);
}
return result;
}
// ===================== 部门增删改 =====================
@Override
@Transactional
public CustomDepartmentVo add(CustomDepartmentRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
UbCustomDeptCategory category = requireLiveCategory(request.getCategoryId());
if (!schoolId.equals(category.getSchoolId())) {
throw new DException("分类不属于该学校范围");
}
String name = requireName(request.getName());
requireNameFree(schoolId, category.getId(), name, null);
UbCustomDepartment entity = new UbCustomDepartment();
entity.setSchoolId(schoolId);
entity.setCategoryId(category.getId());
entity.setName(name);
Integer maxSort = departmentRepository.findMaxSortNoByCategory(category.getId());
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
// 新建默认启用(规格书 §2.2)
entity.setEnabled(1);
entity.setIsDeleted(NOT_DELETED);
entity.setDelMarker(0L);
departmentRepository.save(entity);
return toVo(entity, category.getName());
}
@Override
@Transactional
public CustomDepartmentVo rename(Integer id, CustomDepartmentRequest request) {
UbCustomDepartment entity = requireEditableDepartment(id);
String name = requireName(request.getName());
if (name.equals(entity.getName())) {
return toVo(entity, categoryName(entity.getCategoryId()));
}
requireNameFree(entity.getSchoolId(), entity.getCategoryId(), name, entity.getId());
entity.setName(name);
departmentRepository.save(entity);
return toVo(entity, categoryName(entity.getCategoryId()));
}
@Override
@Transactional
public CustomDepartmentVo move(Integer id, CustomDepartmentMoveRequest request) {
UbCustomDepartment entity = requireEditableDepartment(id);
UbCustomDeptCategory target = requireLiveCategory(request.getCategoryId());
// 只能移到本校其他分类:跨校目标分类直接拒绝(不按「范围里有就算」放宽)
if (!entity.getSchoolId().equals(target.getSchoolId())) {
throw new DException("只能移动到本校其他分类");
}
accessScopeService.requireSchoolInScope(target.getSchoolId());
if (target.getId().equals(entity.getCategoryId())) {
return toVo(entity, target.getName());
}
requireNameFree(entity.getSchoolId(), target.getId(), entity.getName(), entity.getId());
entity.setCategoryId(target.getId());
Integer maxSort = departmentRepository.findMaxSortNoByCategory(target.getId());
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
departmentRepository.save(entity);
return toVo(entity, target.getName());
}
@Override
@Transactional
public void sort(CustomDepartmentSortRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
List<Integer> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new DException("部门ID列表不能为空");
}
if (new HashSet<>(ids).size() != ids.size()) {
throw new DException("部门ID列表存在重复");
}
List<UbCustomDepartment> rows = new ArrayList<>();
Map<Integer, UbCustomDepartment> byId = new HashMap<>();
for (Integer id : ids) {
UbCustomDepartment row = (id == null) ? null : departmentRepository.findById(id).orElse(null);
if (row == null || row.isDeletedRow() || !schoolId.equals(row.getSchoolId())) {
throw new DException("部门不存在或不属于本校");
}
if (request.getCategoryId() != null && !request.getCategoryId().equals(row.getCategoryId())) {
throw new DException("部门不属于该分类");
}
rows.add(row);
byId.put(row.getId(), row);
}
for (int i = 0; i < ids.size(); i++) {
byId.get(ids.get(i)).setSortNo(i + 1);
}
departmentRepository.saveAll(rows);
}
/**
* 启停翻转(范式同 {@code SysGroupService.changeStatus}):只走 {@code updateEnabled} 单列 UPDATE,
* 不动成员、不动软删列、不发事件(本期 CUSTOM 无缓存)。<br>
* ⚠ enabled 语义 1=启用/0=停用,与 sys_group.status 方向相反。
*/
@Override
@Transactional
public void changeEnabled(Integer id, boolean enabled) {
requireEditableDepartment(id);
int updated = departmentRepository.updateEnabled(id, enabled ? 1 : 0);
if (updated == 0) {
throw new DException("自定义部门不存在");
}
}
@Override
@Transactional
public void softDelete(Integer id) {
UbCustomDepartment entity = requireEditableDepartment(id);
entity.setIsDeleted(1);
// del_marker=自身 id:让出 uk(school_id, category_id, name, del_marker) 的有效位,
// 之后同名新部门走新 id,旧引用不会指向新对象(A13);成员关系保留在库由查询侧过滤(A12)
entity.setDelMarker(entity.getId() == null ? 0L : entity.getId().longValue());
entity.setDeletedAt(LocalDateTime.now());
entity.setDeletedBy(UserContext.getCurrentUser());
departmentRepository.save(entity);
}
// ===================== 成员 =====================
@Override
public List<CustomDepartmentMemberVo> members(Integer id) {
UbCustomDepartment department = requireEditableDepartment(id);
List<UbCustomDepartmentMember> rows = memberRepository.findByDepartmentId(department.getId());
if (rows.isEmpty()) {
return Collections.emptyList();
}
List<Integer> userIds = new ArrayList<>();
for (UbCustomDepartmentMember row : rows) {
userIds.add(row.getUserId());
}
Map<Integer, SysUser> userMap = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
userMap.put(user.getId(), user);
}
List<CustomDepartmentMemberVo> result = new ArrayList<>();
for (Integer userId : userIds) {
SysUser user = userMap.get(userId);
// 账号已被删除的孤儿关系行不出现在成员列表(uk 仍占位,重加同 id 时按幂等跳过)
if (user == null) {
continue;
}
result.add(toMemberVo(user));
}
return result;
}
@Override
@Transactional
public void addMembers(Integer id, CustomDepartmentMemberRequest request) {
UbCustomDepartment department = requireEditableDepartment(id);
List<Integer> distinct = distinctUserIds(request.getUserIds());
Set<Integer> teacherUserIds = schoolTeacherUserIds(department.getSchoolId());
Set<Integer> existing = existingMemberUserIds(department.getId());
List<UbCustomDepartmentMember> toSave = new ArrayList<>();
for (Integer userId : distinct) {
if (!teacherUserIds.contains(userId)) {
// D4:非本校或未在教职工范围内(学生组成员)——逐个复核,任一不合格整批拒绝
throw new DException("用户「" + userLabel(userId) + "」不是本校教职工,不能加入自定义部门");
}
if (existing.contains(userId)) {
// 重复添加按幂等跳过(A08),不产生第二条有效成员关系
continue;
}
UbCustomDepartmentMember member = new UbCustomDepartmentMember();
member.setSchoolId(department.getSchoolId());
member.setDepartmentId(department.getId());
member.setUserId(userId);
toSave.add(member);
}
if (!toSave.isEmpty()) {
memberRepository.saveAll(toSave);
}
}
@Override
@Transactional
public void removeMembers(Integer id, CustomDepartmentMemberRequest request) {
UbCustomDepartment department = requireEditableDepartment(id);
List<Integer> distinct = distinctUserIds(request.getUserIds());
memberRepository.deleteByDepartmentIdAndUserIds(department.getId(), distinct);
}
@Override
public List<CustomDepartmentMemberVo> candidateUsers(Integer id, String keyword) {
UbCustomDepartment department = requireEditableDepartment(id);
Set<Integer> teacherUserIds = schoolTeacherUserIds(department.getSchoolId());
Set<Integer> existing = existingMemberUserIds(department.getId());
List<Integer> candidateIds = new ArrayList<>(teacherUserIds);
candidateIds.removeAll(existing);
if (candidateIds.isEmpty()) {
return Collections.emptyList();
}
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
List<CustomDepartmentMemberVo> result = new ArrayList<>();
for (SysUser user : sysUserRepository.findAllById(candidateIds)) {
if (!kw.isEmpty() && !matches(user, kw)) {
continue;
}
result.add(toMemberVo(user));
}
// 上限在关键字过滤之后截断(先截后滤会把命中的藏在后面);超限由前端提示继续输入关键字缩小范围
if (result.size() > CANDIDATE_LIMIT) {
return new ArrayList<>(result.subList(0, CANDIDATE_LIMIT));
}
return result;
}
private boolean matches(SysUser user, String kw) {
return (user.getAccount() != null && user.getAccount().toLowerCase().contains(kw))
|| (user.getRealName() != null && user.getRealName().toLowerCase().contains(kw));
}
// ===================== 校验与私有工具 =====================
/** 定位可操作的<b>有效</b>部门:软删行不参与常规写口(历史解析走 T2 的 resolve-batch)。 */
private UbCustomDepartment requireEditableDepartment(Integer id) {
UbCustomDepartment entity = (id == null) ? null : departmentRepository.findById(id).orElse(null);
if (entity == null || entity.isDeletedRow()) {
throw new DException("自定义部门不存在");
}
// 校维度授权以「对象所属学校」为准:范围外(含跨校)直接拒绝
accessScopeService.requireSchoolInScope(entity.getSchoolId());
return entity;
}
private UbCustomDeptCategory requireLiveCategory(Integer categoryId) {
UbCustomDeptCategory category = (categoryId == null) ? null
: categoryRepository.findById(categoryId).orElse(null);
if (category == null || category.isDeletedRow()) {
throw new DException("分类不存在");
}
accessScopeService.requireSchoolInScope(category.getSchoolId());
return category;
}
private String categoryName(Integer categoryId) {
return categoryRepository.findById(categoryId).map(UbCustomDeptCategory::getName).orElse(null);
}
/** 同校同分类有效部门重名前置校验(uk 兜底,这里给友好报错);跨分类与 SYSTEM/CUSTOM 之间允许重名(A06)。 */
private void requireNameFree(Integer schoolId, Integer categoryId, String name, Integer selfId) {
departmentRepository
.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(schoolId, categoryId, name, NOT_DELETED)
.ifPresent(hit -> {
if (!hit.getId().equals(selfId)) {
throw new DException("该分类下已存在同名部门「" + name + "」");
}
});
}
private List<Integer> distinctUserIds(List<Integer> userIds) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("成员列表不能为空");
}
Set<Integer> distinct = new LinkedHashSet<>(userIds);
distinct.remove(null);
if (distinct.isEmpty()) {
throw new DException("成员列表不能为空");
}
return new ArrayList<>(distinct);
}
private Set<Integer> existingMemberUserIds(Integer departmentId) {
Set<Integer> existing = new HashSet<>();
for (UbCustomDepartmentMember row : memberRepository.findByDepartmentId(departmentId)) {
existing.add(row.getUserId());
}
return existing;
}
/**
* 本校教职工用户集合(D4):落在学校子树内、且<b>不属于</b>该校学生组(type=8)子树的正常账号。<br>
* 判定只依据 {@code sys_user_group_rel} + {@code sys_group.type},与「按组查用户」既有范式一致
* ({@code findByGroupIdIn} 后按 userId 去重)。
*/
private Set<Integer> schoolTeacherUserIds(Integer schoolId) {
List<Integer> schoolGroupIds = sysGroupService.listSelfAndDescendantIds(schoolId);
if (schoolGroupIds.isEmpty()) {
return Collections.emptySet();
}
Set<Integer> studentGroupIds = new LinkedHashSet<>();
for (SysGroup group : sysGroupRepository.findAllById(schoolGroupIds)) {
if (group.getType() == GroupTypeEnum.STUDENT_GROUP) {
studentGroupIds.addAll(sysGroupService.listSelfAndDescendantIds(group.getId()));
}
}
Set<Integer> excluded = new HashSet<>();
if (!studentGroupIds.isEmpty()) {
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByGroupIdIn(new ArrayList<>(studentGroupIds))) {
excluded.add(rel.getUserId());
}
}
Set<Integer> candidates = new HashSet<>();
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByGroupIdIn(schoolGroupIds)) {
if (!excluded.contains(rel.getUserId())) {
candidates.add(rel.getUserId());
}
}
// 有效账号:禁用用户不能作为新成员(LinkedHashSet 保证顺序稳定,便于逐 id 报错定位)
Set<Integer> valid = new LinkedHashSet<>();
for (SysUser user : sysUserRepository.findAllById(new ArrayList<>(candidates))) {
if (user.getDisabled() != StatusEnum.DISABLED) {
valid.add(user.getId());
}
}
return valid;
}
private String userLabel(Integer userId) {
SysUser user = sysUserRepository.findById(userId).orElse(null);
if (user == null) {
return String.valueOf(userId);
}
return user.getRealName() == null ? user.getAccount() : user.getRealName();
}
private Map<Integer, Long> countMembersByDepartment(List<UbCustomDepartment> rows) {
List<Integer> ids = new ArrayList<>();
for (UbCustomDepartment row : rows) {
ids.add(row.getId());
}
Map<Integer, Long> counts = new HashMap<>();
for (UbCustomDepartmentMember member : memberRepository.findByDepartmentIdIn(ids)) {
counts.merge(member.getDepartmentId(), 1L, Long::sum);
}
return counts;
}
private String requireName(String rawName) {
String name = rawName == null ? "" : rawName.trim();
if (name.isEmpty()) {
throw new DException("部门名称不能为空");
}
if (name.length() > 64) {
throw new DException("部门名称不能超过 64 个字符");
}
return name;
}
private CustomDepartmentVo toVo(UbCustomDepartment entity, String categoryName) {
CustomDepartmentVo vo = new CustomDepartmentVo();
vo.setId(entity.getId());
vo.setSchoolId(entity.getSchoolId());
vo.setCategoryId(entity.getCategoryId());
vo.setCategoryName(categoryName);
vo.setName(entity.getName());
vo.setSortNo(entity.getSortNo());
vo.setEnabled(entity.isEnabledRow());
return vo;
}
private CustomDepartmentMemberVo toMemberVo(SysUser user) {
CustomDepartmentMemberVo vo = new CustomDepartmentMemberVo();
vo.setUserId(user.getId());
vo.setAccount(user.getAccount());
vo.setName(user.getRealName());
return vo;
}
}
@@ -0,0 +1,185 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.ICustomDeptCategoryService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理实现。<br>
* 三条铁律:① schoolId 一律由服务端从当前身份范围解析,请求体只作核对(T1.3);
* ② 软删用 is_deleted + del_marker(软删时置为行 id),name 永不改写;
* ③ 本期 CUSTOM 目录不加缓存(任务书 §0.2),故写口不发缓存驱逐事件——若后续接入缓存,
* 必须注册 CacheNames 并保证<b>全部</b>写口发事件驱逐。
*/
@Service
public class UbCustomDeptCategoryService implements ICustomDeptCategoryService {
private static final int NOT_DELETED = 0;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private AccessScopeService accessScopeService;
@Override
public List<CustomDeptCategoryVo> list(Integer schoolId) {
Integer scoped = accessScopeService.resolveWritableSchoolId(schoolId);
List<CustomDeptCategoryVo> result = new ArrayList<>();
for (UbCustomDeptCategory category : requireSchoolCategories(scoped)) {
result.add(toVo(category));
}
return result;
}
@Override
@Transactional
public CustomDeptCategoryVo add(CustomDeptCategoryRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
String name = requireName(request.getName());
if (categoryRepository.existsBySchoolIdAndNameAndIsDeleted(schoolId, name, NOT_DELETED)) {
throw new DException("本校已存在同名分类「" + name + "」");
}
UbCustomDeptCategory entity = new UbCustomDeptCategory();
entity.setSchoolId(schoolId);
entity.setName(name);
Integer maxSort = categoryRepository.findMaxSortNoBySchool(schoolId);
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
entity.setIsDeleted(NOT_DELETED);
entity.setDelMarker(0L);
categoryRepository.save(entity);
return toVo(entity);
}
@Override
@Transactional
public CustomDeptCategoryVo rename(Integer id, CustomDeptCategoryRequest request) {
UbCustomDeptCategory entity = requireEditableCategory(id);
String name = requireName(request.getName());
if (name.equals(entity.getName())) {
return toVo(entity);
}
categoryRepository.findFirstBySchoolIdAndNameAndIsDeleted(entity.getSchoolId(), name, NOT_DELETED)
.ifPresent(hit -> {
if (!hit.getId().equals(entity.getId())) {
throw new DException("本校已存在同名分类「" + name + "」");
}
});
entity.setName(name);
categoryRepository.save(entity);
return toVo(entity);
}
@Override
@Transactional
public void sort(CustomDeptCategorySortRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
List<Integer> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new DException("分类ID列表不能为空");
}
if (new HashSet<>(ids).size() != ids.size()) {
throw new DException("分类ID列表存在重复");
}
List<UbCustomDeptCategory> categories = requireSchoolCategories(schoolId);
Set<Integer> scopeIds = new HashSet<>();
for (UbCustomDeptCategory category : categories) {
scopeIds.add(category.getId());
}
for (Integer id : ids) {
if (!scopeIds.contains(id)) {
throw new DException("分类不存在或不属于本校");
}
}
// 按传入顺序落库(1 起),范式同 SysGroupService.sortChildren
Map<Integer, UbCustomDeptCategory> byId = new HashMap<>();
for (UbCustomDeptCategory category : categories) {
byId.put(category.getId(), category);
}
for (int i = 0; i < ids.size(); i++) {
byId.get(ids.get(i)).setSortNo(i + 1);
}
categoryRepository.saveAll(categories);
}
@Override
@Transactional
public void softDelete(Integer id) {
UbCustomDeptCategory entity = requireEditableCategory(id);
long live = departmentRepository.countByCategoryIdAndIsDeleted(entity.getId(), NOT_DELETED);
if (live > 0) {
// A14:明确拒绝,禁止任何级联删除
throw new DException("该分类下仍有 " + live + " 个部门,请先移动或删除这些部门");
}
markDeleted(entity);
}
// ===================== 私有 =====================
/** 定位可操作的<b>有效</b>分类并做校维度授权(跨校直接拒绝,A09)。 */
private UbCustomDeptCategory requireEditableCategory(Integer id) {
UbCustomDeptCategory entity = (id == null) ? null : categoryRepository.findById(id).orElse(null);
if (entity == null || entity.isDeletedRow()) {
throw new DException("分类不存在");
}
accessScopeService.requireSchoolInScope(entity.getSchoolId());
return entity;
}
private List<UbCustomDeptCategory> requireSchoolCategories(Integer schoolId) {
return categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED);
}
private void markDeleted(UbCustomDeptCategory entity) {
entity.setIsDeleted(1);
// del_marker=自身 id:让出 uk(school_id, name, del_marker) 的有效位,同名新分类可另起新 id(A13)
entity.setDelMarker(entity.getId() == null ? 0L : entity.getId().longValue());
entity.setDeletedAt(LocalDateTime.now());
entity.setDeletedBy(UserContext.getCurrentUser());
categoryRepository.save(entity);
}
private String requireName(String rawName) {
String name = rawName == null ? "" : rawName.trim();
if (name.isEmpty()) {
throw new DException("分类名称不能为空");
}
if (name.length() > 64) {
throw new DException("分类名称不能超过 64 个字符");
}
return name;
}
private CustomDeptCategoryVo toVo(UbCustomDeptCategory entity) {
CustomDeptCategoryVo vo = new CustomDeptCategoryVo();
vo.setId(entity.getId());
vo.setSchoolId(entity.getSchoolId());
vo.setName(entity.getName());
vo.setSortNo(entity.getSortNo());
vo.setDepartmentCount(departmentRepository.countByCategoryIdAndIsDeleted(entity.getId(), NOT_DELETED));
return vo;
}
}
@@ -363,6 +363,55 @@ public class AccessScopeService {
}
}
/**
* 学校维度 guard(2026-10-21 CUSTOM 部门线):schoolId 必须存在、类型为学校组,且落在当前用户数据范围内。<br>
* 现有 guard 都是「组」维度,而 CUSTOM 分类/部门只以学校这一层作组织锚点,故单独成校维度判定;
* 范围判定复用 {@link #scopeGroupIds()},与其同域(学校组 id 即范围根)。
*/
public void requireSchoolInScope(Integer schoolId) {
if (schoolId == null) {
throw new DException("学校ID不能为空");
}
SysGroup school = sysGroupRepository.findById(schoolId).orElse(null);
if (school == null) {
throw new DException("学校分组不存在");
}
if (school.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("学校ID必须为学校分组");
}
requireGroupInScope(schoolId);
}
/**
* 解析写口的生效学校 id —— CUSTOM 侧「schoolId 取自当前身份范围,请求体只作核对不作授权」的唯一入口。<br>
* 分级语义:SCHOOL 只能落在校级可管理学校集合内(多校管理员须显式指定且必须命中其一,未指定则要求补齐);
* SUPER 不限制、BUREAU 限区域内学校,二者均以请求值为准并由 {@link #requireSchoolInScope} 兜范围。
*
* @param requestedSchoolId 前端传来的学校 id(可为 null:单校管理员直接取本校)
* @return 服务端认定的权威学校 id
*/
public Integer resolveWritableSchoolId(Integer requestedSchoolId) {
AccessScope scope = resolveCurrentScope();
if (scope.isNone()) {
throw new DException("暂无权限");
}
if (scope.getLevel() == AccessLevelEnum.SCHOOL) {
List<Integer> mine = scope.schoolGroupIds();
if (requestedSchoolId != null) {
if (!mine.contains(requestedSchoolId)) {
throw new DException("暂无权限访问该组织");
}
return requestedSchoolId;
}
if (mine.size() != 1) {
throw new DException("请选择要管理的学校");
}
return mine.get(0);
}
requireSchoolInScope(requestedSchoolId);
return requestedSchoolId;
}
/**
* 目标用户的归属组必须与当前用户数据范围有交集(SUPER 不限制)。<br>
* 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。
@@ -0,0 +1,436 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
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.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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
/**
* {@code DepartmentDirectoryService}(2026-10-21 T2 通用部门目录契约)单元测试。<br>
* 覆盖验收映射:A15(仅验签≠可读任意学校)、A10/A11 解析侧(停用条目不进气选目录、历史解析仍可见)、
* A12(软删部门只经 resolve-batch 返回)、A06/A19(同名不靠名称回填、别校引用解析不到)、
* 以及 SYSTEM 部门口径=教师组直接子节点(含 withChildren 与下级 parentId)。
*
* <p>夹具组树:5=云海小学 / 6=云海中学;5 下 7=教师组、8=学生组;
* 教师组下 20=校长室(启用)、22=教务处(停用);20 下 21=校办;
* 6 下 9=教师组、40=别校校长室。CUSTOM:分类 10 下 100 启用 / 101 停用 / 102 已软删。</p>
*/
class DepartmentDirectoryServiceTest {
private static final int APP = 3;
private static final int SCHOOL_A = 5;
private static final int SCHOOL_B = 6;
private static final int CATEGORY = 10;
@InjectMocks
private DepartmentDirectoryService directoryService;
@Mock
private UbCustomDeptCategoryRepository categoryRepository;
@Mock
private UbCustomDepartmentRepository departmentRepository;
@Mock
private UbCustomDepartmentMemberRepository memberRepository;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private SysGroupAppRelRepository sysGroupAppRelRepository;
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
when(sysGroupRepository.findAll()).thenReturn(Arrays.asList(
group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0, 0),
group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0, 0),
group(7, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A, 0),
group(8, GroupTypeEnum.STUDENT_GROUP, SCHOOL_A, 0),
group(20, GroupTypeEnum.NORMAL_GROUP, 7, 0),
group(21, GroupTypeEnum.NORMAL_GROUP, 20, 0),
group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1),
group(30, GroupTypeEnum.GRADE_GROUP, 8, 0),
group(9, GroupTypeEnum.TEACHER_GROUP, SCHOOL_B, 0),
group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)
));
when(sysGroupRepository.findById(SCHOOL_A)).thenReturn(Optional.of(group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0, 0)));
when(sysGroupRepository.findById(SCHOOL_B)).thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0, 0)));
// 只有小学开通了本应用
when(sysGroupAppRelRepository.existsByGroupIdAndAppId(SCHOOL_A, APP)).thenReturn(true);
when(sysGroupAppRelRepository.existsByGroupIdAndAppId(SCHOOL_B, APP)).thenReturn(false);
when(sysUserGroupRelRepository.countByGroupId(org.mockito.ArgumentMatchers.anyInt())).thenReturn(1L);
stubCustom();
}
private SysGroup group(Integer id, GroupTypeEnum type, Integer parentId, Integer status) {
SysGroup g = new SysGroup();
g.setId(id);
g.setType(type);
g.setParentId(parentId);
g.setName("组" + id);
g.setStatus(status);
return g;
}
private UbCustomDepartment custom(Integer id, Integer categoryId, String name, Integer enabled, Integer isDeleted) {
UbCustomDepartment d = new UbCustomDepartment();
d.setId(id);
d.setSchoolId(SCHOOL_A);
d.setCategoryId(categoryId);
d.setName(name);
d.setEnabled(enabled);
d.setIsDeleted(isDeleted);
d.setDelMarker(isDeleted == 1 ? id.longValue() : 0L);
d.setSortNo(1);
return d;
}
private UbCustomDeptCategory liveCategory() {
UbCustomDeptCategory c = new UbCustomDeptCategory();
c.setId(CATEGORY);
c.setSchoolId(SCHOOL_A);
c.setName("审核部门");
c.setSortNo(1);
c.setIsDeleted(0);
c.setDelMarker(0L);
return c;
}
private void stubCustom() {
when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL_A, 0))
.thenReturn(Collections.singletonList(liveCategory()));
when(departmentRepository.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(CATEGORY, 0))
.thenReturn(Arrays.asList(custom(100, CATEGORY, "校长室", 1, 0), custom(101, CATEGORY, "教务处", 0, 0)));
when(departmentRepository.findById(100)).thenReturn(Optional.of(custom(100, CATEGORY, "校长室", 1, 0)));
when(departmentRepository.findById(101)).thenReturn(Optional.of(custom(101, CATEGORY, "教务处", 0, 0)));
when(departmentRepository.findById(102)).thenReturn(Optional.of(custom(102, CATEGORY, "已撤并部门", 1, 1)));
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(liveCategory()));
when(memberRepository.findByDepartmentIdIn(anyList())).thenReturn(Collections.emptyList());
when(memberRepository.countByDepartmentId(org.mockito.ArgumentMatchers.anyInt())).thenReturn(0L);
}
// ===================== A15:应用 × 学校 =====================
/** 仅验签不等于可读任意学校:未在该校开通的应用读目录直接拒绝 */
@Test
void listRejectsAppNotOpenedAtThatSchool() {
DException error = assertThrows(DException.class,
() -> directoryService.list(APP, SCHOOL_B, DepartmentTypeEnum.SYSTEM, null, false));
assertTrue(error.getMessage().contains("未在本校开通"));
}
@Test
void listRejectsUnresolvableCallerApp() {
DException error = assertThrows(DException.class,
() -> directoryService.list(null, SCHOOL_A, null, null, false));
assertTrue(error.getMessage().contains("无法识别调用方应用"));
}
@Test
void categoriesAndMembersAlsoCheckSchoolVisibility() {
assertThrows(DException.class, () -> directoryService.categories(APP, SCHOOL_B));
// SYSTEM 组 40 属于云海中学,本应用未开通该校
when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 40, false));
}
@Test
void listRejectsSchoolIdThatIsNotASchoolGroup() {
when(sysGroupRepository.findById(7)).thenReturn(Optional.of(group(7, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A, 0)));
DException error = assertThrows(DException.class,
() -> directoryService.list(APP, 7, null, null, false));
assertTrue(error.getMessage().contains("不是学校组"));
}
// ===================== 目录过滤 =====================
/** SYSTEM 部门口径=教师组直接子节点;停用不进可选目录;学生组下的节点不算部门 */
@Test
void listSystemReturnsOnlyEnabledTeacherGroupChildren() {
List<DepartmentEntryVo> entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, null, false);
assertEquals(Collections.singletonList(20), ids(entries));
DepartmentEntryVo first = entries.get(0);
assertEquals(SCHOOL_A, first.getSchoolId());
assertEquals("SYSTEM", first.getDepartmentType());
assertEquals(Boolean.TRUE, first.getSelectable());
assertNull(first.getCategoryId(), "SYSTEM 条目不带分类");
}
@Test
void listSystemWithChildrenAppendsDescendantsWithParentId() {
List<DepartmentEntryVo> entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, null, true);
assertEquals(Arrays.asList(20, 21), ids(entries));
assertEquals(20, entries.get(1).getParentId(), "下级条目靠 parentId 还原层级");
}
@Test
void listCustomExcludesDisabledButKeepsEnabled() {
List<DepartmentEntryVo> entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.CUSTOM, CATEGORY, false);
assertEquals(Collections.singletonList(100), ids(entries));
assertEquals("审核部门", entries.get(0).getCategoryName());
assertEquals("CUSTOM", entries.get(0).getDepartmentType());
}
@Test
void listBothTypesWhenTypeOmitted() {
List<DepartmentEntryVo> entries = directoryService.list(APP, SCHOOL_A, null, null, false);
assertEquals(Arrays.asList(20, 100), ids(entries));
}
@Test
void listRejectsCategoryIdOnSystemBranch() {
assertThrows(DException.class,
() -> directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, CATEGORY, false));
}
@Test
void listCustomRejectsUnknownCategory() {
assertThrows(DException.class,
() -> directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.CUSTOM, 999, false));
}
// ===================== A12 / A10 历史解析 =====================
/** 软删与停用部门只能经 resolve-batch 拿到,且 selectable=false */
@Test
void resolveBatchReturnsDisabledAndSoftDeletedWithHistoryFlags() {
List<DepartmentEntryVo> entries = directoryService.resolveBatch(APP, Arrays.asList(
ref(SCHOOL_A, "CUSTOM", 101),
ref(SCHOOL_A, "CUSTOM", 102)));
assertEquals(Boolean.FALSE, entries.get(0).getEnabled());
assertEquals(Boolean.FALSE, entries.get(0).getDeleted());
assertEquals(Boolean.FALSE, entries.get(0).getSelectable(), "停用不可新选(A10)");
assertEquals("已撤并部门", entries.get(1).getName(), "软删行保留原名,历史展示不受影响");
assertEquals(Boolean.TRUE, entries.get(1).getDeleted());
assertEquals(Boolean.FALSE, entries.get(1).getSelectable(), "软删不可新选(A12)");
}
/** A13:软删部门保留原 id,旧引用不会指向同名新对象 */
@Test
void resolveBatchReturnsStaleIdWithoutRedirectingToNewObject() {
UbCustomDepartment fresh = custom(103, CATEGORY, "已撤并部门", 1, 0);
when(departmentRepository.findById(103)).thenReturn(Optional.of(fresh));
List<DepartmentEntryVo> entries = directoryService.resolveBatch(APP,
Collections.singletonList(ref(SCHOOL_A, "CUSTOM", 102)));
assertEquals(102, entries.get(0).getDepartmentId());
assertEquals(Boolean.TRUE, entries.get(0).getDeleted());
assertEquals(103, directoryService.resolveBatch(APP, Collections.singletonList(ref(SCHOOL_A, "CUSTOM", 103)))
.get(0).getDepartmentId());
}
/** A19:别校同名组不被当成本校历史值(按 schoolId + type + id 校验归属) */
@Test
void resolveBatchMarksCrossSchoolReferenceUnresolvable() {
when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
List<DepartmentEntryVo> entries = directoryService.resolveBatch(APP,
Collections.singletonList(ref(SCHOOL_A, "SYSTEM", 40)));
assertNull(entries.get(0).getName());
assertEquals(Boolean.TRUE, entries.get(0).getDeleted());
assertEquals(Boolean.FALSE, entries.get(0).getSelectable());
}
@Test
void resolveBatchRejectsBatchContainingUnauthorizedSchool() {
when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
List<DepartmentRefRequest> refs = Arrays.asList(ref(SCHOOL_A, "SYSTEM", 20), ref(SCHOOL_B, "SYSTEM", 40));
assertThrows(DException.class, () -> directoryService.resolveBatch(APP, refs));
}
@Test
void resolveBatchRejectsUnknownDepartmentType() {
List<DepartmentRefRequest> refs = Collections.singletonList(ref(SCHOOL_A, "FLOW", 20));
DException error = assertThrows(DException.class, () -> directoryService.resolveBatch(APP, refs));
assertTrue(error.getMessage().contains("departmentType"));
}
@Test
void resolveBatchOfEmptyRefsReturnsEmptyWithoutAuthCheck() {
assertTrue(directoryService.resolveBatch(APP, Collections.<DepartmentRefRequest>emptyList()).isEmpty());
}
/** SYSTEM 历史解析:停用组仍解析出名称,只是不可选 */
@Test
void resolveSystemReturnsDisabledGroup() {
when(sysGroupRepository.findById(22)).thenReturn(Optional.of(group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1)));
DepartmentEntryVo entry = directoryService.resolveBatch(APP,
Collections.singletonList(ref(SCHOOL_A, "SYSTEM", 22))).get(0);
assertEquals(Boolean.FALSE, entry.getEnabled());
assertEquals(Boolean.FALSE, entry.getSelectable());
assertEquals(Boolean.FALSE, entry.getDeleted(), "SYSTEM 不支持删除,deleted 恒 false");
assertEquals(7, entry.getParentId());
}
// ===================== 成员解析(select / history) =====================
@Test
void membersSelectRejectsDisabledCustomDepartment() {
DException error = assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 101, true));
assertTrue(error.getMessage().contains("停用"));
}
@Test
void membersSelectRejectsSoftDeletedCustomDepartment() {
DException error = assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 102, true));
assertTrue(error.getMessage().contains("已删除"));
}
/** A17:历史解析不因停用/软删而失败,成员照常返回 */
@Test
void membersHistoryReturnsMembersOfDisabledDepartment() {
when(memberRepository.findByDepartmentId(101)).thenReturn(Arrays.asList(member(101, 11), member(101, 12)));
when(sysUserRepository.findAllById(anyList())).thenReturn(Arrays.asList(
user(11, "甲", StatusEnum.NORMAL), user(12, "乙", StatusEnum.DISABLED)));
List<BoaUser> users = directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 101, false);
assertEquals(2, users.size(), "禁用账号不静默剔除,靠 status 交给消费方判(与 /oapi/group/users 同口径)");
assertEquals(Integer.valueOf(1), users.get(0).getStatus());
assertEquals(Integer.valueOf(0), users.get(1).getStatus());
}
@Test
void membersHistoryResolvesSoftDeletedDepartmentMembers() {
when(memberRepository.findByDepartmentId(102)).thenReturn(Collections.singletonList(member(102, 11)));
when(sysUserRepository.findAllById(anyList()))
.thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
assertEquals(1, directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 102, false).size());
}
@Test
void membersOrphanRelationRowsAreSkipped() {
when(memberRepository.findByDepartmentId(100)).thenReturn(Arrays.asList(member(100, 11), member(100, 999)));
when(sysUserRepository.findAllById(anyList()))
.thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
List<BoaUser> users = directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 100, true);
assertEquals(1, users.size());
assertEquals("acc11", users.get(0).getAccount());
}
@Test
void membersSelectRejectsDisabledSystemGroup() {
when(sysGroupRepository.findById(22)).thenReturn(Optional.of(group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1)));
assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 22, true));
}
@Test
void membersSystemHistoryReturnsGroupUsers() {
when(sysGroupRepository.findById(20)).thenReturn(Optional.of(group(20, GroupTypeEnum.NORMAL_GROUP, 7, 0)));
when(sysUserGroupRelRepository.findByGroupId(20)).thenReturn(Collections.singletonList(rel(11, 20)));
when(sysUserRepository.findAllById(anyList()))
.thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
List<BoaUser> users = directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 20, false);
assertEquals(1, users.size());
assertFalse(users.get(0).getName().isEmpty());
}
@Test
void membersRejectsMissingDepartment() {
assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 12345, true));
assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, null, true));
}
// ===================== 辅助 =====================
private List<Integer> ids(List<DepartmentEntryVo> entries) {
List<Integer> ids = new java.util.ArrayList<>();
for (DepartmentEntryVo entry : entries) {
ids.add(entry.getDepartmentId());
}
return ids;
}
private DepartmentRefRequest ref(Integer schoolId, String type, Integer id) {
DepartmentRefRequest ref = new DepartmentRefRequest();
ref.setSchoolId(schoolId);
ref.setDepartmentType(type);
ref.setDepartmentId(id);
return ref;
}
private UbCustomDepartmentMember member(Integer departmentId, Integer userId) {
UbCustomDepartmentMember m = new UbCustomDepartmentMember();
m.setSchoolId(SCHOOL_A);
m.setDepartmentId(departmentId);
m.setUserId(userId);
return m;
}
private SysUserGroupRel rel(Integer userId, Integer groupId) {
SysUserGroupRel r = new SysUserGroupRel();
r.setUserId(userId);
r.setGroupId(groupId);
return r;
}
private SysUser user(Integer id, String name, StatusEnum disabled) {
SysUser u = new SysUser();
u.setId(id);
u.setAccount("acc" + id);
u.setRealName(name);
u.setDisabled(disabled);
return u;
}
}
@@ -0,0 +1,161 @@
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.event.GroupChangedEvent;
import com.yunhai.userbase.repository.SysGroupRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@code SysGroupService.changeIncludeChildren}(2026-10-21 部门线)单元测试。<br>
* 覆盖:① 学校组上置位/复位只走 updateIncludeChildren 单列 UPDATE(不 save 不 delete);
* ② 组不存在 / id 为空抛 DException 且零写入;③ updated=0 分支抛错且**不发**缓存驱逐事件;
* ④ 非学校组节点(教师组/普通组等)拒绝,不写库不发事件;⑤ 成功路径必须发 GroupChangedEvent。
*/
class SysGroupServiceIncludeChildrenTest {
@InjectMocks
private SysGroupService sysGroupService;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private GroupSnapshotService groupSnapshotService;
@Captor
private ArgumentCaptor<GroupChangedEvent> eventCaptor;
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
}
private SysGroup node(Integer id, GroupTypeEnum type, Integer includeChildren) {
SysGroup g = new SysGroup();
g.setId(id);
g.setParentId(1);
g.setType(type);
g.setIncludeChildren(includeChildren);
return g;
}
private SysGroup school(Integer id) {
return node(id, GroupTypeEnum.SCHOOL_GROUP, 1);
}
// ===================== 置位:单列 UPDATE =====================
@Test
void turningOffUsesSingleColumnUpdateOnly() {
when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(1);
sysGroupService.changeIncludeChildren(5, false);
verify(sysGroupRepository).updateIncludeChildren(5, 0);
// 单列 UPDATE 断言:不走实体 save(会触发审计列/监听器),不碰删除
verify(sysGroupRepository, never()).save(any(SysGroup.class));
verify(sysGroupRepository, never()).deleteById(anyInt());
verify(sysGroupRepository, never()).delete(any(SysGroup.class));
}
@Test
void turningOnWritesOne() {
when(sysGroupRepository.findById(5)).thenReturn(Optional.of(node(5, GroupTypeEnum.SCHOOL_GROUP, 0)));
when(sysGroupRepository.updateIncludeChildren(5, 1)).thenReturn(1);
sysGroupService.changeIncludeChildren(5, true);
verify(sysGroupRepository).updateIncludeChildren(5, 1);
verify(sysGroupRepository, never()).save(any(SysGroup.class));
}
@Test
void successEvictsGroupSnapshotCache() {
when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(1);
sysGroupService.changeIncludeChildren(5, false);
// 不发 GroupChangedEvent = 快照最多 30min 不刷新,GetTree / oapi 组树读到旧 includeChildren
verify(eventPublisher).publishEvent(eventCaptor.capture());
assertEquals("changeIncludeChildren", eventCaptor.getValue().getSourceOp());
}
// ===================== 拒绝路径:零写入、零事件 =====================
@Test
void missingGroupRejectedWithoutAnyWrite() {
when(sysGroupRepository.findById(99)).thenReturn(Optional.empty());
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(99, false));
verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
verify(sysGroupRepository, never()).save(any(SysGroup.class));
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
@Test
void nullIdRejectedWithoutLookup() {
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(null, true));
verify(sysGroupRepository, never()).findById(any());
verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
/** 类型守卫:include_children 只是学校节点属性,误调到其他层(教师组/普通组)必须拒绝 */
@Test
void nonSchoolGroupRejectedWithoutWrite() {
when(sysGroupRepository.findById(7)).thenReturn(Optional.of(node(7, GroupTypeEnum.TEACHER_GROUP, 1)));
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(7, false));
verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
verify(sysGroupRepository, never()).save(any(SysGroup.class));
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
@Test
void legacyNullTypeRejectedWithoutWrite() {
when(sysGroupRepository.findById(8)).thenReturn(Optional.of(node(8, null, 1)));
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(8, false));
verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
/** 存在性校验与 UPDATE 之间组被并发删除:updated=0 视作不存在,且不留下虚假的缓存驱逐 */
@Test
void zeroRowsUpdatedThrowsAndDoesNotPublishEvent() {
when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(0);
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(5, false));
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
}
@@ -0,0 +1,601 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.junit.jupiter.api.AfterEach;
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 java.util.ArrayList;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@code UbCustomDepartmentService}(2026-10-21 T1 CUSTOM 部门与成员写口)单元测试。<br>
* 覆盖验收映射:A04~A09(跨校/重名/成员归属)、A10~A13(启停两态、软删保留成员与名称、
* 同名解锁走新 id)、A14 的部门侧前置;以及 D4「本校教职工、排除学生组」的服务端复核。
*/
class UbCustomDepartmentServiceTest {
private static final int SCHOOL = 5;
private static final int OTHER_SCHOOL = 6;
private static final int CATEGORY = 10;
private static final int DEPT = 20;
@InjectMocks
private UbCustomDepartmentService departmentService;
@Mock
private UbCustomDepartmentRepository departmentRepository;
@Mock
private UbCustomDeptCategoryRepository categoryRepository;
@Mock
private UbCustomDepartmentMemberRepository memberRepository;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private ISysGroupService sysGroupService;
@Mock
private AccessScopeService accessScopeService;
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
}
@AfterEach
void tearDown() {
UserContext.clear();
}
// ===================== 夹具 =====================
private UbCustomDeptCategory category(Integer id, Integer schoolId) {
UbCustomDeptCategory c = new UbCustomDeptCategory();
c.setId(id);
c.setSchoolId(schoolId);
c.setName("审核部门");
c.setSortNo(1);
c.setIsDeleted(0);
c.setDelMarker(0L);
return c;
}
private UbCustomDepartment dept(Integer id, Integer schoolId, Integer categoryId, String name) {
UbCustomDepartment d = new UbCustomDepartment();
d.setId(id);
d.setSchoolId(schoolId);
d.setCategoryId(categoryId);
d.setName(name);
d.setSortNo(1);
d.setEnabled(1);
d.setIsDeleted(0);
d.setDelMarker(0L);
return d;
}
private SysUser user(Integer id, String realName) {
SysUser u = new SysUser();
u.setId(id);
u.setAccount("acc" + id);
u.setRealName(realName);
u.setDisabled(StatusEnum.NORMAL);
return u;
}
private SysUserGroupRel rel(Integer userId, Integer groupId) {
SysUserGroupRel r = new SysUserGroupRel();
r.setUserId(userId);
r.setGroupId(groupId);
return r;
}
private SysGroup group(Integer id, GroupTypeEnum type) {
SysGroup g = new SysGroup();
g.setId(id);
g.setParentId(SCHOOL);
g.setType(type);
return g;
}
private void stubLiveCategory() {
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, SCHOOL)));
}
private void stubLiveDepartment() {
when(departmentRepository.findById(DEPT))
.thenReturn(Optional.of(dept(DEPT, SCHOOL, CATEGORY, "校长室")));
}
/**
* findAllById 必须按<b>实际入参</b>返回(桩若无视参数,学生 30 会被当成教职工,
* 「禁用账号不能加入」也会假通过)。
*/
private void stubUserRepository(SysUser... users) {
final Map<Integer, SysUser> byId = new HashMap<>();
for (SysUser user : users) {
byId.put(user.getId(), user);
}
when(sysUserRepository.findAllById(anyList())).thenAnswer(invocation -> {
Iterable<Integer> ids = invocation.getArgument(0);
List<SysUser> hits = new ArrayList<>();
for (Integer id : ids) {
if (byId.containsKey(id)) {
hits.add(byId.get(id));
}
}
return hits;
});
}
/**
* 学校子树 5:教师组 7 / 学生组 8(下挂年级组 21)。<br>
* 11、12 只在教师组下(教职工);30 同时挂教师组与学生组子树(按 D4 排除)。
*/
private void stubSchoolMembership() {
when(sysGroupService.listSelfAndDescendantIds(SCHOOL))
.thenReturn(Arrays.asList(SCHOOL, 7, 8, 21));
when(sysGroupService.listSelfAndDescendantIds(8)).thenReturn(Arrays.asList(8, 21));
when(sysGroupRepository.findAllById(anyList())).thenReturn(Arrays.asList(
group(SCHOOL, GroupTypeEnum.SCHOOL_GROUP),
group(7, GroupTypeEnum.TEACHER_GROUP),
group(8, GroupTypeEnum.STUDENT_GROUP),
group(21, GroupTypeEnum.GRADE_GROUP)));
when(sysUserGroupRelRepository.findByGroupIdIn(Arrays.asList(8, 21)))
.thenReturn(Collections.singletonList(rel(30, 21)));
when(sysUserGroupRelRepository.findByGroupIdIn(Arrays.asList(SCHOOL, 7, 8, 21)))
.thenReturn(Arrays.asList(rel(11, 7), rel(12, 7), rel(30, 21)));
stubUserRepository(user(11, "甲"), user(12, "乙"), user(30, "丙"));
}
// ===================== 创建 / 重名 =====================
@Test
void addCreatesEnabledDepartmentAtTailOfSort() {
stubLiveCategory();
when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
.thenReturn(Optional.empty());
when(departmentRepository.findMaxSortNoByCategory(CATEGORY)).thenReturn(3);
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
departmentService.add(request);
ArgumentCaptor<UbCustomDepartment> captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
verify(departmentRepository).save(captor.capture());
UbCustomDepartment saved = captor.getValue();
assertEquals(1, saved.getEnabled(), "新建默认启用(规格书 §2.2)");
assertEquals(0, saved.getIsDeleted());
assertEquals(4, saved.getSortNo());
assertEquals(SCHOOL, saved.getSchoolId(), "落库学校取服务端解析值,不是前端传值");
}
/** A06/A05:同校同分类重名拒绝;uk 之外给友好报错,且不落库 */
@Test
void addRejectsDuplicateNameInSameCategory() {
stubLiveCategory();
when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
.thenReturn(Optional.of(dept(21, SCHOOL, CATEGORY, "校长室")));
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
DException error = assertThrows(DException.class, () -> departmentService.add(request));
assertTrue(error.getMessage().contains("同名部门"));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
/** A09:分类跨校(请求校与分类校不一致)直接拒绝 */
@Test
void addRejectsCategoryOfAnotherSchool() {
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, OTHER_SCHOOL)));
when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
assertThrows(DException.class, () -> departmentService.add(request));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
@Test
void addRejectsMissingCategoryWithoutPersist() {
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.empty());
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
assertThrows(DException.class, () -> departmentService.add(request));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
// ===================== 移动 =====================
@Test
void moveRejectsCrossSchoolTargetCategory() {
stubLiveDepartment();
when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, OTHER_SCHOOL)));
CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(11);
assertThrows(DException.class, () -> departmentService.move(DEPT, request));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
@Test
void moveRejectsDuplicateNameInTargetCategory() {
stubLiveDepartment();
when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, SCHOOL)));
when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, 11, "校长室", 0))
.thenReturn(Optional.of(dept(22, SCHOOL, 11, "校长室")));
CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(11);
assertThrows(DException.class, () -> departmentService.move(DEPT, request));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
@Test
void moveAcceptsSameSchoolTargetCategory() {
stubLiveDepartment();
when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, SCHOOL)));
when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, 11, "校长室", 0))
.thenReturn(Optional.empty());
when(departmentRepository.findMaxSortNoByCategory(11)).thenReturn(null);
CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(11);
departmentService.move(DEPT, request);
ArgumentCaptor<UbCustomDepartment> captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
verify(departmentRepository).save(captor.capture());
assertEquals(11, captor.getValue().getCategoryId());
assertEquals(1, captor.getValue().getSortNo(), "空目标分类落到首位");
}
// ===================== 启停(A10/A11) =====================
/** A10:停用走单列 UPDATE,不走实体 save(避免动审计列/其他列),成员与软删列不受影响 */
@Test
void disableUsesSingleColumnUpdateOnly() {
stubLiveDepartment();
when(departmentRepository.updateEnabled(DEPT, 0)).thenReturn(1);
departmentService.changeEnabled(DEPT, false);
verify(departmentRepository).updateEnabled(DEPT, 0);
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
/** A11:启用同样单列翻转,恢复可选,原 id 与成员不变 */
@Test
void enableWritesOneAndKeepsIdentity() {
stubLiveDepartment();
when(departmentRepository.updateEnabled(DEPT, 1)).thenReturn(1);
departmentService.changeEnabled(DEPT, true);
verify(departmentRepository).updateEnabled(DEPT, 1);
verify(memberRepository, never()).deleteByDepartmentIdAndUserIds(anyInt(), anyList());
}
@Test
void changeEnabledRejectsMissingDepartmentAndZeroRowUpdate() {
when(departmentRepository.findById(99)).thenReturn(Optional.empty());
assertThrows(DException.class, () -> departmentService.changeEnabled(99, false));
stubLiveDepartment();
when(departmentRepository.updateEnabled(DEPT, 0)).thenReturn(0);
assertThrows(DException.class, () -> departmentService.changeEnabled(DEPT, false));
}
@Test
void changeEnabledRejectsSoftDeletedDepartment() {
UbCustomDepartment deleted = dept(DEPT, SCHOOL, CATEGORY, "已删部门");
deleted.setIsDeleted(1);
deleted.setDelMarker((long) DEPT);
when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(deleted));
assertThrows(DException.class, () -> departmentService.changeEnabled(DEPT, true));
verify(departmentRepository, never()).updateEnabled(anyInt(), anyInt());
}
// ===================== 软删(A12/A13) =====================
/** A12:软删只打标记,成员关系保留在库(由查询侧过滤),不物理删任何东西 */
@Test
void softDeleteKeepsMemberRowsAndName() {
UserContext.setCurrentUser(91);
stubLiveDepartment();
departmentService.softDelete(DEPT);
ArgumentCaptor<UbCustomDepartment> captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
verify(departmentRepository).save(captor.capture());
UbCustomDepartment deleted = captor.getValue();
assertEquals(1, deleted.getIsDeleted());
assertEquals((long) DEPT, deleted.getDelMarker().longValue());
assertEquals("校长室", deleted.getName(), "名称永不改写,历史解析拿到的就是原名");
assertEquals(Integer.valueOf(91), deleted.getDeletedBy());
verify(memberRepository, never()).deleteByDepartmentIdAndUserIds(anyInt(), anyList());
verify(memberRepository, never()).delete(any(UbCustomDepartmentMember.class));
}
/**
* A13:软删行不再占用同名校验(查询带 is_deleted=0),同名新建放行且一定是<b>新 id</b>
* (自增主键,实体保存前 id 仍为空);旧引用不会指向新对象。
*/
@Test
void sameNameAllowedAfterSoftDeleteAndGetsNewId() {
stubLiveCategory();
when(departmentRepository
.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
.thenReturn(Optional.empty());
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
departmentService.add(request);
ArgumentCaptor<UbCustomDepartment> captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
verify(departmentRepository).save(captor.capture());
assertEquals(null, captor.getValue().getId(), "新建走新 id,不复用软删行主键");
}
// ===================== 成员(A07/A08/D4) =====================
/** D4:学生组下的用户即使同校也不能加入;教职工放行 */
@Test
void addMembersRejectsStudentUser() {
stubLiveDepartment();
stubSchoolMembership();
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Collections.singletonList(30));
DException error = assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
assertTrue(error.getMessage().contains("本校教职工"));
verify(memberRepository, never()).saveAll(anyList());
}
@Test
void addMembersRejectsUserOutsideSchoolScope() {
stubLiveDepartment();
stubSchoolMembership();
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Collections.singletonList(777));
assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
verify(memberRepository, never()).saveAll(anyList());
}
/** A08:重复添加幂等——已存在的成员不再插入第二条有效关系 */
@Test
void addMembersSkipsExistingRelationsIdempotently() {
stubLiveDepartment();
stubSchoolMembership();
UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
existing.setDepartmentId(DEPT);
existing.setUserId(11);
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(new ArrayList<>(Arrays.asList(11, 12)));
departmentService.addMembers(DEPT, request);
ArgumentCaptor<List<UbCustomDepartmentMember>> captor = ArgumentCaptor.forClass(List.class);
verify(memberRepository).saveAll(captor.capture());
assertEquals(1, captor.getValue().size());
assertEquals(Integer.valueOf(12), captor.getValue().get(0).getUserId());
assertEquals(SCHOOL, captor.getValue().get(0).getSchoolId(), "成员行冗余学校须与部门同校");
}
@Test
void addMembersWithAllDuplicatesIsNoop() {
stubLiveDepartment();
stubSchoolMembership();
UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
existing.setDepartmentId(DEPT);
existing.setUserId(11);
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Arrays.asList(11, 11));
departmentService.addMembers(DEPT, request);
verify(memberRepository, never()).saveAll(anyList());
}
/** D4 补充:禁用账号不能作为新成员 */
@Test
void addMembersRejectsDisabledAccount() {
stubLiveDepartment();
stubSchoolMembership();
SysUser disabled = user(11, "甲");
disabled.setDisabled(StatusEnum.DISABLED);
stubUserRepository(disabled, user(12, "乙"), user(30, "丙"));
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Collections.singletonList(11));
assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
verify(memberRepository, never()).saveAll(anyList());
}
@Test
void addMembersRejectsEmptyList() {
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Collections.emptyList());
assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
}
/** 移除成员=物理删关系行 */
@Test
void removeMembersDeletesRelationRowsPhysically() {
stubLiveDepartment();
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Arrays.asList(11, 11, null));
departmentService.removeMembers(DEPT, request);
verify(memberRepository).deleteByDepartmentIdAndUserIds(DEPT, Arrays.asList(11));
}
@Test
void membersListSkipsOrphanUserRows() {
stubLiveDepartment();
UbCustomDepartmentMember live = new UbCustomDepartmentMember();
live.setDepartmentId(DEPT);
live.setUserId(11);
UbCustomDepartmentMember orphan = new UbCustomDepartmentMember();
orphan.setDepartmentId(DEPT);
orphan.setUserId(999);
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Arrays.asList(live, orphan));
when(sysUserRepository.findAllById(anyList())).thenReturn(Collections.singletonList(user(11, "甲")));
assertEquals(1, departmentService.members(DEPT).size());
}
// ===================== 跨校(A09) =====================
@Test
void crossSchoolDepartmentWriteIsRejected() {
UbCustomDepartment foreign = dept(DEPT, OTHER_SCHOOL, CATEGORY, "别校部门");
when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(foreign));
org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
.when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
assertThrows(DException.class, () -> departmentService.softDelete(DEPT));
assertThrows(DException.class, () -> departmentService.members(DEPT));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
@Test
void listRejectsForeignCategory() {
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, OTHER_SCHOOL)));
assertThrows(DException.class, () -> departmentService.list(SCHOOL, CATEGORY));
}
// ===================== 候选教职工(选人弹窗数据源) =====================
/** 候选口径与写口一致:学生(30)不出现,已在部门内的(11)不重复出现 */
@Test
void candidateUsersExcludeStudentsAndExistingMembers() {
stubLiveDepartment();
stubSchoolMembership();
UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
existing.setDepartmentId(DEPT);
existing.setUserId(11);
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
List<CustomDepartmentMemberVo> candidates = departmentService.candidateUsers(DEPT, null);
assertEquals(1, candidates.size());
assertEquals(Integer.valueOf(12), candidates.get(0).getUserId());
}
@Test
void candidateUsersFilterByKeywordIgnoringCase() {
stubLiveDepartment();
stubSchoolMembership();
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
List<CustomDepartmentMemberVo> hits = departmentService.candidateUsers(DEPT, " ACC12 ");
assertEquals(1, hits.size());
assertEquals(Integer.valueOf(12), hits.get(0).getUserId());
}
@Test
void candidateUsersRejectCrossSchoolDepartment() {
UbCustomDepartment foreign = dept(DEPT, OTHER_SCHOOL, CATEGORY, "别校部门");
when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(foreign));
org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
.when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
assertThrows(DException.class, () -> departmentService.candidateUsers(DEPT, null));
}
}
@@ -0,0 +1,250 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.junit.jupiter.api.AfterEach;
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 java.util.Arrays;
import java.util.Collections;
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.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@code UbCustomDeptCategoryService}(2026-10-21 T1 CUSTOM 分类写口)单元测试。<br>
* 覆盖验收映射:A09 跨校写拒绝、A14 非空分类软删明确拒绝(无级联)、
* A13 软删只让位唯一键(del_marker=行 id、name 不改写)、重名前置校验、排序范围校验。
*/
class UbCustomDeptCategoryServiceTest {
private static final int SCHOOL = 5;
private static final int OTHER_SCHOOL = 6;
@InjectMocks
private UbCustomDeptCategoryService categoryService;
@Mock
private UbCustomDeptCategoryRepository categoryRepository;
@Mock
private UbCustomDepartmentRepository departmentRepository;
@Mock
private AccessScopeService accessScopeService;
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
}
@AfterEach
void tearDown() {
UserContext.clear();
}
private UbCustomDeptCategory category(Integer id, Integer schoolId, String name) {
UbCustomDeptCategory c = new UbCustomDeptCategory();
c.setId(id);
c.setSchoolId(schoolId);
c.setName(name);
c.setSortNo(1);
c.setIsDeleted(0);
c.setDelMarker(0L);
return c;
}
private CustomDeptCategoryRequest request(Integer schoolId, String name) {
CustomDeptCategoryRequest request = new CustomDeptCategoryRequest();
request.setSchoolId(schoolId);
request.setName(name);
return request;
}
// ===================== 创建 =====================
@Test
void addPersistsNewCategoryAtTailOfSort() {
when(categoryRepository.existsBySchoolIdAndNameAndIsDeleted(SCHOOL, "审核部门", 0)).thenReturn(false);
when(categoryRepository.findMaxSortNoBySchool(SCHOOL)).thenReturn(7);
categoryService.add(request(SCHOOL, "审核部门"));
ArgumentCaptor<UbCustomDeptCategory> captor = ArgumentCaptor.forClass(UbCustomDeptCategory.class);
verify(categoryRepository).save(captor.capture());
UbCustomDeptCategory saved = captor.getValue();
assertEquals(SCHOOL, saved.getSchoolId());
assertEquals("审核部门", saved.getName());
assertEquals(8, saved.getSortNo(), "新建分类排到最后");
assertEquals(0, saved.getIsDeleted());
assertEquals(0L, saved.getDelMarker(), "有效行 del_marker 必须为 0,否则让不出同名唯一键");
}
@Test
void addRejectsDuplicateNameWithinSchool() {
when(categoryRepository.existsBySchoolIdAndNameAndIsDeleted(SCHOOL, "审核部门", 0)).thenReturn(true);
DException error = assertThrows(DException.class, () -> categoryService.add(request(SCHOOL, "审核部门")));
assertTrue(error.getMessage().contains("同名分类"));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
/** A09:请求体的 schoolId 只作核对,越权由学校维度 guard 拒绝,服务层不落库 */
@Test
void addRejectsCrossSchoolWriteWithoutPersist() {
when(accessScopeService.resolveWritableSchoolId(OTHER_SCHOOL))
.thenThrow(new DException("暂无权限访问该组织"));
assertThrows(DException.class, () -> categoryService.add(request(OTHER_SCHOOL, "审核部门")));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
verify(categoryRepository, never()).existsBySchoolIdAndNameAndIsDeleted(anyInt(), anyString(), anyInt());
}
@Test
void addTrimsBlankNameAsError() {
assertThrows(DException.class, () -> categoryService.add(request(SCHOOL, " ")));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
// ===================== 改名 =====================
@Test
void renameRejectsNameTakenByAnotherLiveCategory() {
when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "旧分类")));
when(categoryRepository.findFirstBySchoolIdAndNameAndIsDeleted(SCHOOL, "事项部门", 0))
.thenReturn(Optional.of(category(4, SCHOOL, "事项部门")));
assertThrows(DException.class, () -> categoryService.rename(3, request(SCHOOL, "事项部门")));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
@Test
void renameRejectsCrossSchoolCategory() {
when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, OTHER_SCHOOL, "别校分类")));
org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
.when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
assertThrows(DException.class, () -> categoryService.rename(3, request(SCHOOL, "改名")));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
// ===================== 软删(A14 / A13) =====================
/** A14:分类下仍有有效部门 → 明确拒绝,不允许任何级联删除 */
@Test
void softDeleteRejectedWhenCategoryStillHasDepartments() {
when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "审核部门")));
when(departmentRepository.countByCategoryIdAndIsDeleted(3, 0)).thenReturn(2L);
DException error = assertThrows(DException.class, () -> categoryService.softDelete(3));
assertTrue(error.getMessage().contains("2"), "报错需给出剩余部门数,便于用户先处理");
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
/** A13 前提:软删只让位唯一键(del_marker=行 id),name 永不改写,旧引用仍指向原名原 id */
@Test
void softDeleteMarksDelMarkerAndKeepsName() {
UserContext.setCurrentUser(91);
when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "审核部门")));
when(departmentRepository.countByCategoryIdAndIsDeleted(3, 0)).thenReturn(0L);
categoryService.softDelete(3);
ArgumentCaptor<UbCustomDeptCategory> captor = ArgumentCaptor.forClass(UbCustomDeptCategory.class);
verify(categoryRepository).save(captor.capture());
UbCustomDeptCategory deleted = captor.getValue();
assertEquals(1, deleted.getIsDeleted());
assertEquals(3L, deleted.getDelMarker(), "del_marker 必须置为自身 id,uk 才让位");
assertEquals("审核部门", deleted.getName(), "软删不得改写名称");
assertEquals(Integer.valueOf(91), deleted.getDeletedBy());
assertTrue(deleted.getDeletedAt() != null);
}
@Test
void softDeleteRejectsMissingOrAlreadyDeletedCategory() {
UbCustomDeptCategory deleted = category(4, SCHOOL, "已删分类");
deleted.setIsDeleted(1);
deleted.setDelMarker(4L);
when(categoryRepository.findById(3)).thenReturn(Optional.empty());
when(categoryRepository.findById(4)).thenReturn(Optional.of(deleted));
assertThrows(DException.class, () -> categoryService.softDelete(3));
assertThrows(DException.class, () -> categoryService.softDelete(4));
// 空 id 走同一分支:不查库、不落库
assertThrows(DException.class, () -> categoryService.softDelete(null));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
verify(departmentRepository, never()).countByCategoryIdAndIsDeleted(anyInt(), anyInt());
}
// ===================== 排序 =====================
@Test
void sortRejectsForeignCategoryIds() {
when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL, 0))
.thenReturn(Collections.singletonList(category(3, SCHOOL, "甲")));
CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
request.setSchoolId(SCHOOL);
request.setIds(Arrays.asList(3, 99));
assertThrows(DException.class, () -> categoryService.sort(request));
verify(categoryRepository, never()).saveAll(anyList());
}
@Test
void sortWritesAscendingSequenceByGivenOrder() {
UbCustomDeptCategory a = category(3, SCHOOL, "甲");
UbCustomDeptCategory b = category(4, SCHOOL, "乙");
when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL, 0))
.thenReturn(Arrays.asList(a, b));
CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
request.setSchoolId(SCHOOL);
request.setIds(Arrays.asList(4, 3));
categoryService.sort(request);
assertEquals(1, b.getSortNo());
assertEquals(2, a.getSortNo());
verify(categoryRepository).saveAll(eq(Arrays.asList(a, b)));
}
@Test
void sortRejectsDuplicateIds() {
CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
request.setSchoolId(SCHOOL);
request.setIds(Arrays.asList(3, 3));
assertThrows(DException.class, () -> categoryService.sort(request));
verify(categoryRepository, never()).saveAll(anyList());
}
}
@@ -0,0 +1,241 @@
package com.yunhai.userbase.service.resolve;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
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.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.enums.SysRoleEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
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 org.junit.jupiter.api.AfterEach;
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.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;
/**
* 学校维度 guard 单测(2026-10-21 T1 CUSTOM 部门线新增)。<br>
* CUSTOM 分类/部门只以学校作组织锚点,现有 guard 全是「组」维度,
* 故 {@code requireSchoolInScope} / {@code resolveWritableSchoolId} 必须独立验证:
* 请求体 schoolId 只作核对、不作授权(A09)。
*/
class AccessScopeServiceSchoolGuardTest {
private static final Integer USER_ID = 1;
private static final Integer SCHOOL_A = 10;
private static final Integer SCHOOL_B = 20;
private static final Integer TEACHER_GROUP_IN_A = 101;
/** 教师组下的部门(普通组),用于验证「组在范围内但不是学校」也按学校维度拒绝 */
private static final Integer DEPT_GROUP_IN_A = 102;
@InjectMocks
private AccessScopeService accessScopeService;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private SysUserRoleRelRepository sysUserRoleRelRepository;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
@Mock
private MainGroupResolver mainGroupResolver;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
AccessScopeService.clearCache();
UserContext.clear();
when(sysGroupRepository.findAll()).thenReturn(Arrays.asList(
group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0),
group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0),
group(TEACHER_GROUP_IN_A, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A),
group(DEPT_GROUP_IN_A, GroupTypeEnum.NORMAL_GROUP, TEACHER_GROUP_IN_A)
));
}
@AfterEach
void tearDown() {
AccessScopeService.clearCache();
UserContext.clear();
}
private SysGroup group(Integer id, GroupTypeEnum type, Integer parentId) {
SysGroup g = new SysGroup();
g.setId(id);
g.setType(type);
g.setParentId(parentId);
return g;
}
private SysUser user(UserTypeEnum type) {
SysUser u = new SysUser();
u.setId(USER_ID);
u.setType(type);
return u;
}
private SysUserRoleRel adminRel(Integer schoolId) {
SysUserRoleRel rel = new SysUserRoleRel();
rel.setUserId(USER_ID);
rel.setRoleId(SysRoleEnum.ADMIN.getKey());
rel.setSchoolId(schoolId);
rel.setStatus(StatusEnum.NORMAL);
return rel;
}
/** 学校管理员(单校):身份行小学 + 当前激活身份小学 */
private void givenSingleSchoolAdmin() {
UserContext.setCurrentUser(USER_ID);
when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SCHOOL_ADMIN)));
when(sysUserRoleRelRepository.findByUserIdAndStatus(USER_ID, StatusEnum.NORMAL))
.thenReturn(java.util.Collections.singletonList(adminRel(SCHOOL_A)));
SysUserCurrentRole current = new SysUserCurrentRole();
current.setUserId(USER_ID);
current.setSchoolId(SCHOOL_A);
when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.of(current));
}
private void givenSuper() {
UserContext.setCurrentUser(USER_ID);
when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SUPER_ADMIN)));
}
/** 多校管理员且未指定学校:主组解析不出(保留历史宽口径 → 两校都在范围内) */
private void givenTwoSchoolAdminWithoutActiveRole() {
UserContext.setCurrentUser(USER_ID);
when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SCHOOL_ADMIN)));
when(sysUserRoleRelRepository.findByUserIdAndStatus(USER_ID, StatusEnum.NORMAL))
.thenReturn(Arrays.asList(adminRel(SCHOOL_A), adminRel(SCHOOL_B)));
when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.empty());
when(mainGroupResolver.resolve(anyInt())).thenReturn(Optional.<SysUserGroupRel>empty());
}
// ===================== requireSchoolInScope =====================
@Test
void requireSchoolInScopeRejectsNull() {
givenSuper();
DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(null));
assertEquals("学校ID不能为空", error.getMessage());
}
@Test
void requireSchoolInScopeRejectsUnknownGroup() {
givenSuper();
when(sysGroupRepository.findById(999)).thenReturn(Optional.empty());
DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(999));
assertEquals("学校分组不存在", error.getMessage());
}
/** 教师组在自己的数据范围内,但不是学校 → 学校维度仍要拒绝(不能拿组维度 guard 蒙混) */
@Test
void requireSchoolInScopeRejectsNonSchoolTypeEvenInsideScope() {
givenSingleSchoolAdmin();
when(sysGroupRepository.findById(TEACHER_GROUP_IN_A))
.thenReturn(Optional.of(group(TEACHER_GROUP_IN_A, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A)));
DException error = assertThrows(DException.class,
() -> accessScopeService.requireSchoolInScope(TEACHER_GROUP_IN_A));
assertEquals("学校ID必须为学校分组", error.getMessage());
}
/** A09:学校管理员只能命中本校 */
@Test
void requireSchoolInScopeRejectsAnotherSchoolForSchoolAdmin() {
givenSingleSchoolAdmin();
when(sysGroupRepository.findById(SCHOOL_B))
.thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(SCHOOL_B));
assertTrue(error.getMessage().contains("暂无权限"));
}
@Test
void requireSchoolInScopeAcceptsOwnSchool() {
givenSingleSchoolAdmin();
when(sysGroupRepository.findById(SCHOOL_A))
.thenReturn(Optional.of(group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0)));
accessScopeService.requireSchoolInScope(SCHOOL_A);
}
@Test
void requireSchoolInScopeAcceptsAnySchoolForSuper() {
givenSuper();
when(sysGroupRepository.findById(SCHOOL_B))
.thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
accessScopeService.requireSchoolInScope(SCHOOL_B);
}
// ===================== resolveWritableSchoolId =====================
/** 单校管理员不传 schoolId → 取本校(写口仍落在权威学校上) */
@Test
void resolveWritableSchoolIdUsesOwnSchoolWhenNotSpecified() {
givenSingleSchoolAdmin();
assertEquals(SCHOOL_A, accessScopeService.resolveWritableSchoolId(null));
}
/** 单校管理员传别校 id → 拒绝,且不回落到本校(不能静默改写用户意图) */
@Test
void resolveWritableSchoolIdRejectsForeignSchool() {
givenSingleSchoolAdmin();
DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(SCHOOL_B));
assertTrue(error.getMessage().contains("暂无权限"));
}
@Test
void resolveWritableSchoolIdAcceptsMatchedOwnSchool() {
givenSingleSchoolAdmin();
assertEquals(SCHOOL_A, accessScopeService.resolveWritableSchoolId(SCHOOL_A));
}
/** 多校管理员未指定学校 → 要求补齐,不猜学校 */
@Test
void resolveWritableSchoolIdRequiresChoiceForMultiSchoolAdmin() {
givenTwoSchoolAdminWithoutActiveRole();
DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(null));
assertTrue(error.getMessage().contains("请选择"));
}
/** 未登录(UserContext 为空 → NONE)→ 暂无权限 */
@Test
void resolveWritableSchoolIdRejectsAnonymous() {
DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(SCHOOL_A));
assertEquals("暂无权限", error.getMessage());
}
/** SUPER:以请求学校为准(校维度 guard 兜范围) */
@Test
void resolveWritableSchoolIdUsesRequestedSchoolForSuper() {
givenSuper();
when(sysGroupRepository.findById(SCHOOL_B))
.thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
assertEquals(SCHOOL_B, accessScopeService.resolveWritableSchoolId(SCHOOL_B));
}
}