diff --git a/sql/20261021_add_sys_group_include_children.sql b/sql/20261021_add_sys_group_include_children.sql new file mode 100644 index 0000000..335ab78 --- /dev/null +++ b/sql/20261021_add_sys_group_include_children.sql @@ -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; diff --git a/sql/20261021_create_ub_custom_department_tables.sql b/sql/20261021_create_ub_custom_department_tables.sql new file mode 100644 index 0000000..591f0a1 --- /dev/null +++ b/sql/20261021_create_ub_custom_department_tables.sql @@ -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; diff --git a/src/main/java/com/yunhai/userbase/controller/api/CustomDeptCategoryController.java b/src/main/java/com/yunhai/userbase/controller/api/CustomDeptCategoryController.java new file mode 100644 index 0000000..4f565ee --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/CustomDeptCategoryController.java @@ -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 侧,部门管理页左侧平铺列表的数据源)。
+ * 动词式端点、{@code /api} 前缀与 {@code SysGroupController} 同范式; + * 四级访问范围与跨校拦截在 Service 内统一执行(写口只认登录身份,不认请求体 schoolId)。
+ * 分类是容器:无启停、无成员;删除只有软删,且非空分类明确拒绝(无级联)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/deptcategory/") +public class CustomDeptCategoryController { + + @Autowired + private ICustomDeptCategoryService customDeptCategoryService; + + @Operation(operationId = "customDeptCategoryList") + @GetMapping("List") + public List 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); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/CustomDeptController.java b/src/main/java/com/yunhai/userbase/controller/api/CustomDeptController.java new file mode 100644 index 0000000..b88cde8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/CustomDeptController.java @@ -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 侧:部门本体 + 启停 + 软删 + 成员)。
+ * 与 SYSTEM 组织树完全隔离:本控制器不读写 {@code sys_group} 的任何写路径, + * 也不提供删除 SYSTEM 部门的入口(任务书 D1)。
+ * 跨校/学生成员等校验全部在 Service 服务端执行,前端传值只作核对。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/customdept/") +public class CustomDeptController { + + @Autowired + private ICustomDepartmentService customDepartmentService; + + @Operation(operationId = "customDeptList") + @GetMapping("List") + public List 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。
+ * 停用只影响「选」(新业务选不到),不影响「看」与「办」(历史/在途仍可解析),与 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 members(@PathVariable Integer id) { + return customDepartmentService.members(id); + } + + /** + * 可添加的候选教职工:本校教职工(服务端排除学生组)且尚未在该部门。
+ * 现有 {@code GroupAuthz.GroupUsers} 是组维度、会带出学生组用户,不能当候选源,故单独成口(D4)。 + */ + @Operation(operationId = "customDeptCandidateUsers") + @GetMapping("CandidateUsers/{id}") + public List 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); + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/DepartmentController.java b/src/main/java/com/yunhai/userbase/controller/oapi/DepartmentController.java new file mode 100644 index 0000000..81c0935 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/oapi/DepartmentController.java @@ -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)。
+ *
+ * 鉴权链路照 {@code /oapi/group} 现行做法:验签由 {@code OpenApiAuthEnforceConfiguration} 的 + * {@code RequireAppSignFilter} 在过滤器层强制(无签名直接 401),本层不做验签; + * 但验签只证明「是哪个应用」,能否读这所学校由 {@code sys_group_app_rel}(应用在该校已开通)决定, + * 越校读取抛业务异常(A15)。
+ * 引用一律 {@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; + + /** + * 可选部门目录:默认只返回启用且未软删的条目。
+ * {@code type=SYSTEM|CUSTOM}(缺省两类都返回);{@code categoryId} 只对 CUSTOM 生效; + * {@code withChildren=true} 只对 SYSTEM 生效,连同部门下级一起返回(扁平 + parentId)。 + */ + @GetMapping + public List 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 categories(@RequestParam("schoolId") Integer schoolId, + HttpServletRequest request) { + return departmentDirectoryService.categories(resolveCallerAppId(request), schoolId); + } + + /** + * 批量解析引用(唯一允许返回停用/软删部门的读口,A12)。
+ * 一批里任一学校未对本应用开通 → 整批拒绝;解析不到的引用返回 name=null、selectable=false。 + */ + @PostMapping("resolve-batch") + public List resolveBatch(@RequestBody @Validated List refs, + HttpServletRequest request) { + return departmentDirectoryService.resolveBatch(resolveCallerAppId(request), refs); + } + + /** + * 部门成员解析。{@code scope=select}(缺省)=供新的指派:部门停用/软删直接拒绝;
+ * {@code scope=history}=已有实例解析:停用/软删照常返回成员,不影响在途(A17)。
+ * 出参字段与 {@code /oapi/group/users} 同形状(轻量 BoaUser,禁用账号靠 status=0 标识、不静默剔除)。 + */ + @GetMapping("{type}/{id}/members") + public List 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} 同一链路)。
+ * 解析不出应用 → 传 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/entity/UbCustomDepartment.java b/src/main/java/com/yunhai/userbase/entity/UbCustomDepartment.java new file mode 100644 index 0000000..b350f42 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/UbCustomDepartment.java @@ -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 侧可指派对象:有成员、有启停、删除为软删)。
+ * 与 SYSTEM 组织树完全分离:不改组织树、不混入自定义节点; + * 主键独立于 {@code sys_group.id},跨应用引用一律带 {@code departmentType} 消歧,禁止以名称做键。
+ * 唯一性由 {@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=停用。
+ * ⚠ 语义与 {@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; + } +} diff --git a/src/main/java/com/yunhai/userbase/entity/UbCustomDepartmentMember.java b/src/main/java/com/yunhai/userbase/entity/UbCustomDepartmentMember.java new file mode 100644 index 0000000..aeae222 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/UbCustomDepartmentMember.java @@ -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},不复制账号)。
+ * 一个用户可属同校多个 CUSTOM 部门;{@code uk_department_user(department_id, user_id)} 挡有效重复。
+ * 移除成员=物理删该关系行(本仓无软删惯例,增删审计由 {@code @OperationLog} 承担), + * 因此不会残留「已移除但仍有效」的成员。
+ * 成员范围=本校教职工且排除学生组(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; +} diff --git a/src/main/java/com/yunhai/userbase/entity/UbCustomDeptCategory.java b/src/main/java/com/yunhai/userbase/entity/UbCustomDeptCategory.java new file mode 100644 index 0000000..083232d --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/UbCustomDeptCategory.java @@ -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 侧容器层)。
+ * 分类只是管理容器:没有启停状态、不能配成员,不是可指派的业务部门。
+ * 唯一性由 {@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; + } +} diff --git a/src/main/java/com/yunhai/userbase/enums/DepartmentTypeEnum.java b/src/main/java/com/yunhai/userbase/enums/DepartmentTypeEnum.java new file mode 100644 index 0000000..5f6bb55 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/DepartmentTypeEnum.java @@ -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 部门来源类型(跨业务统一引用的消歧位)。
+ * 只有 {@code SYSTEM}(原组织架构组)与 {@code CUSTOM}(学校自定义部门)两种, + * 不得扩成 FLOW/SVC/AUDIT 等业务类型;引用一律「schoolId + departmentType + departmentId」,禁止以名称做键。 + */ +@Getter +@AllArgsConstructor +public enum DepartmentTypeEnum implements IEnum { + + 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMemberRequest.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMemberRequest.java new file mode 100644 index 0000000..eb11d5b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMemberRequest.java @@ -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 部门成员批量添加/移除请求。
+ * 服务端逐个复核每个 userId:本校 + 教职工(排除学生组 type=8 下的用户)+ 有效账号; + * 任一不合格整批拒绝(不返回「部分成功」,避免前端以为已生效)。 + */ +@Data +public class CustomDepartmentMemberRequest { + + /** 学校组 id(可空:单校管理员由服务端取本校)。 */ + private Integer schoolId; + + @NotEmpty(message = "成员列表不能为空") + private List userIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMemberVo.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMemberVo.java new file mode 100644 index 0000000..0d6c24d --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMemberVo.java @@ -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; +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMoveRequest.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMoveRequest.java new file mode 100644 index 0000000..f713b2f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentMoveRequest.java @@ -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 部门移动请求:只允许移动到本校其他分类;目标分类跨校直接拒绝。 + */ +@Data +public class CustomDepartmentMoveRequest { + + /** 学校组 id(可空:单校管理员由服务端取本校)。 */ + private Integer schoolId; + + /** 目标分类 id。 */ + @NotNull(message = "目标分类不能为空") + private Integer categoryId; +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentRequest.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentRequest.java new file mode 100644 index 0000000..d4d9f6f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentSortRequest.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentSortRequest.java new file mode 100644 index 0000000..ccb9512 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentSortRequest.java @@ -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 ids; +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentVo.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentVo.java new file mode 100644 index 0000000..18b1e68 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDepartmentVo.java @@ -0,0 +1,31 @@ +package com.yunhai.userbase.model.customdept; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/10/21 + * @description 自定义部门列表项(管理端卡片;停用可见、软删默认不出现)。
+ * {@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; +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategoryRequest.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategoryRequest.java new file mode 100644 index 0000000..932f203 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategoryRequest.java @@ -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 自定义分类创建/改名请求。
+ * schoolId 只作与服务端身份范围的一致性核对,不作为授权依据(授权见 + * {@code AccessScopeService#resolveWritableSchoolId})。 + */ +@Data +public class CustomDeptCategoryRequest { + + /** 学校组 id(可空:单校管理员由服务端取本校)。 */ + private Integer schoolId; + + @NotBlank(message = "分类名称不能为空") + @Size(max = 64, message = "分类名称不能超过 64 个字符") + private String name; +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategorySortRequest.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategorySortRequest.java new file mode 100644 index 0000000..455c1d0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategorySortRequest.java @@ -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 ids; +} diff --git a/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategoryVo.java b/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategoryVo.java new file mode 100644 index 0000000..b7b059d --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/customdept/CustomDeptCategoryVo.java @@ -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; + + /** 该分类下有效部门数:前端据此对非空分类禁用「删除」。 */ + private Long departmentCount; +} diff --git a/src/main/java/com/yunhai/userbase/model/dept/DepartmentEntryVo.java b/src/main/java/com/yunhai/userbase/model/dept/DepartmentEntryVo.java new file mode 100644 index 0000000..fd66b79 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/dept/DepartmentEntryVo.java @@ -0,0 +1,44 @@ +package com.yunhai.userbase.model.dept; + +import lombok.Data; + +/** + * @author chenx + * @date 2026/10/21 + * @description 通用部门目录条目(规格书 §4.2 统一响应条目,SYSTEM/CUSTOM 共用同一形状)。
+ * 引用必须三件套齐全:{@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; + + /** 是否可用于新的选择/绑定:停用或已软删 → false(A10/A12)。 */ + private Boolean selectable; + + /** SYSTEM=该组直属用户数;CUSTOM=有效成员数。 */ + private Long memberCount; + + /** 上级部门 id(仅 SYSTEM;withChildren=true 时用于重建层级,CUSTOM 为 null)。 */ + private Integer parentId; +} diff --git a/src/main/java/com/yunhai/userbase/model/dept/DepartmentRefRequest.java b/src/main/java/com/yunhai/userbase/model/dept/DepartmentRefRequest.java new file mode 100644 index 0000000..0551281 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/dept/DepartmentRefRequest.java @@ -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} 的入参项)。
+ * 业务侧存的就是这三件套;这里不按名称回填,名称只作为解析结果返回。 + */ +@Data +public class DepartmentRefRequest { + + @NotNull(message = "schoolId 不能为空") + private Integer schoolId; + + /** SYSTEM / CUSTOM(大小写不敏感)。 */ + @NotBlank(message = "departmentType 不能为空") + private String departmentType; + + @NotNull(message = "departmentId 不能为空") + private Integer departmentId; +} diff --git a/src/main/java/com/yunhai/userbase/repository/UbCustomDepartmentMemberRepository.java b/src/main/java/com/yunhai/userbase/repository/UbCustomDepartmentMemberRepository.java new file mode 100644 index 0000000..9b3f217 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/UbCustomDepartmentMemberRepository.java @@ -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, JpaSpecificationExecutor { + + List findByDepartmentId(Integer departmentId); + + /** 批量取多部门成员(列表页成员数统计用,调用方自行分组计数)。 */ + List findByDepartmentIdIn(List departmentIds); + + /** 某用户在 CUSTOM 侧的全部归属(跨部门合法性判定用)。 */ + List 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 userIds); + + long countByDepartmentId(Integer departmentId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/UbCustomDepartmentRepository.java b/src/main/java/com/yunhai/userbase/repository/UbCustomDepartmentRepository.java new file mode 100644 index 0000000..7e98453 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/UbCustomDepartmentRepository.java @@ -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 自定义部门仓储。
+ * 与 {@code SysGroupRepository#updateStatus} 同范式:启停走单列 {@code @Modifying} JPQL, + * 绕过实体监听器与审计列(这是本仓既定的单列翻转特例);常规增删改仍走实体保存。 + */ +public interface UbCustomDepartmentRepository + extends JpaRepository, JpaSpecificationExecutor { + + /** + * 启用/停用单列 UPDATE:严格只动 enabled 一列。
+ * ⚠ 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 findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(Integer categoryId, Integer isDeleted); + + /** 某校全部有效部门(T2 目录/分类计数用)。 */ + List findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(Integer schoolId, Integer isDeleted); + + /** 同校同分类下有效同名部门(重名前置校验)。 */ + Optional 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); +} diff --git a/src/main/java/com/yunhai/userbase/repository/UbCustomDeptCategoryRepository.java b/src/main/java/com/yunhai/userbase/repository/UbCustomDeptCategoryRepository.java new file mode 100644 index 0000000..b99d9e2 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/UbCustomDeptCategoryRepository.java @@ -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 自定义部门分类仓储。
+ * 查询一律带 {@code isDeleted} 维度:软删行默认不出现,历史解析(T2)单独按 id 取。 + */ +public interface UbCustomDeptCategoryRepository + extends JpaRepository, JpaSpecificationExecutor { + + /** 分类列表:按学校 + 排序(sort_no 相同时按 id 稳定兜底)。 */ + List findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(Integer schoolId, Integer isDeleted); + + /** 同校有效分类是否同名(uk 的服务端前置校验,给友好报错)。 */ + Optional 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); +} diff --git a/src/main/java/com/yunhai/userbase/service/ICustomDepartmentService.java b/src/main/java/com/yunhai/userbase/service/ICustomDepartmentService.java new file mode 100644 index 0000000..85a54f1 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/ICustomDepartmentService.java @@ -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)。
+ * 部门只有「启用/停用」两态;删除是软删(不是第三种状态);成员引用 sys_user.id,不复制账号。 + */ +public interface ICustomDepartmentService { + + /** 某分类下的有效部门列表(含成员数;停用可见、软删不出现)。 */ + List 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 members(Integer id); + + /** + * 可添加的候选人员:本校教职工(D4,排除学生组)且尚未在该部门内,按账号/姓名模糊过滤。
+ * 现有 {@code /api/groupauthz/GroupUsers} 是「组维度」口径、会带出学生组用户,不能直接当候选源, + * 故候选必须走这条与写口同一判定的接口。 + * + * @param keyword 账号/姓名关键字,空表示不过滤 + */ + List candidateUsers(Integer id, String keyword); + + /** 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,已存在的按幂等跳过。 */ + void addMembers(Integer id, CustomDepartmentMemberRequest request); + + /** 移除成员:物理删关系行(不残留有效成员)。 */ + void removeMembers(Integer id, CustomDepartmentMemberRequest request); +} diff --git a/src/main/java/com/yunhai/userbase/service/ICustomDeptCategoryService.java b/src/main/java/com/yunhai/userbase/service/ICustomDeptCategoryService.java new file mode 100644 index 0000000..6caee11 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/ICustomDeptCategoryService.java @@ -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)。
+ * 分类是纯容器:无启停、无成员;删除只有软删,且禁止任何级联。 + */ +public interface ICustomDeptCategoryService { + + /** 本校有效分类列表(按 sort_no、id 升序;软删分类不出现)。 */ + List list(Integer schoolId); + + /** 新建分类:同校有效分类重名直接拒绝。 */ + CustomDeptCategoryVo add(CustomDeptCategoryRequest request); + + /** 分类改名:同校其他有效分类重名拒绝;name 改写只发生在有效行,软删行的历史名称永不覆盖。 */ + CustomDeptCategoryVo rename(Integer id, CustomDeptCategoryRequest request); + + /** 分类排序:ids 必须全部是本校有效分类。 */ + void sort(CustomDeptCategorySortRequest request); + + /** + * 软删分类:仍存在有效子部门时明确拒绝(A14),不做任何级联删除。
+ * 软删写 is_deleted/del_marker/deleted_at/deleted_by,保留原 id 且不复用。 + */ + void softDelete(Integer id); +} diff --git a/src/main/java/com/yunhai/userbase/service/IDepartmentDirectoryService.java b/src/main/java/com/yunhai/userbase/service/IDepartmentDirectoryService.java new file mode 100644 index 0000000..3245b58 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/IDepartmentDirectoryService.java @@ -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} 读取)。
+ * 与后台 {@code ICustomDepartmentService} 的关键差别:本服务不感知登录态 + * ({@code AccessScopeService} 只服务管理端),授权单位是「调用方应用 × 学校」: + * 应用必须在目标学校已开通({@code sys_group_app_rel}),仅验签不等于可读任意学校(A15)。
+ * 只读;不写任何数据;本期直查不加缓存。 + */ +public interface IDepartmentDirectoryService { + + /** + * 可选部门目录:默认只返回启用且未软删的条目。
+ * type=null 时 SYSTEM + CUSTOM 一并返回;categoryId 只对 CUSTOM 生效; + * withChildren 只对 SYSTEM 生效(true=含部门下级,层级靠 parentId 还原)。 + */ + List list(Integer appId, Integer schoolId, DepartmentTypeEnum type, + Integer categoryId, boolean withChildren); + + /** CUSTOM 分类目录(按学校授权;不混入 SYSTEM 组织树)。 */ + List categories(Integer appId, Integer schoolId); + + /** + * 批量历史解析:唯一允许返回停用/软删部门的读口(A12)。
+ * 解析不到(对象不存在,或 SYSTEM 组已被组织模块删除)时按「不可选」返回:name=null、deleted=true。 + */ + List resolveBatch(Integer appId, List refs); + + /** + * 部门成员解析。{@code forNewAssignment=true}(供新指派)时要求部门启用且未软删,否则拒绝; + * {@code forNewAssignment=false}(已有实例解析)时停用/软删照常返回成员,不改变在途语义(A17)。 + */ + List members(Integer appId, DepartmentTypeEnum type, Integer departmentId, boolean forNewAssignment); +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/DepartmentDirectoryService.java b/src/main/java/com/yunhai/userbase/service/impl/DepartmentDirectoryService.java new file mode 100644 index 0000000..a5c3d4f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/DepartmentDirectoryService.java @@ -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)。
+ * 授权链路照 {@code /oapi/group} 现行做法:签名由过滤器强制,本层只做「应用 × 学校」可见性—— + * 权威事实是 {@code sys_group_app_rel}(该应用在该学校已开通),与 + * {@code SchoolAppOverrideService#editAppForSchool} 同一口径,不新建 ACL 表。
+ * 直查不加缓存(任务书 §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 list(Integer appId, Integer schoolId, DepartmentTypeEnum type, + Integer categoryId, boolean withChildren) { + assertAppCanReadSchool(appId, schoolId); + List 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 categories(Integer appId, Integer schoolId) { + assertAppCanReadSchool(appId, schoolId); + List 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 resolveBatch(Integer appId, List refs) { + if (refs == null || refs.isEmpty()) { + return Collections.emptyList(); + } + // 逐校校验授权:一批里出现未开通的学校 → 整批拒绝(不做「能解析多少给多少」) + Set 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 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 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 customMembers(Integer departmentId) { + List rows = memberRepository.findByDepartmentId(departmentId); + if (rows.isEmpty()) { + return Collections.emptyList(); + } + List userIds = new ArrayList<>(); + for (UbCustomDepartmentMember row : rows) { + userIds.add(row.getUserId()); + } + return toBoaUsers(userIds); + } + + private List systemMembers(Integer groupId) { + List rels = sysUserGroupRelRepository.findByGroupId(groupId); + if (rels.isEmpty()) { + return Collections.emptyList(); + } + Set 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 末条)。
+ * 不禁用账号:现行组用户接口就是「照发 + status 标 0」由消费方判({@code toLightBoaUser}), + * 这里若私自过滤,历史解析会静默少人。「人已离校」的处置策略归 flow-center 现行规则(§8.7),UserBase 不猜。
+ * 只有关系行指向的物理不存在账号(孤儿行)跳过。 + */ + private List toBoaUsers(List userIds) { + Map byId = new HashMap<>(); + for (SysUser user : sysUserRepository.findAllById(userIds)) { + byId.put(user.getId(), user); + } + List 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 部门口径与部门管理页一致:教师组(type=7)的直接子节点; + * 学生组(type=8)下的节点不算部门。
+ * withChildren=true 时连同部门下级一起给出(扁平 + parentId,由消费方重建层级)。
+ * 停用的部门只在历史解析里出现(本方法由 {@code onlySelectable=true} 调用时会整枝剪掉)。 + */ + private List systemDepartments(Integer schoolId, boolean withChildren, boolean onlySelectable) { + List all = sysGroupRepository.findAll(); + Map> childrenMap = new HashMap<>(); + for (SysGroup group : all) { + Integer parent = group.getParentId() == null ? 0 : group.getParentId(); + childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(group); + } + List result = new ArrayList<>(); + for (SysGroup child : childrenMap.getOrDefault(schoolId, Collections.emptyList())) { + if (child.getType() != GroupTypeEnum.TEACHER_GROUP) { + continue; + } + for (SysGroup department : childrenMap.getOrDefault(child.getId(), Collections.emptyList())) { + boolean appended = appendSystemEntry(result, schoolId, department, onlySelectable); + if (withChildren && appended) { + appendSystemDescendants(result, schoolId, department.getId(), childrenMap, onlySelectable); + } + } + } + return result; + } + + private void appendSystemDescendants(List result, Integer schoolId, Integer parentId, + Map> childrenMap, boolean onlySelectable) { + Deque frontier = new ArrayDeque<>(); + frontier.add(parentId); + Set visited = new HashSet<>(); + while (!frontier.isEmpty()) { + Integer cursor = frontier.poll(); + for (SysGroup child : childrenMap.getOrDefault(cursor, Collections.emptyList())) { + if (!visited.add(child.getId())) { + continue; + } + appendSystemEntry(result, schoolId, child, onlySelectable); + frontier.add(child.getId()); + } + } + } + + /** + * 追加一个 SYSTEM 节点为目录条目。
+ * 学生组本身永不作为部门;停用节点在 {@code onlySelectable} 下直接跳过。
+ * 下级节点的组类型(普通组/年级组…)就是组树语义,不做二次筛选,照 parentId 原样下发。 + * + * @return 该节点是否进入结果(false 表示被过滤,其下级不再展开) + */ + private boolean appendSystemEntry(List 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 customDepartments(Integer schoolId, Integer categoryId, boolean onlySelectable) { + Map 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 result = new ArrayList<>(); + for (Integer liveCategoryId : categoryNames.keySet()) { + List rows = + departmentRepository.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(liveCategoryId, NOT_DELETED); + Map 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 countMembers(List rows) { + List ids = new ArrayList<>(); + for (UbCustomDepartment row : rows) { + ids.add(row.getId()); + } + Map 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 byId = new HashMap<>(); + for (SysGroup each : sysGroupRepository.findAll()) { + byId.put(each.getId(), each); + } + Set 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 51a1519..6c06808 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -251,15 +251,21 @@ public class SysGroupService implements ISysGroupService { /** * 「包含子组」翻转(2026-10-21 部门线):严格单列 UPDATE,范式与 {@link #changeStatus} 完全一致。
+ * 与 changeStatus 的唯一差别:本属性只在**学校组**节点上有意义(对齐 SysGroup 字段注释「仅学校节点使用」), + * 故在此强制校验类型,非学校组直接拒绝——前端只在学校行放开关,服务端不能只靠它。
* 必须发 {@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("系统组不存在"); diff --git a/src/main/java/com/yunhai/userbase/service/impl/UbCustomDepartmentService.java b/src/main/java/com/yunhai/userbase/service/impl/UbCustomDepartmentService.java new file mode 100644 index 0000000..9017ad0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/UbCustomDepartmentService.java @@ -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 侧权威写口)。
+ * 权限:schoolId 全部由 {@link AccessScopeService} 从登录身份解析,请求体只作核对; + * 每个写口先定位对象、再校验其所属学校落在当前范围内(跨校一律拒绝,A09)。
+ * 成员范围(D4):本校 + 教职工,排除学生组(type=8)子树下的用户。
+ * 本期 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 list(Integer schoolId, Integer categoryId) { + Integer scoped = accessScopeService.resolveWritableSchoolId(schoolId); + UbCustomDeptCategory category = requireLiveCategory(categoryId); + if (!scoped.equals(category.getSchoolId())) { + throw new DException("分类不属于该学校范围"); + } + List rows = departmentRepository + .findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(category.getId(), NOT_DELETED); + if (rows.isEmpty()) { + return Collections.emptyList(); + } + Map memberCounts = countMembersByDepartment(rows); + List 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 ids = request.getIds(); + if (ids == null || ids.isEmpty()) { + throw new DException("部门ID列表不能为空"); + } + if (new HashSet<>(ids).size() != ids.size()) { + throw new DException("部门ID列表存在重复"); + } + List rows = new ArrayList<>(); + Map 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 无缓存)。
+ * ⚠ 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 members(Integer id) { + UbCustomDepartment department = requireEditableDepartment(id); + List rows = memberRepository.findByDepartmentId(department.getId()); + if (rows.isEmpty()) { + return Collections.emptyList(); + } + List userIds = new ArrayList<>(); + for (UbCustomDepartmentMember row : rows) { + userIds.add(row.getUserId()); + } + Map userMap = new HashMap<>(); + for (SysUser user : sysUserRepository.findAllById(userIds)) { + userMap.put(user.getId(), user); + } + List 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 distinct = distinctUserIds(request.getUserIds()); + Set teacherUserIds = schoolTeacherUserIds(department.getSchoolId()); + Set existing = existingMemberUserIds(department.getId()); + List 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 distinct = distinctUserIds(request.getUserIds()); + memberRepository.deleteByDepartmentIdAndUserIds(department.getId(), distinct); + } + + @Override + public List candidateUsers(Integer id, String keyword) { + UbCustomDepartment department = requireEditableDepartment(id); + Set teacherUserIds = schoolTeacherUserIds(department.getSchoolId()); + Set existing = existingMemberUserIds(department.getId()); + List candidateIds = new ArrayList<>(teacherUserIds); + candidateIds.removeAll(existing); + if (candidateIds.isEmpty()) { + return Collections.emptyList(); + } + String kw = keyword == null ? "" : keyword.trim().toLowerCase(); + List 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)); + } + + // ===================== 校验与私有工具 ===================== + + /** 定位可操作的有效部门:软删行不参与常规写口(历史解析走 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 distinctUserIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + throw new DException("成员列表不能为空"); + } + Set distinct = new LinkedHashSet<>(userIds); + distinct.remove(null); + if (distinct.isEmpty()) { + throw new DException("成员列表不能为空"); + } + return new ArrayList<>(distinct); + } + + private Set existingMemberUserIds(Integer departmentId) { + Set existing = new HashSet<>(); + for (UbCustomDepartmentMember row : memberRepository.findByDepartmentId(departmentId)) { + existing.add(row.getUserId()); + } + return existing; + } + + /** + * 本校教职工用户集合(D4):落在学校子树内、且不属于该校学生组(type=8)子树的正常账号。
+ * 判定只依据 {@code sys_user_group_rel} + {@code sys_group.type},与「按组查用户」既有范式一致 + * ({@code findByGroupIdIn} 后按 userId 去重)。 + */ + private Set schoolTeacherUserIds(Integer schoolId) { + List schoolGroupIds = sysGroupService.listSelfAndDescendantIds(schoolId); + if (schoolGroupIds.isEmpty()) { + return Collections.emptySet(); + } + Set studentGroupIds = new LinkedHashSet<>(); + for (SysGroup group : sysGroupRepository.findAllById(schoolGroupIds)) { + if (group.getType() == GroupTypeEnum.STUDENT_GROUP) { + studentGroupIds.addAll(sysGroupService.listSelfAndDescendantIds(group.getId())); + } + } + Set excluded = new HashSet<>(); + if (!studentGroupIds.isEmpty()) { + for (SysUserGroupRel rel : sysUserGroupRelRepository.findByGroupIdIn(new ArrayList<>(studentGroupIds))) { + excluded.add(rel.getUserId()); + } + } + Set candidates = new HashSet<>(); + for (SysUserGroupRel rel : sysUserGroupRelRepository.findByGroupIdIn(schoolGroupIds)) { + if (!excluded.contains(rel.getUserId())) { + candidates.add(rel.getUserId()); + } + } + // 有效账号:禁用用户不能作为新成员(LinkedHashSet 保证顺序稳定,便于逐 id 报错定位) + Set 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 countMembersByDepartment(List rows) { + List ids = new ArrayList<>(); + for (UbCustomDepartment row : rows) { + ids.add(row.getId()); + } + Map 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryService.java b/src/main/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryService.java new file mode 100644 index 0000000..03ebe65 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryService.java @@ -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 自定义部门分类管理实现。
+ * 三条铁律:① schoolId 一律由服务端从当前身份范围解析,请求体只作核对(T1.3); + * ② 软删用 is_deleted + del_marker(软删时置为行 id),name 永不改写; + * ③ 本期 CUSTOM 目录不加缓存(任务书 §0.2),故写口不发缓存驱逐事件——若后续接入缓存, + * 必须注册 CacheNames 并保证全部写口发事件驱逐。 + */ +@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 list(Integer schoolId) { + Integer scoped = accessScopeService.resolveWritableSchoolId(schoolId); + List 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 ids = request.getIds(); + if (ids == null || ids.isEmpty()) { + throw new DException("分类ID列表不能为空"); + } + if (new HashSet<>(ids).size() != ids.size()) { + throw new DException("分类ID列表存在重复"); + } + List categories = requireSchoolCategories(schoolId); + Set 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 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); + } + + // ===================== 私有 ===================== + + /** 定位可操作的有效分类并做校维度授权(跨校直接拒绝,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 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java b/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java index 11c6524..432ef4a 100644 --- a/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java +++ b/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java @@ -363,6 +363,55 @@ public class AccessScopeService { } } + /** + * 学校维度 guard(2026-10-21 CUSTOM 部门线):schoolId 必须存在、类型为学校组,且落在当前用户数据范围内。
+ * 现有 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 取自当前身份范围,请求体只作核对不作授权」的唯一入口。
+ * 分级语义: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 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 不限制)。
* 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。 diff --git a/src/test/java/com/yunhai/userbase/service/impl/DepartmentDirectoryServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/DepartmentDirectoryServiceTest.java new file mode 100644 index 0000000..abeda3f --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/DepartmentDirectoryServiceTest.java @@ -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 通用部门目录契约)单元测试。
+ * 覆盖验收映射:A15(仅验签≠可读任意学校)、A10/A11 解析侧(停用条目不进气选目录、历史解析仍可见)、 + * A12(软删部门只经 resolve-batch 返回)、A06/A19(同名不靠名称回填、别校引用解析不到)、 + * 以及 SYSTEM 部门口径=教师组直接子节点(含 withChildren 与下级 parentId)。 + * + *

夹具组树:5=云海小学 / 6=云海中学;5 下 7=教师组、8=学生组; + * 教师组下 20=校长室(启用)、22=教务处(停用);20 下 21=校办; + * 6 下 9=教师组、40=别校校长室。CUSTOM:分类 10 下 100 启用 / 101 停用 / 102 已软删。

+ */ +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 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 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 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 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 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 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 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 refs = Arrays.asList(ref(SCHOOL_A, "SYSTEM", 20), ref(SCHOOL_B, "SYSTEM", 40)); + + assertThrows(DException.class, () -> directoryService.resolveBatch(APP, refs)); + } + + @Test + void resolveBatchRejectsUnknownDepartmentType() { + List 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.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 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 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 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 ids(List entries) { + List 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; + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIncludeChildrenTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIncludeChildrenTest.java new file mode 100644 index 0000000..d8caea1 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIncludeChildrenTest.java @@ -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 部门线)单元测试。
+ * 覆盖:① 学校组上置位/复位只走 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 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)); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/UbCustomDepartmentServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/UbCustomDepartmentServiceTest.java new file mode 100644 index 0000000..4d408a7 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/UbCustomDepartmentServiceTest.java @@ -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 部门与成员写口)单元测试。
+ * 覆盖验收映射: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 必须按实际入参返回(桩若无视参数,学生 30 会被当成教职工, + * 「禁用账号不能加入」也会假通过)。 + */ + private void stubUserRepository(SysUser... users) { + final Map byId = new HashMap<>(); + for (SysUser user : users) { + byId.put(user.getId(), user); + } + when(sysUserRepository.findAllById(anyList())).thenAnswer(invocation -> { + Iterable ids = invocation.getArgument(0); + List hits = new ArrayList<>(); + for (Integer id : ids) { + if (byId.containsKey(id)) { + hits.add(byId.get(id)); + } + } + return hits; + }); + } + + /** + * 学校子树 5:教师组 7 / 学生组 8(下挂年级组 21)。
+ * 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 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 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 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),同名新建放行且一定是新 id + * (自增主键,实体保存前 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 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> 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 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 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)); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryServiceTest.java new file mode 100644 index 0000000..f0fd59c --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryServiceTest.java @@ -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 分类写口)单元测试。
+ * 覆盖验收映射: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 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 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()); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceSchoolGuardTest.java b/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceSchoolGuardTest.java new file mode 100644 index 0000000..b99b45c --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceSchoolGuardTest.java @@ -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 部门线新增)。
+ * 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.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)); + } +}