Compare commits

...
11 Commits
Author SHA1 Message Date
chenxing 809d88c838 feat(dept): SysGroupVo 补 memberCount/memberNames,getTree 回填部门成员数与姓名预览
build / build-push (push) Successful in 2m23s
- SysGroupVo 新增 memberCount(直属成员数)与 memberNames(前 50 个姓名预览)
- SysGroupService 新增 fillMemberStats:整棵树只发两次查询(关联表 in + 用户表 in),
  getTree()/getTree(scope) 均回填,口径同 oapi 部门目录(sys_user_group_rel 按 groupId 计数,不排除转出)
- 预览上限 MEMBER_PREVIEW_LIMIT=50,避免大部门把名单塞进 getTree;完整名单走 /api/sysuser/List?groupId=
2026-10-23 10:30:52 +08:00
chenxing 72fe0f218a feat(app): application_info app_code 写入口守卫(已登记拒改名/拒清空,初次登记唯一性保持)
build / build-push (push) Successful in 1m26s
2026-10-22 17:14:27 +08:00
chenx b1b769a1b8 feat(dept): CUSTOM 自定义部门与统一部门目录契约(双来源部门二期)
build / build-push (push) Successful in 59s
- 三表 ub_custom_dept_category/ub_custom_department/ub_custom_department_member(DDL 入 service/sql,160 待人工执行)
- 实体/仓库/服务:学校隔离(AccessScopeService 新增 requireSchoolInScope/resolveWritableSchoolId)、
  del_marker=id 软删让位 uk、成员=本校教职工黑名单口径(排除学生组 type=8 与禁用账号)
- 管理端 /api/deptcategory、/api/customdept 共 17 端点(含 CandidateUsers 候选口)
- 开放端 /oapi/departments 四口:目录/分类/resolve-batch/成员(scope 缺省=select 严档),
  应用×学校授权走 sys_group_app_rel(仅验签不放行跨校)
- SysGroupService.changeIncludeChildren 补学校组类型校验
- 新增单测 83 例全绿(部门26/分类12/目录25/schoolguard12/includeChildren8)
2026-10-22 09:35:37 +08:00
chenxing bf3f3344c1 feat(dept): sys_group 增列 include_children,支持「包含子组」落库
build / build-push (push) Successful in 1m12s
- 新增 sys_group.include_children(1=部门列表含下级部门 / 0=仅本级,默认 1),仅学校节点使用
- 实体 / Vo / Repository / Service / Controller 全链路打通:单列 UPDATE + 发布 GroupChangedEvent 驱逐 groupSnapshot 缓存
- 新端点 POST /api/sysgroup/SetIncludeChildren/{id},权限只用 requireGroupInScope
  (不能用 requireGroupModifiable:后者禁止 SCHOOL 级操作学校组本身,而本属性正是学校管理员要改的本校属性)
- deploy/sql/20261021_add_sys_group_include_children.sql 提供幂等 DDL,需在 160 手工执行
2026-10-21 15:12:31 +08:00
ops 996eed3caf fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:25:25 +00:00
ops 8e7b69c795 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:29 +00:00
ops 7247c1193a ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:01 +00:00
chenxing ddaa3a6f42 feat(oapi): 新增移动端渠道绑定开放接口
build / build-push (push) Successful in 59s
- GET /oapi/channel/user 按 (channel, openid) 查已绑定用户,未绑定返回 null
- POST /oapi/channel/bind 凭证绑定:账号密码本服务校验 + 三层查重(openid/账号/unionid)
- POST /oapi/channel/bindByPhone 手机号证明绑定(归属由渠道验签器证明)
- POST /oapi/channel/unbind 按登录账号 + 渠道删绑定行
- 抢绑防护:openid/unionId 仅由服务端从一次性 loginTicket 获取,前端不接触
- 新增 sys_user_channel_rel 实体/仓储/服务,写操作经 @OapiLog 记审计
- 补 ChannelBindServiceTest
2026-10-21 08:52:52 +08:00
chenxing cb40524b8e feat(oapi): user/modules 新增可选 appCode,支持查询指定应用的模块树
- 传 appCode => 按 application_info.app_code 解析目标应用(供托管型应用代渲染导航)
- 未传 => 保持原行为,按验签 appKey 解析调用方应用
- 应用解析失败一律返回空列表(安全优先,禁止意外泄露其他应用模块)
- 参数用 appCode 而非整数 appId:跨环境稳定,与 form-center 的 /app/{appCode}/ 同键
2026-10-21 08:52:42 +08:00
chenxing 195936236d feat(module): 子模块支持按钮权限型,custom_id 放开为可空
- AppModule.customId 去掉 @NotBlank / nullable=false;AppModuleRequest 同步放开必填校验
- AppModuleService 空白归一为 null,仅在有值时做应用内唯一校验;path 同样归一 + trim
- 必须存 NULL 而非 '':'' 在 uk_app_module_custom(app_id, custom_id) 下每应用只能有一条
- 新增幂等 DDL sql/app_module_custom_id_nullable.sql(ddl-auto=update 不会改列属性)
- 补 AppModuleServiceButtonModuleTest
2026-10-21 08:52:34 +08:00
chenxing ad434dbc36 refactor(path): API 前缀收敛为 /api,退役 /{platform} 通配
- Config.API_PATH 由 "/{platform}" 改为 "/api",另留 MOBI_API_PATH 常量做对齐
- ManageAccessInterceptor 白名单改显式 /api 前缀,路径从通配变闭集
- DWebMvcConfig 拦截器注册由 /{platform}/** 改 /api/**,堵掉「任意前缀绕过白名单」的洞
2026-10-21 08:52:26 +08:00
64 changed files with 5309 additions and 36 deletions
+13 -1
View File
@@ -30,7 +30,7 @@ jobs:
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
chosen=""
for host in dl-cdn.alpinelinux.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.huaweicloud.com; do
for host in mirrors.aliyun.com mirrors.huaweicloud.com mirrors.cloud.tencent.com dl-cdn.alpinelinux.org; do
if wget -q -T 6 -O /dev/null "https://${host}/alpine/v3.20/main/x86_64/APKINDEX.tar.gz"; then
chosen="${host}"
break
@@ -71,6 +71,18 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: |
set -e
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f mavenbuild-${MB} 2>/dev/null || true
docker run -d --name mavenbuild-${MB} \
--network infra-infra-net \
@@ -0,0 +1,38 @@
-- =====================================================================
-- 部门管理 ·「包含子组」落库(方案:部门管理-包含子组落库方案.md)
-- sys_group 增列 include_children:学校节点上的一个开关,
-- 1 = 部门列表连同下级部门一起列出(默认)/ 0 = 只列一级部门
-- 交付文件,不在本机执行;由人工在 160 执行
-- 执行前请备份;JPA 实体已同名映射,执行后重启后端即可生效
-- =====================================================================
-- ---------------------------------------------------------------------------
-- 1. 增列 include_children
-- 粒度:1 学校 : 1 值,与 status 同为节点自身属性,故直接挂在 sys_group,不另建表
-- 默认 1:与升级前的页面表现一致,存量学校无需回填
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动加列,但**不带** NOT NULL/DEFAULT/COMMENT,
-- 存量会留 NULL。因此本脚本建议先执行,保证列定义可控;即使漏执行,前端已按 null = 含子组 兜底
-- ---------------------------------------------------------------------------
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sys_group'
AND COLUMN_NAME = 'include_children');
SET @sql := IF(@col = 0,
'ALTER TABLE sys_group ADD COLUMN include_children TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''包含子组:1=部门列表含下级部门,0=仅本级'' AFTER status',
'SELECT ''include_children already exists'' AS msg');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------------
-- 2. 核验(可选)
-- ---------------------------------------------------------------------------
-- SHOW COLUMNS FROM sys_group LIKE 'include_children';
-- SELECT COUNT(*) AS total, SUM(include_children = 1) AS include_on FROM sys_group;
-- ---------------------------------------------------------------------------
-- 回滚(仅当确认不再需要该能力时执行;只加列不改列,代码回滚无需删列)
-- ---------------------------------------------------------------------------
-- ALTER TABLE sys_group DROP COLUMN include_children;
@@ -0,0 +1,102 @@
-- =====================================================================
-- UserBase 双来源部门 · CUSTOM 侧建表(任务书 USERBASE_DEPARTMENT_QODER_TASK.md T1 / 规格书 UB-1)
-- 三张新表:ub_custom_dept_category(自定义分类)/ ub_custom_department(自定义部门)
-- / ub_custom_department_member(部门成员关系)
-- 交付文件,不在本机执行;由人工在 160 执行
-- 执行前请备份;JPA 实体与列同名映射,执行后重启后端即可生效
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动建表,但**不带** NOT NULL/DEFAULT/COMMENT/唯一键,
-- 且 del_marker 之类参与唯一约束的列必须由本脚本定义,故务必先执行本脚本
-- 口径:school_id = sys_group 中 type=2(学校组)节点的 id,与 AccessScopeService.scopeGroupIds() 同域
-- =====================================================================
-- ---------------------------------------------------------------------------
-- 1. 自定义分类(学校的容器层,本身无启停、无成员)
-- 名称唯一:del_marker 技巧——MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住「软删后再激活同名」,
-- 故软删时把 del_marker 置为该行 id,未删行恒为 0,同校同名只能有一条**有效**行;
-- name 列永不改写(软删保留原名,保证历史解析拿到原名、旧引用不指向新对象)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_dept_category (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(不可变稳定 ID)',
school_id INT NOT NULL COMMENT '学校组 id(sys_group.type=2 的节点 id)',
name VARCHAR(64) NOT NULL COMMENT '分类名称,学校自定;永不改写',
sort_no INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(不是业务状态)',
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键,保证同名只挡有效行',
deleted_at DATETIME NULL COMMENT '软删时间',
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_school_name_marker (school_id, name, del_marker),
KEY idx_school_sort (school_id, is_deleted, sort_no)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门分类(学校级容器,非可选部门)';
-- ---------------------------------------------------------------------------
-- 2. 自定义部门(可配成员、可启停的实际可选对象)
-- 唯一键=同校同分类下未软删的部门名不重复;跨分类、SYSTEM 与 CUSTOM 之间允许重名(A06)
-- enabled 语义直读 1=启用/0=停用,**与 sys_group.status(0=启用/1=停用)方向相反,严禁混用**
-- 软删不级联删成员:成员关系保留在库,由查询侧按部门 is_deleted 过滤(A12)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_department (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(CUSTOM 侧独立 ID,与 SYSTEM 组 id 不同域,引用须带 departmentType)',
school_id INT NOT NULL COMMENT '学校组 id(冗余自分类,写口服务端复核)',
category_id INT NOT NULL COMMENT '所属分类 ub_custom_dept_category.id,必须同校',
name VARCHAR(64) NOT NULL COMMENT '部门名称,学校自定;永不改写',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同分类内展示顺序,越小越靠前',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启停:1=启用 / 0=停用(新建默认启用)',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(删除不是第三种业务状态)',
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键',
deleted_at DATETIME NULL COMMENT '软删时间',
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_school_category_name_marker (school_id, category_id, name, del_marker),
KEY idx_category_list (category_id, is_deleted, sort_no),
KEY idx_school_list (school_id, is_deleted, enabled)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学校自定义部门(与 SYSTEM 组织树完全分离)';
-- ---------------------------------------------------------------------------
-- 3. 自定义部门成员关系(引用 sys_user.id,不复制账号)
-- 移除成员=物理删该关系行(本仓无软删惯例,增删审计靠 @OperationLog),不得残留有效成员
-- uk 挡不住并发重复插入之外的场景(如软删部门后重新激活)由服务端逐个复核兜底
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_department_member (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
school_id INT NOT NULL COMMENT '学校组 id(与部门同校,写口服务端复核)',
department_id INT NOT NULL COMMENT 'ub_custom_department.id',
user_id INT NOT NULL COMMENT 'sys_user.id(本校教职工,排除学生组成员)',
create_user INT NOT NULL COMMENT '添加人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '添加时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_department_user (department_id, user_id),
KEY idx_department (department_id),
KEY idx_school (school_id),
KEY idx_user (user_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门成员关系(一个用户可属同校多个部门)';
-- ---------------------------------------------------------------------------
-- 4. 核验(可选,执行后逐条确认)
-- ---------------------------------------------------------------------------
-- SHOW CREATE TABLE ub_custom_dept_category;
-- SHOW CREATE TABLE ub_custom_department;
-- SHOW CREATE TABLE ub_custom_department_member;
-- SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'ub_custom%';
-- 唯一键生效自检(手工插入应报 1062):
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW());
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW()); -- 期望失败
-- DELETE FROM ub_custom_dept_category WHERE school_id = 1 AND name = '自检分类';
-- ---------------------------------------------------------------------------
-- 回滚(本期未上线,回滚即删表;CUSTOM 数据不与 sys_group 有任何写耦合)
-- ---------------------------------------------------------------------------
-- DROP TABLE IF EXISTS ub_custom_department_member;
-- DROP TABLE IF EXISTS ub_custom_department;
-- DROP TABLE IF EXISTS ub_custom_dept_category;
@@ -19,7 +19,7 @@ import java.util.List;
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br>
* 注册于 /api/**(显式前缀,路径闭集),排除 /oapi/**(服务间签名调用,行为不变)。<br>
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
*
* @author chenx
@@ -29,17 +29,18 @@ import java.util.List;
public class ManageAccessInterceptor implements HandlerInterceptor {
/**
* 白名单(Ant 风格,* 匹配 platform 前缀):
* 白名单(显式 /api 前缀,路径闭集):
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
* appearance 外观偏好读(仅登录即可读自身偏好,平台 yh-appearance-spring-boot-starter 端点);
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
*/
private static final List<String> WHITELIST = Arrays.asList(
"/*/base/GetUserInfo",
"/*/base/GetUserScope",
"/*/base/GetSysDict",
"/*/base/RedirectTo/*",
"/api/base/GetUserInfo",
"/api/base/GetUserScope",
"/api/base/GetSysDict",
"/api/base/RedirectTo/*",
"/api/appearance",
"/*/env-config");
"/api/env-config");
private static final AntPathMatcher MATCHER = new AntPathMatcher();
@@ -13,9 +13,10 @@ public enum Config
Inst;
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
// "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
// "/api//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
// 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi
public static final String API_PATH = "/api"; // PC 管理端(CAS 会话),路径闭集:/manage 等旧前缀随之 404
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 token;UserBase 自身不挂 mobiapi 控制器,仅供常量对齐)
public static final String OPEN_API_PATH = "/oapi/";
public String getString(String key)
@@ -38,11 +38,12 @@ public class DWebMvcConfig implements WebMvcConfigurer
.addPathPatterns("/**")
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。
// /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。
// 注册 /api/**(显式前缀,路径闭集:/manage、/{platform} 通配退役,旧前缀随之 404,
// 同时堵掉"任意前缀绕过白名单"的洞);显式排除 /oapi/**(服务间签名调用不受影响)。
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
// /v3/api-docs、/swagger-ui:接口文档工具链。
registry.addInterceptor(manageAccessInterceptor)
.addPathPatterns("/{platform}/**")
.addPathPatterns("/api/**")
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
"/webjars/**");
@@ -27,7 +27,7 @@ import java.util.TreeSet;
/**
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
*
* {@code GET /{platform}/cache/registry} 输出三块内容:<br>
* {@code GET /api/cache/registry} 输出三块内容:<br>
* <ul>
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
@@ -0,0 +1,74 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.service.ICustomDeptCategoryService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理(CUSTOM 侧,部门管理页左侧平铺列表的数据源)。<br>
* 动词式端点、{@code /api} 前缀与 {@code SysGroupController} 同范式;
* 四级访问范围与跨校拦截在 Service 内统一执行(写口只认登录身份,不认请求体 schoolId)。<br>
* 分类是容器:无启停、无成员;删除只有软删,且非空分类明确拒绝(无级联)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/deptcategory/")
public class CustomDeptCategoryController {
@Autowired
private ICustomDeptCategoryService customDeptCategoryService;
@Operation(operationId = "customDeptCategoryList")
@GetMapping("List")
public List<CustomDeptCategoryVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId) {
return customDeptCategoryService.list(schoolId);
}
@OperationLog("新增自定义部门分类")
@Operation(operationId = "customDeptCategoryAdd")
@PostMapping("Add")
public CustomDeptCategoryVo add(@RequestBody @Validated CustomDeptCategoryRequest request) {
return customDeptCategoryService.add(request);
}
@OperationLog("重命名自定义部门分类")
@Operation(operationId = "customDeptCategoryEdit")
@PostMapping("Edit/{id}")
public CustomDeptCategoryVo edit(@PathVariable Integer id,
@RequestBody @Validated CustomDeptCategoryRequest request) {
return customDeptCategoryService.rename(id, request);
}
@OperationLog("自定义部门分类排序")
@Operation(operationId = "customDeptCategorySort")
@PostMapping("Sort")
public void sort(@RequestBody @Validated CustomDeptCategorySortRequest request) {
customDeptCategoryService.sort(request);
}
/**
* 软删分类:分类下仍有有效部门时明确拒绝(A14),不做任何级联删除。
*/
@OperationLog("删除自定义部门分类")
@Operation(operationId = "customDeptCategoryDelete")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
customDeptCategoryService.softDelete(id);
}
}
@@ -0,0 +1,149 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import com.yunhai.userbase.service.ICustomDepartmentService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理(CUSTOM 侧:部门本体 + 启停 + 软删 + 成员)。<br>
* 与 SYSTEM 组织树完全隔离:本控制器不读写 {@code sys_group} 的任何写路径,
* 也不提供删除 SYSTEM 部门的入口(任务书 D1)。<br>
* 跨校/学生成员等校验全部在 Service 服务端执行,前端传值只作核对。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/customdept/")
public class CustomDeptController {
@Autowired
private ICustomDepartmentService customDepartmentService;
@Operation(operationId = "customDeptList")
@GetMapping("List")
public List<CustomDepartmentVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId,
@RequestParam("categoryId") Integer categoryId) {
return customDepartmentService.list(schoolId, categoryId);
}
@OperationLog("新增自定义部门")
@Operation(operationId = "customDeptAdd")
@PostMapping("Add")
public CustomDepartmentVo add(@RequestBody @Validated CustomDepartmentRequest request) {
return customDepartmentService.add(request);
}
@OperationLog("重命名自定义部门")
@Operation(operationId = "customDeptEdit")
@PostMapping("Edit/{id}")
public CustomDepartmentVo edit(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentRequest request) {
return customDepartmentService.rename(id, request);
}
/**
* 移动至本校其他分类:目标分类跨校直接拒绝。
*/
@OperationLog("移动自定义部门")
@Operation(operationId = "customDeptMove")
@PostMapping("Move/{id}")
public CustomDepartmentVo move(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMoveRequest request) {
return customDepartmentService.move(id, request);
}
@OperationLog("自定义部门排序")
@Operation(operationId = "customDeptSort")
@PostMapping("Sort")
public void sort(@RequestBody @Validated CustomDepartmentSortRequest request) {
customDepartmentService.sort(request);
}
/**
* 停用自定义部门:单列 UPDATE enabled=0。<br>
* 停用只影响「选」(新业务选不到),不影响「看」与「办」(历史/在途仍可解析),与 SYSTEM 同口径。
*/
@OperationLog("停用自定义部门")
@Operation(operationId = "customDeptDisable")
@PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) {
customDepartmentService.changeEnabled(id, false);
}
/**
* 启用自定义部门:单列 UPDATE enabled=1,原 id 与成员保持不变(A11)。
*/
@OperationLog("启用自定义部门")
@Operation(operationId = "customDeptEnable")
@PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) {
customDepartmentService.changeEnabled(id, true);
}
/**
* 软删自定义部门:默认列表不再出现,历史引用仍可解析(A12);成员关系保留在库、由查询侧过滤。
*/
@OperationLog("删除自定义部门")
@Operation(operationId = "customDeptDelete")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
customDepartmentService.softDelete(id);
}
@Operation(operationId = "customDeptMembers")
@GetMapping("Members/{id}")
public List<CustomDepartmentMemberVo> members(@PathVariable Integer id) {
return customDepartmentService.members(id);
}
/**
* 可添加的候选教职工:本校教职工(服务端排除学生组)且尚未在该部门。<br>
* 现有 {@code GroupAuthz.GroupUsers} 是组维度、会带出学生组用户,不能当候选源,故单独成口(D4)。
*/
@Operation(operationId = "customDeptCandidateUsers")
@GetMapping("CandidateUsers/{id}")
public List<CustomDepartmentMemberVo> candidateUsers(@PathVariable Integer id,
@RequestParam(value = "keyword", required = false) String keyword) {
return customDepartmentService.candidateUsers(id, keyword);
}
/**
* 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,重复添加按幂等跳过(A07/A08)。
*/
@OperationLog("添加自定义部门成员")
@Operation(operationId = "customDeptAddMembers")
@PostMapping("AddMembers/{id}")
public void addMembers(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMemberRequest request) {
customDepartmentService.addMembers(id, request);
}
/**
* 移除成员:物理删关系行,不残留有效成员。
*/
@OperationLog("移除自定义部门成员")
@Operation(operationId = "customDeptRemoveMembers")
@PostMapping("RemoveMembers/{id}")
public void removeMembers(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMemberRequest request) {
customDepartmentService.removeMembers(id, request);
}
}
@@ -4,6 +4,7 @@ import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupIncludeChildrenRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
@@ -90,6 +91,21 @@ public class SysGroupController {
sysGroupService.changeStatus(id, false);
}
/**
* 设置「包含子组」(2026-10-21 部门线):该学校在部门管理页是否连同下级部门一起列出。<br>
* 单列 UPDATE sys_group.include_children,不碰其他列与表。<br>
* 权限<b>只用</b> {@code requireGroupInScope}:不能加 {@code requireGroupModifiable}——
* 后者禁止 SCHOOL 级操作学校组本身,而本属性恰恰是学校管理员必须能改的本校属性。
*/
@OperationLog("设置包含子组")
@Operation(operationId = "sysGroupSetIncludeChildren")
@PostMapping("SetIncludeChildren/{id}")
public void setIncludeChildren(@PathVariable Integer id,
@RequestBody @Validated SysGroupIncludeChildrenRequest request) {
accessScopeService.requireGroupInScope(id);
sysGroupService.changeIncludeChildren(id, Boolean.TRUE.equals(request.getIncludeChildren()));
}
@Operation(operationId = "sysGroupList")
@GetMapping("/List")
public List<SysGroupVo> list(SysGroupQuery query) {
@@ -0,0 +1,71 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.annotation.OapiLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.channel.ChannelBindByPhoneRequest;
import com.yunhai.userbase.model.channel.ChannelBindRequest;
import com.yunhai.userbase.model.channel.ChannelUnbindRequest;
import com.yunhai.userbase.service.impl.ChannelBindService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 移动端渠道绑定开放接口(sys_user_channel_rel,移动端接入 步 3,方案 §3.13)。<br>
* <br>
* 供 SDK 登录端点(门户 / 各子应用)经 yh-app-auth 签名调用({@code /oapi/channel/...});
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制。<br>
* 绑定身份以服务端校验为准:openid/unionId 来自一次性 loginTicket,账号密码/手机号在本服务校验,
* 前端永不接触 openid/unionid(抢绑防护,方案 §3.4)。<br>
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
*
* @author chenx
* @date 2026/9/25
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "channel/")
public class ChannelBindController {
@Autowired
private ChannelBindService channelBindService;
/**
* 按 (channel, openid) 查已绑定用户。
*
* @return 已绑定时返回用户明细(含 currentRole);未绑定返回 null(data 为 null,code=0)
*/
@GetMapping("user")
public BoaUser user(@RequestParam("channel") String channel,
@RequestParam("openid") String openid) {
return channelBindService.findBoundUser(channel, openid);
}
/** 凭证绑定:账号密码校验 + 三层查重(openid/账号/unionid)通过后写绑定关系 */
@OapiLog
@PostMapping("bind")
public BoaUser bind(@RequestBody @Validated ChannelBindRequest request) {
return channelBindService.bindByCredential(request.getChannel(), request.getOpenid(),
request.getUnionId(), request.getAccount(), request.getPassword());
}
/** 手机号证明绑定(小程序一键手机号):手机号归属由渠道验签器证明 */
@OapiLog
@PostMapping("bindByPhone")
public BoaUser bindByPhone(@RequestBody @Validated ChannelBindByPhoneRequest request) {
return channelBindService.bindByPhone(request.getChannel(), request.getOpenid(),
request.getUnionId(), request.getPhone());
}
/** 解绑:按登录账号 + 渠道删绑定行(account 来自 token payload,服务端定位) */
@OapiLog
@PostMapping("unbind")
public void unbind(@RequestBody @Validated ChannelUnbindRequest request) {
channelBindService.unbind(request.getChannel(), request.getAccount());
}
}
@@ -0,0 +1,166 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.service.IDepartmentDirectoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 通用部门目录开放接口({@code /oapi/departments/...},规格书 UB-2)。<br>
* <br>
* 鉴权链路照 {@code /oapi/group} 现行做法:验签由 {@code OpenApiAuthEnforceConfiguration} 的
* {@code RequireAppSignFilter} 在过滤器层强制(无签名直接 401),本层不做验签;
* 但<b>验签只证明「是哪个应用」</b>,能否读这所学校由 {@code sys_group_app_rel}(应用在该校已开通)决定,
* 越校读取抛业务异常(A15)。<br>
* 引用一律 {@code schoolId + departmentType + departmentId} 三件套,禁止以名称做键。
*/
@Slf4j
@RestController
@RequestMapping(Config.OPEN_API_PATH + "departments")
public class DepartmentController {
/** 成员解析上下文:供新的指派/绑定(严档,也是缺省)。 */
private static final String SCOPE_SELECT = "select";
/** 成员解析上下文:已有实例的历史解析(宽档,停用/软删照发)。 */
private static final String SCOPE_HISTORY = "history";
@Autowired
private IDepartmentDirectoryService departmentDirectoryService;
@Autowired
private IApplicationInfoService applicationInfoService;
/**
* 可选部门目录:默认只返回启用且未软删的条目。<br>
* {@code type=SYSTEM|CUSTOM}(缺省两类都返回);{@code categoryId} 只对 CUSTOM 生效;
* {@code withChildren=true} 只对 SYSTEM 生效,连同部门下级一起返回(扁平 + parentId)。
*/
@GetMapping
public List<DepartmentEntryVo> list(@RequestParam("schoolId") Integer schoolId,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "categoryId", required = false) Integer categoryId,
@RequestParam(value = "withChildren", required = false) Boolean withChildren,
HttpServletRequest request) {
DepartmentTypeEnum parsed = parseType(type, true);
return departmentDirectoryService.list(resolveCallerAppId(request), schoolId, parsed,
categoryId, Boolean.TRUE.equals(withChildren));
}
/**
* CUSTOM 分类目录(学校通用,不按调用方应用隔离;分类只是容器,不能指派人员)。
*/
@GetMapping("categories")
public List<CustomDeptCategoryVo> categories(@RequestParam("schoolId") Integer schoolId,
HttpServletRequest request) {
return departmentDirectoryService.categories(resolveCallerAppId(request), schoolId);
}
/**
* 批量解析引用(<b>唯一</b>允许返回停用/软删部门的读口,A12)。<br>
* 一批里任一学校未对本应用开通 → 整批拒绝;解析不到的引用返回 name=null、selectable=false。
*/
@PostMapping("resolve-batch")
public List<DepartmentEntryVo> resolveBatch(@RequestBody @Validated List<DepartmentRefRequest> refs,
HttpServletRequest request) {
return departmentDirectoryService.resolveBatch(resolveCallerAppId(request), refs);
}
/**
* 部门成员解析。{@code scope=select}(缺省)=供新的指派:部门停用/软删直接拒绝;<br>
* {@code scope=history}=已有实例解析:停用/软删照常返回成员,不影响在途(A17)。<br>
* 出参字段与 {@code /oapi/group/users} 同形状(轻量 BoaUser,禁用账号靠 status=0 标识、不静默剔除)。
*/
@GetMapping("{type}/{id}/members")
public List<BoaUser> members(@PathVariable String type,
@PathVariable Integer id,
@RequestParam(value = "scope", required = false) String scope,
HttpServletRequest request) {
DepartmentTypeEnum parsed = parseType(type, false);
return departmentDirectoryService.members(resolveCallerAppId(request), parsed, id, parseScope(scope));
}
// ===================== 私有 =====================
/**
* 解析成员上下文:缺省与 {@code select} 都按「供新的指派」处理(更严的一档做默认),
* 只有明确 {@code history} 才放行停用/软删;其余取值直接拒绝——写错的 scope 不能静默变成宽口径。
*/
private boolean parseScope(String scope) {
String normalized = scope == null ? "" : scope.trim().toLowerCase();
if (normalized.isEmpty() || SCOPE_SELECT.equals(normalized)) {
return true;
}
if (SCOPE_HISTORY.equals(normalized)) {
return false;
}
throw new DException("scope 只允许 select / history");
}
/**
* 解析来源类型:路径段必解析(非法即拒),查询参数缺省表示「两类都要」。
*/
private DepartmentTypeEnum parseType(String raw, boolean allowMissing) {
if (raw == null || raw.trim().isEmpty()) {
if (allowMissing) {
return null;
}
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
DepartmentTypeEnum parsed = DepartmentTypeEnum.of(raw);
if (parsed == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
return parsed;
}
/**
* 从验签属性取 appKey,再映射为 application_info 主键(与 {@code oapi/user} 的 {@code modules} 同一链路)。<br>
* 解析不出应用 → 传 null 给 Service,由其按「无法识别调用方」拒绝:部门目录不能匿名兜底成「全量可读」。
*/
private Integer resolveCallerAppId(HttpServletRequest request) {
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
if (attr == null) {
log.warn("[oapi:departments] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
request.getRequestURI());
return null;
}
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
if (appKeyStr.trim().isEmpty()) {
log.warn("[oapi:departments] AppAuth attr is blank, uri={}", request.getRequestURI());
return null;
}
Integer appKey;
try {
appKey = Integer.parseInt(appKeyStr.trim());
} catch (NumberFormatException e) {
log.warn("[oapi:departments] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
return null;
}
Integer appId = applicationInfoService.findIdByAppKey(appKey);
if (appId == null) {
log.warn("[oapi:departments] appKey={} not found in application_info, uri={}", appKey,
request.getRequestURI());
}
return appId;
}
}
@@ -10,6 +10,7 @@ import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.annotation.OapiLog;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
@@ -91,30 +92,49 @@ public class UserController {
}
/**
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
* 查询用户在<b>指定应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
* <br>
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br>
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter
* 已保证非空);<br>
* ② 查 application_info 映射到具体 appId;<br>
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按单一应用维度过滤。应用来源两态:<br>
* ① 传了可选参数 appCode → 按 application_info.app_code 解析<b>目标应用</b>(供托管型应用
* 如 form-center 代快应用渲染导航时查询目标应用的模块清单;查不到返回空列表并 warn,
* 口径同现有兜底);<br>
* ② 未传 appCode → 现行为不变:通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的
* appKey(RequireAppSignFilter 已保证非空),查 application_info 映射到具体 appId;<br>
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
* <br>
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。<br>
* 边界:应用解析失败(appCode/appKey 查不到对应应用)→ 直接返回空列表(安全优先,禁止意外
* 返回其他应用的模块)。<br>
* 记账:本参数暴露面=模块名/路径/图标级低敏数据;如需收紧可后续限定仅 BASE 应用调用
* (暂放开,表单中心建设方案 §6.2 已记)。参数用 appCode(字符串)而非整数 appKey:
* 跨环境稳定,与 form-center 分发路由 /app/{appCode}/ 同键。
*
* @param request 用于取 AppAuthFilter 设置的 appKey
* @param request 用于取 AppAuthFilter 设置的 appKey(未传 appCode 时使用)
* @param userId 用户ID(必填)
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
* 为空时取用户全部真实归属组,与权限一览一致)
* @param appCode 目标应用技术标识(可选;传=查该应用的模块树,不传=按签名调用方解析)
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
*/
@GetMapping("modules")
public List<BoaModule> modules(HttpServletRequest request,
@RequestParam("userId") Integer userId,
@RequestParam(value = "schId", required = false) Integer schId) {
@RequestParam(value = "schId", required = false) Integer schId,
@RequestParam(value = "appCode", required = false) String appCode) {
if (userId == null) {
return Collections.emptyList();
}
Integer appId = resolveCallerAppId(request);
Integer appId;
if (appCode != null && !appCode.trim().isEmpty()) {
ApplicationInfo app = applicationInfoService.getByAppCode(appCode.trim());
if (app == null) {
log.warn("[oapi:user:modules] appCode={} not found in application_info, uri={}",
appCode, request.getRequestURI());
return Collections.emptyList();
}
appId = app.getId();
} else {
appId = resolveCallerAppId(request);
}
if (appId == null) {
return Collections.emptyList();
}
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
/**
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
*/
@NotBlank
@Column(name = "custom_id", nullable = false, length = 64)
@Column(name = "custom_id", nullable = true, length = 64)
private String customId;
/**
@@ -41,6 +41,13 @@ public class SysGroup extends BaseEntity {
@Column(name = "status")
private Integer status = 0;
/**
* 包含子组(2026-10-21 部门线):1=部门列表含下级部门 / 0=仅一级部门,仅学校节点使用。<br>
* 与 status 同为节点自身属性,翻转只允许走 {@code updateIncludeChildren} 单列 UPDATE。
*/
@Column(name = "include_children")
private Integer includeChildren = 1;
// 一对多反向关联中间表
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@@ -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.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 用户-移动渠道绑定关系(移动端接入 步 3,方案 §3.13)。
*
* <p>openid / unionid ↔ sys_user.id 的绑定属 UserBase 身份域;SDK 登录端点经
* {@code /oapi/channel/**}(应用签名)读写。channel 取值与前端 MobileChannel 对齐:
* wxgzh / miniapp / qywx / dingtalk。</p>
*
* <p>⚠ uk_user_channel 只约束半边:同一 unionid 仍可绑到两个账号(公众号一个、小程序一个),
* 应用层在 {@code ChannelBindService} 绑定时用 idx_unionid 查重补齐,勿仅依赖唯一约束。</p>
*
* @author chenx
* @date 2026/9/25
*/
@Data
@NoArgsConstructor
@Entity
@Table(
name = "sys_user_channel_rel",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"channel", "openid"}),
@UniqueConstraint(columnNames = {"user_id", "channel"})
},
indexes = {@Index(columnList = "unionid")}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUserChannelRel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** sys_user.id */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** wxgzh / miniapp / qywx / dingtalk */
@Column(nullable = false, length = 20)
private String channel;
/** 渠道内用户标识 */
@Column(nullable = false, length = 64)
private String openid;
/** 同一开放平台下跨端一致;可空 */
@Column(name = "unionid", length = 64)
private String unionId;
/** 绑定时间(服务端 now() 写入) */
@Column(name = "bound_at", nullable = false)
private LocalDateTime boundAt;
/** 最近一次经该渠道登录时间(token 签发时更新,可空) */
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
}
@@ -0,0 +1,71 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/10/21
* @description 学校自定义部门(CUSTOM 侧可指派对象:有成员、有启停、删除为软删)。<br>
* 与 SYSTEM 组织树完全分离:不改组织树、不混入自定义节点;
* 主键独立于 {@code sys_group.id},跨应用引用一律带 {@code departmentType} 消歧,禁止以名称做键。<br>
* 唯一性由 {@code uk_school_category_name_marker(school_id, category_id, name, del_marker)} 保证:
* 同校同分类下未软删的部门名不重复,跨分类与 SYSTEM/CUSTOM 之间允许重名。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_department")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDepartment extends BaseEntity {
/** 学校组 id(冗余自分类,写口由服务端复核,不接受前端传值做授权)。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 所属分类(必须同校,移动只允许在本校其他分类之间)。 */
@Column(name = "category_id", nullable = false)
private Integer categoryId;
/** 部门名称,学校自定;软删也不改写。 */
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/**
* 启用状态:1=启用 / 0=停用。<br>
* ⚠ 语义与 {@code sys_group.status}(0=启用/1=停用)方向相反,严禁混用;接口层统一输出 boolean enabled。
*/
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
/** 软删标记:0=有效 / 1=已软删;软删后成员关系保留在库,由查询侧过滤。 */
@Column(name = "is_deleted", nullable = false)
private Integer isDeleted = 0;
/** 软删时置为该行 id,参与唯一键。 */
@Column(name = "del_marker", nullable = false)
private Long delMarker = 0L;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Integer deletedBy;
public boolean isDeletedRow() {
return isDeleted != null && isDeleted == 1;
}
public boolean isEnabledRow() {
return enabled != null && enabled == 1;
}
}
@@ -0,0 +1,36 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门成员关系(引用 {@code sys_user.id},不复制账号)。<br>
* 一个用户可属同校多个 CUSTOM 部门;{@code uk_department_user(department_id, user_id)} 挡有效重复。<br>
* 移除成员=<b>物理删</b>该关系行(本仓无软删惯例,增删审计由 {@code @OperationLog} 承担),
* 因此不会残留「已移除但仍有效」的成员。<br>
* 成员范围=本校教职工且排除学生组(type=8)下的用户,由服务端逐个复核,不接受前端传入即认定。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_department_member")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDepartmentMember extends BaseEntity {
/** 学校组 id(与部门同校,写口服务端复核)。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
@Column(name = "department_id", nullable = false)
private Integer departmentId;
@Column(name = "user_id", nullable = false)
private Integer userId;
}
@@ -0,0 +1,56 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/10/21
* @description 学校自定义部门分类(CUSTOM 侧容器层)。<br>
* 分类只是管理容器:<b>没有启停状态、不能配成员</b>,不是可指派的业务部门。<br>
* 唯一性由 {@code uk_school_name_marker(school_id, name, del_marker)} 保证:
* 未软删行 {@code del_marker=0},软删时置为该行 id,因此同校同名只挡有效行;
* {@code name} 永不改写,保证历史解析拿到原名、旧引用不指向新对象。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_dept_category")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDeptCategory extends BaseEntity {
/** 学校组 id(sys_group 中 type=2 节点),与 AccessScopeService.scopeGroupIds() 同域。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 分类名称,学校自定;软删也不改写。 */
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 软删标记:0=有效 / 1=已软删。删除不是第三种业务状态,与 enabled 维度分离。 */
@Column(name = "is_deleted", nullable = false)
private Integer isDeleted = 0;
/** 软删时置为该行 id,参与唯一键(MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住重复激活)。 */
@Column(name = "del_marker", nullable = false)
private Long delMarker = 0L;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Integer deletedBy;
public boolean isDeletedRow() {
return isDeleted != null && isDeleted == 1;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/10/21
* @description 部门来源类型(跨业务统一引用的消歧位)。<br>
* 只有 {@code SYSTEM}(原组织架构组)与 {@code CUSTOM}(学校自定义部门)两种,
* <b>不得</b>扩成 FLOW/SVC/AUDIT 等业务类型;引用一律「schoolId + departmentType + departmentId」,禁止以名称做键。
*/
@Getter
@AllArgsConstructor
public enum DepartmentTypeEnum implements IEnum<String> {
SYSTEM("SYSTEM", "系统部门"),
CUSTOM("CUSTOM", "自定义部门");
private final String key;
private final String value;
@Override
public String getKey() {
return key;
}
@Override
public String getValue() {
return value;
}
/** 宽松解析(大小写/空白不敏感);非 SYSTEM/CUSTOM 返回 null,由调用方拒绝。 */
public static DepartmentTypeEnum of(String raw) {
if (raw == null) {
return null;
}
String normalized = raw.trim().toUpperCase();
for (DepartmentTypeEnum type : values()) {
if (type.key.equals(normalized)) {
return type;
}
}
return null;
}
}
@@ -26,10 +26,13 @@ public class AppModuleRequest {
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
private String name;
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空")
/**
* 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
*/
private String customId;
/** 模块路径(前端路由/资源路径)。<br> */
/** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
private String path;
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
@@ -0,0 +1,30 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 渠道绑定(手机号证明模式)开放接口入参(/oapi/channel/bindByPhone)。
* 供小程序「一键手机号」使用:手机号归属由渠道验签器(phoneCode → phone)证明,无密码。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelBindByPhoneRequest {
/** 渠道码:miniapp */
@NotBlank(message = "渠道不能为空")
private String channel;
@NotBlank(message = "openid 不能为空")
private String openid;
/** 同一开放平台下跨端一致标识;可空 */
private String unionId;
/** 渠道验签器解析出的已验证手机号 */
@NotBlank(message = "手机号不能为空")
private String phone;
}
@@ -0,0 +1,33 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 渠道绑定(凭证模式)开放接口入参(/oapi/channel/bind)。
* openid/unionId 由 SDK 登录端点从一次性 loginTicket 解出后传入,前端永不直传。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelBindRequest {
/** 渠道码:wxgzh / miniapp / qywx / dingtalk */
@NotBlank(message = "渠道不能为空")
private String channel;
@NotBlank(message = "openid 不能为空")
private String openid;
/** 同一开放平台下跨端一致标识;可空 */
private String unionId;
/** 登录账号(学生学号 / 教师手机号) */
@NotBlank(message = "账号不能为空")
private String account;
@NotBlank(message = "密码不能为空")
private String password;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 解绑开放接口入参(/oapi/channel/unbind)。
* account 由 SDK 登录端点从 token payload 解出后传入(服务端定位用户),非客户端自报。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelUnbindRequest {
/** 渠道码:wxgzh / miniapp / qywx / dingtalk */
@NotBlank(message = "渠道不能为空")
private String channel;
/** 登录账号(token payload 内 account) */
@NotBlank(message = "账号不能为空")
private String account;
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 部门成员批量添加/移除请求。<br>
* 服务端<b>逐个</b>复核每个 userId:本校 + 教职工(排除学生组 type=8 下的用户)+ 有效账号;
* 任一不合格整批拒绝(不返回「部分成功」,避免前端以为已生效)。
*/
@Data
public class CustomDepartmentMemberRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
@NotEmpty(message = "成员列表不能为空")
private List<Integer> userIds;
}
@@ -0,0 +1,20 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 部门成员列表项(管理端弹窗用,字段最小化:不返回手机号/身份证等敏感列)。
*/
@Data
public class CustomDepartmentMemberVo {
private Integer userId;
/** 账号(sys_user.username)。 */
private String account;
/** 姓名(sys_user.real_name)。 */
private String name;
}
@@ -0,0 +1,21 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 部门移动请求:只允许移动到<b>本校</b>其他分类;目标分类跨校直接拒绝。
*/
@Data
public class CustomDepartmentMoveRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 目标分类 id。 */
@NotNull(message = "目标分类不能为空")
private Integer categoryId;
}
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门创建/改名请求。新建默认 enabled=1,成员可为空(先建后配)。
*/
@Data
public class CustomDepartmentRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 归属分类 id:创建必填;改名不生效(以库内归属为准,防止前端传错分类绕过重名范围)。 */
private Integer categoryId;
@NotBlank(message = "部门名称不能为空")
@Size(max = 64, message = "部门名称不能超过 64 个字符")
private String name;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 部门排序请求:把某分类下的有效部门按给定 ids 顺序重排(sort_no 从 1 起)。
*/
@Data
public class CustomDepartmentSortRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 排序作用范围所在分类 id。 */
private Integer categoryId;
@NotEmpty(message = "部门ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,31 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门列表项(管理端卡片;停用可见、软删默认不出现)。<br>
* {@code enabled} 对外统一 boolean,库内是 1/0 单列,避免与 sys_group.status 的方向混淆。
*/
@Data
public class CustomDepartmentVo {
private Integer id;
private Integer schoolId;
private Integer categoryId;
private String categoryName;
private String name;
private Integer sortNo;
/** 是否启用(true=启用)。 */
private Boolean enabled;
/** 有效成员数。 */
private Long memberCount;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义分类创建/改名请求。<br>
* schoolId 只作与服务端身份范围的一致性核对,<b>不作为授权依据</b>(授权见
* {@code AccessScopeService#resolveWritableSchoolId})。
*/
@Data
public class CustomDeptCategoryRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
@NotBlank(message = "分类名称不能为空")
@Size(max = 64, message = "分类名称不能超过 64 个字符")
private String name;
}
@@ -0,0 +1,22 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 分类排序请求:把本校有效分类按给定 ids 顺序重排(范式同 SysGroupSortRequest,落库 sort_no 从 1 起)。
*/
@Data
public class CustomDeptCategorySortRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 期望顺序的分类 id 列表,必须覆盖本校全部有效分类且不得含别校分类。 */
@NotEmpty(message = "分类ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义分类列表项(管理端左侧平铺列表;软删分类不出现在列表里)。
*/
@Data
public class CustomDeptCategoryVo {
private Integer id;
private Integer schoolId;
private String name;
private Integer sortNo;
/** 该分类下<b>有效</b>部门数:前端据此对非空分类禁用「删除」。 */
private Long departmentCount;
}
@@ -0,0 +1,44 @@
package com.yunhai.userbase.model.dept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录条目(规格书 §4.2 统一响应条目,SYSTEM/CUSTOM 共用同一形状)。<br>
* 引用必须三件套齐全:{@code schoolId + departmentType + departmentId};
* SYSTEM 的 {@code categoryId/categoryName} 恒为 null,层级用 {@code parentId}(照组契约)。
*/
@Data
public class DepartmentEntryVo {
/** 学校组 id。 */
private Integer schoolId;
/** 来源:SYSTEM / CUSTOM(消歧位,禁止靠名称或 ID 单独判断)。 */
private String departmentType;
/** SYSTEM= sys_group.id;CUSTOM= ub_custom_department.id。两域各自独立,可数值相同但语义不同。 */
private Integer departmentId;
private String name;
private Integer categoryId;
private String categoryName;
/** 是否启用(CUSTOM 读 enabled 列,SYSTEM 读 status≠停用)。 */
private Boolean enabled;
/** 是否已软删(仅历史解析口会为 true;SYSTEM 不支持删除,恒 false)。 */
private Boolean deleted;
/** 是否可用于<b>新的</b>选择/绑定:停用或已软删 → false(A10/A12)。 */
private Boolean selectable;
/** SYSTEM=该组直属用户数;CUSTOM=有效成员数。 */
private Long memberCount;
/** 上级部门 id(仅 SYSTEM;withChildren=true 时用于重建层级,CUSTOM 为 null)。 */
private Integer parentId;
}
@@ -0,0 +1,26 @@
package com.yunhai.userbase.model.dept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 部门引用({@code POST /oapi/departments/resolve-batch} 的入参项)。<br>
* 业务侧存的就是这三件套;这里<b>不</b>按名称回填,名称只作为解析结果返回。
*/
@Data
public class DepartmentRefRequest {
@NotNull(message = "schoolId 不能为空")
private Integer schoolId;
/** SYSTEM / CUSTOM(大小写不敏感)。 */
@NotBlank(message = "departmentType 不能为空")
private String departmentType;
@NotNull(message = "departmentId 不能为空")
private Integer departmentId;
}
@@ -0,0 +1,19 @@
package com.yunhai.userbase.model.sysgroup;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 设置「包含子组」请求:该校在部门管理页是否连同下级部门一起列出。<br>
* 仅学校节点使用;true=含子组(1)/ false=仅本级(0)。
*/
@Data
public class SysGroupIncludeChildrenRequest {
/** 是否包含子组。 */
@NotNull(message = "includeChildren 不能为空")
private Boolean includeChildren;
}
@@ -18,6 +18,15 @@ public class SysGroupVo {
private Integer sort;
/** 启停状态(2026-10-20 部门线 P0):0=启用 / 1=停用;BeanUtils 同名拷贝,null 视为 0 由前端兜底 */
private Integer status;
/** 包含子组(2026-10-21):1=部门列表含下级部门 / 0=仅本级;BeanUtils 同名拷贝,null 视为 1 由前端兜底 */
private Integer includeChildren;
/** 直属成员数(部门表格「人员」列);口径同 oapi 部门目录:sys_user_group_rel 按 groupId 计数,不排除转出。 */
private Long memberCount;
/**
* 直属成员姓名预览,最多 {@code SysGroupService.MEMBER_PREVIEW_LIMIT} 个。<br>
* 完整名单不走这里(部门可能上百人),前端点「查看全部」调 /api/sysuser/List?groupId=xxx。
*/
private List<String> memberNames = new ArrayList<>();
/** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */
private List<SysGroupVo> children = new ArrayList<>();
}
@@ -17,6 +17,12 @@ import java.util.List;
* @description
*/
public interface ApplicationInfoRepository extends JpaRepository<ApplicationInfo, Integer>, JpaSpecificationExecutor<ApplicationInfo> {
/** 读取持久化标识快照;禁止查询前自动 flush 掉待校验的实体改名。 */
@Query("SELECT a.appCode FROM ApplicationInfo a WHERE a.id = :id")
@org.springframework.data.jpa.repository.QueryHints(
@javax.persistence.QueryHint(name = "org.hibernate.flushMode", value = "COMMIT"))
String findPersistedAppCodeById(@Param("id") Integer id);
// 基石应用排除 = AppNature.isBaseApp(appKey<=100000 ∪ app_nature='BASE')的 SQL 形态,常量拼接、语义单点见 AppNature。
// app_nature 过渡期可空:`<> 'BASE'` 遇 NULL 求值为 NULL(非 TRUE)会整行漏掉,必须带 IS NULL 分支。
@Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " +
@@ -25,6 +25,14 @@ public interface SysGroupRepository extends JpaRepository<SysGroup, Integer>, Jp
@Query("UPDATE SysGroup g SET g.status = :status WHERE g.id = :id")
int updateStatus(@Param("id") Integer id, @Param("status") Integer status);
/**
* 「包含子组」单列 UPDATE(2026-10-21 部门线):与 {@link #updateStatus} 同语义,
* 严格只动 sys_group.include_children 一列,不触发实体监听器与审计列。
*/
@Modifying
@Query("UPDATE SysGroup g SET g.includeChildren = :includeChildren WHERE g.id = :id")
int updateIncludeChildren(@Param("id") Integer id, @Param("includeChildren") Integer includeChildren);
@Query("SELECT MAX(g.sort) FROM SysGroup g")
Integer findMaxSort();
@@ -0,0 +1,39 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.SysUserChannelRel;
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;
/**
* 用户-移动渠道绑定仓储(sys_user_channel_rel,移动端接入 步 3)。
*
* @author chenx
* @date 2026/9/25
*/
public interface SysUserChannelRelRepository extends JpaRepository<SysUserChannelRel, Long>, JpaSpecificationExecutor<SysUserChannelRel> {
/** 按 (channel, openid) 查绑定(uk_channel_openid 唯一) */
Optional<SysUserChannelRel> findByChannelAndOpenid(String channel, String openid);
/** 按用户 + 渠道查绑定(uk_user_channel 唯一):判"该账号在此渠道是否已绑过" */
Optional<SysUserChannelRel> findByUserIdAndChannel(Integer userId, String channel);
/** unionid 应用层查重(uk_user_channel 只约束半边,方案 §3.13 ⚠):同 unionid 已存在的绑定行 */
List<SysUserChannelRel> findByUnionId(String unionId);
/** 解绑(按用户 + 渠道) */
@Modifying
@Query("DELETE FROM SysUserChannelRel r WHERE r.userId = :userId AND r.channel = :channel")
void deleteByUserIdAndChannel(@Param("userId") Integer userId, @Param("channel") String channel);
/** 级联清理:删除用户时清残留绑定行 */
@Modifying
@Query("DELETE FROM SysUserChannelRel r WHERE r.userId = :userId")
void deleteByUserIdBulk(@Param("userId") Integer userId);
}
@@ -0,0 +1,38 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门成员关系仓储。
*/
public interface UbCustomDepartmentMemberRepository
extends JpaRepository<UbCustomDepartmentMember, Integer>, JpaSpecificationExecutor<UbCustomDepartmentMember> {
List<UbCustomDepartmentMember> findByDepartmentId(Integer departmentId);
/** 批量取多部门成员(列表页成员数统计用,调用方自行分组计数)。 */
List<UbCustomDepartmentMember> findByDepartmentIdIn(List<Integer> departmentIds);
/** 某用户在 CUSTOM 侧的全部归属(跨部门合法性判定用)。 */
List<UbCustomDepartmentMember> findByUserId(Integer userId);
/**
* 移除成员=物理删关系行(bulk delete 直接发 SQL,避免 Hibernate flush 先 insert 后 delete 撞唯一键,
* 与 {@code SysGroupAppRelRepository#deleteByGroupIdBulk} 同理由)。
*/
@Modifying
@Query("DELETE FROM UbCustomDepartmentMember m WHERE m.departmentId = :departmentId AND m.userId IN :userIds")
int deleteByDepartmentIdAndUserIds(@Param("departmentId") Integer departmentId,
@Param("userIds") List<Integer> userIds);
long countByDepartmentId(Integer departmentId);
}
@@ -0,0 +1,50 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDepartment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门仓储。<br>
* 与 {@code SysGroupRepository#updateStatus} 同范式:启停走单列 {@code @Modifying} JPQL,
* 绕过实体监听器与审计列(这是本仓既定的单列翻转特例);常规增删改仍走实体保存。
*/
public interface UbCustomDepartmentRepository
extends JpaRepository<UbCustomDepartment, Integer>, JpaSpecificationExecutor<UbCustomDepartment> {
/**
* 启用/停用单列 UPDATE:严格只动 enabled 一列。<br>
* ⚠ enabled 语义 1=启用/0=停用,与 sys_group.status 方向相反,调用方不要按 status 传值。
*/
@Modifying
@Query("UPDATE UbCustomDepartment d SET d.enabled = :enabled WHERE d.id = :id")
int updateEnabled(@Param("id") Integer id, @Param("enabled") Integer enabled);
/** 某分类下的有效部门(按排序)。 */
List<UbCustomDepartment> findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(Integer categoryId, Integer isDeleted);
/** 某校全部有效部门(T2 目录/分类计数用)。 */
List<UbCustomDepartment> findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(Integer schoolId, Integer isDeleted);
/** 同校同分类下有效同名部门(重名前置校验)。 */
Optional<UbCustomDepartment> findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(
Integer schoolId, Integer categoryId, String name, Integer isDeleted);
boolean existsBySchoolIdAndCategoryIdAndNameAndIsDeleted(
Integer schoolId, Integer categoryId, String name, Integer isDeleted);
/** 分类下有效部门数(非空分类禁止软删,A14)。 */
long countByCategoryIdAndIsDeleted(Integer categoryId, Integer isDeleted);
/** 同分类内当前最大排序号(新建部门排到最后;无有效部门返回 null)。 */
@Query("SELECT MAX(d.sortNo) FROM UbCustomDepartment d WHERE d.categoryId = :categoryId AND d.isDeleted = 0")
Integer findMaxSortNoByCategory(@Param("categoryId") Integer categoryId);
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类仓储。<br>
* 查询一律带 {@code isDeleted} 维度:软删行默认不出现,历史解析(T2)单独按 id 取。
*/
public interface UbCustomDeptCategoryRepository
extends JpaRepository<UbCustomDeptCategory, Integer>, JpaSpecificationExecutor<UbCustomDeptCategory> {
/** 分类列表:按学校 + 排序(sort_no 相同时按 id 稳定兜底)。 */
List<UbCustomDeptCategory> findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(Integer schoolId, Integer isDeleted);
/** 同校有效分类是否同名(uk 的服务端前置校验,给友好报错)。 */
Optional<UbCustomDeptCategory> findFirstBySchoolIdAndNameAndIsDeleted(Integer schoolId, String name, Integer isDeleted);
boolean existsBySchoolIdAndNameAndIsDeleted(Integer schoolId, String name, Integer isDeleted);
long countBySchoolIdAndIsDeleted(Integer schoolId, Integer isDeleted);
/** 本校当前最大分类排序号(新建分类排到最后;无有效分类返回 null)。 */
@Query("SELECT MAX(c.sortNo) FROM UbCustomDeptCategory c WHERE c.schoolId = :schoolId AND c.isDeleted = 0")
Integer findMaxSortNoBySchool(@Param("schoolId") Integer schoolId);
}
@@ -0,0 +1,58 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理服务(CUSTOM 侧,规格书 UB-1)。<br>
* 部门只有「启用/停用」两态;删除是软删(不是第三种状态);成员引用 sys_user.id,不复制账号。
*/
public interface ICustomDepartmentService {
/** 某分类下的有效部门列表(含成员数;停用可见、软删不出现)。 */
List<CustomDepartmentVo> list(Integer schoolId, Integer categoryId);
/** 新建部门:默认 enabled=1,成员可为空(先建后配);同校同分类重名拒绝。 */
CustomDepartmentVo add(CustomDepartmentRequest request);
/** 改名:归属分类以库内为准;同校同分类重名拒绝。 */
CustomDepartmentVo rename(Integer id, CustomDepartmentRequest request);
/** 移动至本校其他分类;目标分类跨校直接拒绝。 */
CustomDepartmentVo move(Integer id, CustomDepartmentMoveRequest request);
/** 同分类内排序:ids 必须全部是本校有效部门。 */
void sort(CustomDepartmentSortRequest request);
/** 启用/停用(单列 UPDATE,两态翻转;软删行不可翻转)。 */
void changeEnabled(Integer id, boolean enabled);
/** 软删部门:成员关系保留在库、由查询侧过滤;不物理删、不释放 id。 */
void softDelete(Integer id);
/** 有效成员列表。 */
List<CustomDepartmentMemberVo> members(Integer id);
/**
* 可添加的候选人员:本校教职工(D4,排除学生组)且<b>尚未</b>在该部门内,按账号/姓名模糊过滤。<br>
* 现有 {@code /api/groupauthz/GroupUsers} 是「组维度」口径、会带出学生组用户,不能直接当候选源,
* 故候选必须走这条与写口同一判定的接口。
*
* @param keyword 账号/姓名关键字,空表示不过滤
*/
List<CustomDepartmentMemberVo> candidateUsers(Integer id, String keyword);
/** 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,已存在的按幂等跳过。 */
void addMembers(Integer id, CustomDepartmentMemberRequest request);
/** 移除成员:物理删关系行(不残留有效成员)。 */
void removeMembers(Integer id, CustomDepartmentMemberRequest request);
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理服务(CUSTOM 侧,规格书 UB-1)。<br>
* 分类是纯容器:无启停、无成员;删除只有软删,且<b>禁止任何级联</b>。
*/
public interface ICustomDeptCategoryService {
/** 本校有效分类列表(按 sort_no、id 升序;软删分类不出现)。 */
List<CustomDeptCategoryVo> list(Integer schoolId);
/** 新建分类:同校有效分类重名直接拒绝。 */
CustomDeptCategoryVo add(CustomDeptCategoryRequest request);
/** 分类改名:同校其他有效分类重名拒绝;name 改写只发生在有效行,软删行的历史名称永不覆盖。 */
CustomDeptCategoryVo rename(Integer id, CustomDeptCategoryRequest request);
/** 分类排序:ids 必须全部是本校有效分类。 */
void sort(CustomDeptCategorySortRequest request);
/**
* 软删分类:仍存在有效子部门时<b>明确拒绝</b>(A14),不做任何级联删除。<br>
* 软删写 is_deleted/del_marker/deleted_at/deleted_by,保留原 id 且不复用。
*/
void softDelete(Integer id);
}
@@ -0,0 +1,44 @@
package com.yunhai.userbase.service;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录契约服务(规格书 UB-2,供 flow-center / svc-hall 等消费方经 {@code /oapi} 读取)。<br>
* 与后台 {@code ICustomDepartmentService} 的关键差别:本服务<b>不感知登录态</b>
* ({@code AccessScopeService} 只服务管理端),授权单位是「调用方应用 × 学校」:
* 应用必须在目标学校已开通({@code sys_group_app_rel}),<b>仅验签不等于可读任意学校</b>(A15)。<br>
* 只读;不写任何数据;本期直查不加缓存。
*/
public interface IDepartmentDirectoryService {
/**
* 可选部门目录:默认只返回<b>启用且未软删</b>的条目。<br>
* type=null 时 SYSTEM + CUSTOM 一并返回;categoryId 只对 CUSTOM 生效;
* withChildren 只对 SYSTEM 生效(true=含部门下级,层级靠 parentId 还原)。
*/
List<DepartmentEntryVo> list(Integer appId, Integer schoolId, DepartmentTypeEnum type,
Integer categoryId, boolean withChildren);
/** CUSTOM 分类目录(按学校授权;不混入 SYSTEM 组织树)。 */
List<CustomDeptCategoryVo> categories(Integer appId, Integer schoolId);
/**
* 批量历史解析:<b>唯一</b>允许返回停用/软删部门的读口(A12)。<br>
* 解析不到(对象不存在,或 SYSTEM 组已被组织模块删除)时按「不可选」返回:name=null、deleted=true。
*/
List<DepartmentEntryVo> resolveBatch(Integer appId, List<DepartmentRefRequest> refs);
/**
* 部门成员解析。{@code forNewAssignment=true}(供新指派)时要求部门启用且未软删,否则拒绝;
* {@code forNewAssignment=false}(已有实例解析)时停用/软删照常返回成员,不改变在途语义(A17)。
*/
List<BoaUser> members(Integer appId, DepartmentTypeEnum type, Integer departmentId, boolean forNewAssignment);
}
@@ -37,6 +37,15 @@ public interface ISysGroupService {
*/
void changeStatus(Integer id, boolean disabled);
/**
* 设置「包含子组」(2026-10-21 部门线):单列 UPDATE sys_group.include_children,不碰其他任何表与列。<br>
* 仅学校节点使用,语义是「部门管理页的部门列表是否连同下级部门一起展示」。
*
* @param id 学校组ID
* @param include true=含子组(1)/ false=仅本级(0)
*/
void changeIncludeChildren(Integer id, boolean include);
/**
* 开放接口专用:在学校(组)范围内删除组。<br>
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
@@ -58,12 +58,16 @@ public class AppModuleService implements IAppModuleService {
if (!applicationInfoRepository.existsById(request.getAppId())) {
throw new DException("所属应用不存在");
}
if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) {
throw new DException("自定义ID在该应用下已存在");
}
AppModule entity = new AppModule();
BeanUtils.copyProperties(request, entity);
entity.setId(null);
// 按钮权限型子模块可不带编码:空白归一为 null,仅在有值时做应用内唯一校验
entity.setCustomId(normalizeBlankToNull(request.getCustomId()));
if (entity.getCustomId() != null
&& appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), entity.getCustomId())) {
throw new DException("自定义ID在该应用下已存在");
}
entity.setPath(normalizeBlankToNull(request.getPath()));
entity.setLevel(resolveLevel(request.getAppId(), request.getParentId()));
// sortNo 默认为空(由前端编辑时带入),不自动递增
appModuleRepository.save(entity);
@@ -77,13 +81,15 @@ public class AppModuleService implements IAppModuleService {
Objects.requireNonNull(request, "模块编辑入参不能为空");
AppModule entity = appModuleRepository.findById(id)
.orElseThrow(() -> new RuntimeException("模块不存在: " + id));
if (appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), request.getCustomId(), id)) {
// 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
String customId = normalizeBlankToNull(request.getCustomId());
if (customId != null
&& appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), customId, id)) {
throw new DException("自定义ID在该应用下已存在");
}
// 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
entity.setName(request.getName());
entity.setCustomId(request.getCustomId());
entity.setPath(request.getPath());
entity.setCustomId(customId);
entity.setPath(normalizeBlankToNull(request.getPath()));
entity.setIcon(request.getIcon());
entity.setSortNo(request.getSortNo());
// parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级
@@ -278,6 +284,19 @@ public class AppModuleService implements IAppModuleService {
}
}
/**
* 按钮权限型子模块可不带编码/路径:空白(null/空串/纯空格)统一归一为 null。<br>
* customId 归一是硬性要求:'' 在唯一键 uk_app_module_custom(app_id, custom_id) 下每个应用只能有一条,
* 多个按钮型子模块会互相撞键;NULL 在 MySQL 唯一索引中可并存。
*/
private static String normalizeBlankToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private AppModuleVo convertToVo(AppModule entity) {
AppModuleVo vo = new AppModuleVo();
BeanUtils.copyProperties(entity, vo);
@@ -145,6 +145,10 @@ public class ApplicationInfoService implements IApplicationInfoService {
*/
@Override
public ApplicationInfo save(ApplicationInfo info) {
if (info.getId() != null) {
requireUnchangedAppCode(applicationInfoRepository.findPersistedAppCodeById(info.getId()),
info.getAppCode());
}
ApplicationInfo saved = applicationInfoRepository.save(info);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
return saved;
@@ -199,6 +203,10 @@ public class ApplicationInfoService implements IApplicationInfoService {
}
ApplicationInfo entity = applicationInfoRepository.findById(id)
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
// 空值仍按既有部分提交语义处理;非空已登记标识不可真正改名。
if (StringUtils.isNotBlank(request.getAppCode())) {
requireUnchangedAppCode(entity.getAppCode(), request.getAppCode());
}
if (StringUtils.isNotBlank(request.getAppCode())
&& applicationInfoRepository.existsByAppCodeAndIdNot(request.getAppCode(), id)) {
throw new DException("技术标识已存在");
@@ -225,6 +233,12 @@ public class ApplicationInfoService implements IApplicationInfoService {
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
}
private static void requireUnchangedAppCode(String persisted, String proposed) {
if (StringUtils.isNotBlank(persisted) && !Objects.equals(persisted, proposed)) {
throw new DException("已登记的应用技术标识 appCode 不可修改");
}
}
@Override
@Transactional
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理);
@@ -433,4 +447,4 @@ public class ApplicationInfoService implements IApplicationInfoService {
}
return result;
}
}
}
@@ -0,0 +1,146 @@
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.SysUser;
import com.yunhai.userbase.entity.SysUserChannelRel;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.repository.SysUserChannelRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 移动端渠道绑定服务(sys_user_channel_rel,移动端接入 步 3,方案 §3.13)。<br>
* <br>
* openid / unionid ↔ userId 的绑定属 UserBase 身份域;SDK 登录端点(门户/各子应用)经
* {@code /oapi/channel/**} 应用签名调用本服务。绑定一律以服务端解析出的身份为准
* (openid/unionid 来自一次性 loginTicket,账号密码/手机号在服务端校验),前端永不直传 openid。<br>
* <br>
* 唯一性三层防线:<br>
* ① uk_channel_openid:一个 openid 同渠道只能绑一个账号(DB 兜底 + 应用层前置校验友好报错);<br>
* ② uk_user_channel:一个账号同渠道只能绑一个 openid(换绑须先解绑);<br>
* ③ unionid 应用层查重:uk_user_channel 只约束半边(同一 unionid 仍可绑两个账号——公众号一个、
* 小程序一个),用 idx_unionid 在绑定时补齐(方案 §3.13 ⚠)。
*
* @author chenx
* @date 2026/9/25
*/
@Service
public class ChannelBindService {
@Autowired
private SysUserChannelRelRepository relRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private UserCenterService userCenterService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 按 (channel, openid) 查已绑定用户(登录端点"已绑定 → 直接发 token"分支)。
*
* @return 已绑定时返回用户明细(含 currentRole);未绑定返回 null
*/
public BoaUser findBoundUser(String channel, String openid) {
return relRepository.findByChannelAndOpenid(channel, openid)
.map(rel -> userCenterService.getUserInfoWithDeptById(rel.getUserId()))
.orElse(null);
}
/**
* 凭证绑定(loginTicket 身份 + 账号密码)。密码 BCrypt 校验失败统一报"账号或密码错误"
* (不区分账号不存在/密码错,防账号枚举);防爆破计数(LoginGuard)在 SDK 侧。
*/
@Transactional
public BoaUser bindByCredential(String channel, String openid, String unionId,
String account, String password) {
requireIdentity(channel, openid);
if (StringUtils.isBlank(account) || StringUtils.isBlank(password)) {
throw new DException("账号与密码不能为空");
}
SysUser user = sysUserRepository.findByAccount(account);
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
throw new DException("账号或密码错误");
}
return doBind(channel, openid, unionId, user);
}
/**
* 手机号证明绑定(loginTicket 身份 + 渠道验签器已验证的手机号,小程序一键手机号)。
* 手机号归属由渠道验签器证明(phoneCode → phone),无需密码。
*/
@Transactional
public BoaUser bindByPhone(String channel, String openid, String unionId, String phone) {
requireIdentity(channel, openid);
if (StringUtils.isBlank(phone)) {
throw new DException("手机号不能为空");
}
SysUser user = sysUserRepository.findByPhone(phone);
if (user == null) {
throw new DException("未找到该手机号对应的账号");
}
return doBind(channel, openid, unionId, user);
}
/** 解绑:按登录账号 + 渠道删绑定行(服务端据 token 内 account 定位,非客户端自报 openid) */
@Transactional
public void unbind(String channel, String account) {
if (StringUtils.isBlank(channel) || StringUtils.isBlank(account)) {
throw new DException("渠道与账号不能为空");
}
SysUser user = sysUserRepository.findByAccount(account);
if (user == null) {
return;
}
relRepository.deleteByUserIdAndChannel(user.getId(), channel);
}
private void requireIdentity(String channel, String openid) {
if (StringUtils.isBlank(channel) || StringUtils.isBlank(openid)) {
throw new DException("渠道与 openid 不能为空");
}
}
private BoaUser doBind(String channel, String openid, String unionId, SysUser user) {
if (StatusEnum.DISABLED.equals(user.getDisabled())) {
throw new DException("账号已禁用,请联系管理员");
}
// ① openid 已绑定其他账号 → 拒绝(正常流程登录端点会先查绑定直接发 token,此处兜底并发窗口)
Optional<SysUserChannelRel> byOpenid = relRepository.findByChannelAndOpenid(channel, openid);
if (byOpenid.isPresent() && !byOpenid.get().getUserId().equals(user.getId())) {
throw new DException("该微信已绑定其他账号");
}
// ② 账号在此渠道已绑定其他 openid → 拒绝(换绑须先解绑)
Optional<SysUserChannelRel> byUser = relRepository.findByUserIdAndChannel(user.getId(), channel);
if (byUser.isPresent() && !byUser.get().getOpenid().equals(openid)) {
throw new DException("该账号已绑定其他微信,请先解绑");
}
// ③ unionid 应用层查重:防同一 unionid 绑到两个账号(uk_user_channel 只约束半边)
if (StringUtils.isNotBlank(unionId)) {
for (SysUserChannelRel rel : relRepository.findByUnionId(unionId)) {
if (!rel.getUserId().equals(user.getId())) {
throw new DException("该微信已绑定其他账号");
}
}
}
SysUserChannelRel rel = byOpenid.orElseGet(SysUserChannelRel::new);
rel.setUserId(user.getId());
rel.setChannel(channel);
rel.setOpenid(openid);
rel.setUnionId(StringUtils.isBlank(unionId) ? null : unionId);
rel.setBoundAt(LocalDateTime.now());
relRepository.save(rel);
return userCenterService.getUserInfoWithDeptById(user.getId());
}
}
@@ -0,0 +1,479 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.IDepartmentDirectoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录契约实现(UB-2)。<br>
* 授权链路照 {@code /oapi/group} 现行做法:签名由过滤器强制,本层只做「应用 × 学校」可见性——
* 权威事实是 {@code sys_group_app_rel}(该应用在该学校已开通),与
* {@code SchoolAppOverrideService#editAppForSchool} 同一口径,不新建 ACL 表。<br>
* 直查不加缓存(任务书 §0.2 铁律);{@code AccessScopeService} 只服务管理端,本类不引用。
*/
@Service
public class DepartmentDirectoryService implements IDepartmentDirectoryService {
private static final int NOT_DELETED = 0;
/** sys_group.status:0=启用 / 1=停用(方向与 CUSTOM 的 enabled 相反)。 */
private static final int GROUP_STATUS_DISABLED = 1;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private UbCustomDepartmentMemberRepository memberRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private SysGroupAppRelRepository sysGroupAppRelRepository;
// ===================== 目录 =====================
@Override
public List<DepartmentEntryVo> list(Integer appId, Integer schoolId, DepartmentTypeEnum type,
Integer categoryId, boolean withChildren) {
assertAppCanReadSchool(appId, schoolId);
List<DepartmentEntryVo> result = new ArrayList<>();
if (type == null || type == DepartmentTypeEnum.SYSTEM) {
if (categoryId != null) {
throw new DException("categoryId 仅对 CUSTOM 生效");
}
result.addAll(systemDepartments(schoolId, withChildren, true));
}
if (type == null || type == DepartmentTypeEnum.CUSTOM) {
result.addAll(customDepartments(schoolId, categoryId, true));
}
return result;
}
@Override
public List<CustomDeptCategoryVo> categories(Integer appId, Integer schoolId) {
assertAppCanReadSchool(appId, schoolId);
List<CustomDeptCategoryVo> result = new ArrayList<>();
for (UbCustomDeptCategory category :
categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED)) {
CustomDeptCategoryVo vo = new CustomDeptCategoryVo();
vo.setId(category.getId());
vo.setSchoolId(category.getSchoolId());
vo.setName(category.getName());
vo.setSortNo(category.getSortNo());
vo.setDepartmentCount(
departmentRepository.countByCategoryIdAndIsDeleted(category.getId(), NOT_DELETED));
result.add(vo);
}
return result;
}
// ===================== 历史解析 =====================
@Override
public List<DepartmentEntryVo> resolveBatch(Integer appId, List<DepartmentRefRequest> refs) {
if (refs == null || refs.isEmpty()) {
return Collections.emptyList();
}
// 逐校校验授权:一批里出现未开通的学校 → 整批拒绝(不做「能解析多少给多少」)
Set<Integer> schools = new LinkedHashSet<>();
for (DepartmentRefRequest ref : refs) {
if (ref == null || ref.getSchoolId() == null) {
throw new DException("引用项缺少 schoolId");
}
schools.add(ref.getSchoolId());
}
for (Integer schoolId : schools) {
assertAppCanReadSchool(appId, schoolId);
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (DepartmentRefRequest ref : refs) {
result.add(resolveOne(ref));
}
return result;
}
private DepartmentEntryVo resolveOne(DepartmentRefRequest ref) {
DepartmentTypeEnum type = DepartmentTypeEnum.of(ref.getDepartmentType());
if (type == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
if (type == DepartmentTypeEnum.CUSTOM) {
return resolveCustom(ref.getSchoolId(), ref.getDepartmentId());
}
return resolveSystem(ref.getSchoolId(), ref.getDepartmentId());
}
/** CUSTOM 历史解析:软删/停用照常返回名称与状态,仅 selectable=false。 */
private DepartmentEntryVo resolveCustom(Integer schoolId, Integer departmentId) {
UbCustomDepartment department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || !schoolId.equals(department.getSchoolId())) {
return unresolvable(schoolId, DepartmentTypeEnum.CUSTOM, departmentId);
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(department.getSchoolId());
entry.setDepartmentType(DepartmentTypeEnum.CUSTOM.getKey());
entry.setDepartmentId(department.getId());
entry.setName(department.getName());
entry.setCategoryId(department.getCategoryId());
entry.setCategoryName(categoryRepository.findById(department.getCategoryId())
.map(UbCustomDeptCategory::getName).orElse(null));
entry.setDeleted(department.isDeletedRow());
entry.setEnabled(department.isEnabledRow());
entry.setSelectable(!department.isDeletedRow() && department.isEnabledRow());
entry.setMemberCount(memberRepository.countByDepartmentId(department.getId()));
return entry;
}
/** SYSTEM 历史解析:组不存在、或不属于引用所称学校 → 视为解析不到(不把别校同名组当历史值)。 */
private DepartmentEntryVo resolveSystem(Integer schoolId, Integer groupId) {
SysGroup group = sysGroupRepository.findById(groupId).orElse(null);
if (group == null || !schoolId.equals(schoolOfGroup(group))) {
return unresolvable(schoolId, DepartmentTypeEnum.SYSTEM, groupId);
}
boolean enabled = isSystemEnabled(group);
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(DepartmentTypeEnum.SYSTEM.getKey());
entry.setDepartmentId(group.getId());
entry.setName(group.getName());
entry.setParentId(group.getParentId());
entry.setDeleted(false);
entry.setEnabled(enabled);
entry.setSelectable(enabled);
entry.setMemberCount(sysUserGroupRelRepository.countByGroupId(group.getId()));
return entry;
}
private DepartmentEntryVo unresolvable(Integer schoolId, DepartmentTypeEnum type, Integer departmentId) {
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(type.getKey());
entry.setDepartmentId(departmentId);
entry.setDeleted(true);
entry.setEnabled(null);
entry.setSelectable(false);
entry.setMemberCount(0L);
return entry;
}
// ===================== 成员解析 =====================
@Override
public List<BoaUser> members(Integer appId, DepartmentTypeEnum type, Integer departmentId,
boolean forNewAssignment) {
if (type == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
if (departmentId == null) {
throw new DException("部门ID不能为空");
}
if (type == DepartmentTypeEnum.CUSTOM) {
UbCustomDepartment department = departmentRepository.findById(departmentId).orElse(null);
if (department == null) {
throw new DException("自定义部门不存在");
}
assertAppCanReadSchool(appId, department.getSchoolId());
if (forNewAssignment && department.isDeletedRow()) {
throw new DException("部门已删除,不可用于新的指派");
}
if (forNewAssignment && !department.isEnabledRow()) {
throw new DException("部门已停用,不可用于新的指派");
}
return customMembers(departmentId);
}
SysGroup group = sysGroupRepository.findById(departmentId).orElse(null);
if (group == null) {
throw new DException("系统组不存在");
}
Integer schoolId = schoolOfGroup(group);
if (schoolId == null) {
throw new DException("该组织节点不属于任何学校");
}
assertAppCanReadSchool(appId, schoolId);
if (forNewAssignment && !isSystemEnabled(group)) {
throw new DException("部门已停用,不可用于新的指派");
}
return systemMembers(departmentId);
}
private List<BoaUser> customMembers(Integer departmentId) {
List<UbCustomDepartmentMember> rows = memberRepository.findByDepartmentId(departmentId);
if (rows.isEmpty()) {
return Collections.emptyList();
}
List<Integer> userIds = new ArrayList<>();
for (UbCustomDepartmentMember row : rows) {
userIds.add(row.getUserId());
}
return toBoaUsers(userIds);
}
private List<BoaUser> systemMembers(Integer groupId) {
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByGroupId(groupId);
if (rels.isEmpty()) {
return Collections.emptyList();
}
Set<Integer> userIds = new LinkedHashSet<>();
for (SysUserGroupRel rel : rels) {
userIds.add(rel.getUserId());
}
return toBoaUsers(new ArrayList<>(userIds));
}
/**
* 出参形状与 {@code /oapi/group/users} 保持一致:只给 id/账号/姓名/type/status,敏感列不外泄(§4.2 末条)。<br>
* <b>不禁用账号</b>:现行组用户接口就是「照发 + status 标 0」由消费方判({@code toLightBoaUser}),
* 这里若私自过滤,历史解析会静默少人。「人已离校」的处置策略归 flow-center 现行规则(§8.7),UserBase 不猜。<br>
* 只有关系行指向的物理不存在账号(孤儿行)跳过。
*/
private List<BoaUser> toBoaUsers(List<Integer> userIds) {
Map<Integer, SysUser> byId = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
byId.put(user.getId(), user);
}
List<BoaUser> result = new ArrayList<>();
for (Integer userId : userIds) {
SysUser user = byId.get(userId);
if (user == null) {
continue;
}
BoaUser boa = new BoaUser();
boa.setId(user.getId());
boa.setAccount(user.getAccount());
boa.setName(user.getRealName());
boa.setType(user.getType() != null ? user.getType().getKey() : null);
// 状态语义与组用户接口逐字对齐:disabled(NORMAL=0/ DISABLED=1) → BoaUser(1 正常 / 0 禁用)
boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0);
result.add(boa);
}
return result;
}
// ===================== SYSTEM 目录装配 =====================
/**
* SYSTEM 部门口径与部门管理页一致:<b>教师组(type=7)的直接子节点</b>;
* 学生组(type=8)下的节点不算部门。<br>
* withChildren=true 时连同部门下级一起给出(扁平 + parentId,由消费方重建层级)。<br>
* 停用的部门只在历史解析里出现(本方法由 {@code onlySelectable=true} 调用时会整枝剪掉)。
*/
private List<DepartmentEntryVo> systemDepartments(Integer schoolId, boolean withChildren, boolean onlySelectable) {
List<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, List<SysGroup>> childrenMap = new HashMap<>();
for (SysGroup group : all) {
Integer parent = group.getParentId() == null ? 0 : group.getParentId();
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(group);
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (SysGroup child : childrenMap.getOrDefault(schoolId, Collections.<SysGroup>emptyList())) {
if (child.getType() != GroupTypeEnum.TEACHER_GROUP) {
continue;
}
for (SysGroup department : childrenMap.getOrDefault(child.getId(), Collections.<SysGroup>emptyList())) {
boolean appended = appendSystemEntry(result, schoolId, department, onlySelectable);
if (withChildren && appended) {
appendSystemDescendants(result, schoolId, department.getId(), childrenMap, onlySelectable);
}
}
}
return result;
}
private void appendSystemDescendants(List<DepartmentEntryVo> result, Integer schoolId, Integer parentId,
Map<Integer, List<SysGroup>> childrenMap, boolean onlySelectable) {
Deque<Integer> frontier = new ArrayDeque<>();
frontier.add(parentId);
Set<Integer> visited = new HashSet<>();
while (!frontier.isEmpty()) {
Integer cursor = frontier.poll();
for (SysGroup child : childrenMap.getOrDefault(cursor, Collections.<SysGroup>emptyList())) {
if (!visited.add(child.getId())) {
continue;
}
appendSystemEntry(result, schoolId, child, onlySelectable);
frontier.add(child.getId());
}
}
}
/**
* 追加一个 SYSTEM 节点为目录条目。<br>
* 学生组本身永不作为部门;停用节点在 {@code onlySelectable} 下直接跳过。<br>
* 下级节点的组类型(普通组/年级组…)就是组树语义,不做二次筛选,照 parentId 原样下发。
*
* @return 该节点是否进入结果(false 表示被过滤,其下级不再展开)
*/
private boolean appendSystemEntry(List<DepartmentEntryVo> result, Integer schoolId, SysGroup group,
boolean onlySelectable) {
if (group.getType() == GroupTypeEnum.STUDENT_GROUP) {
return false;
}
boolean enabled = isSystemEnabled(group);
if (onlySelectable && !enabled) {
return false;
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(DepartmentTypeEnum.SYSTEM.getKey());
entry.setDepartmentId(group.getId());
entry.setName(group.getName());
entry.setParentId(group.getParentId());
entry.setEnabled(enabled);
entry.setDeleted(false);
entry.setSelectable(enabled);
entry.setMemberCount(sysUserGroupRelRepository.countByGroupId(group.getId()));
result.add(entry);
return true;
}
/** sys_group.status:0=启用(含存量 null)/ 1=停用。方向与 CUSTOM 的 enabled 相反。 */
private boolean isSystemEnabled(SysGroup group) {
return group.getStatus() == null || group.getStatus() != GROUP_STATUS_DISABLED;
}
// ===================== CUSTOM 目录装配 =====================
private List<DepartmentEntryVo> customDepartments(Integer schoolId, Integer categoryId, boolean onlySelectable) {
Map<Integer, String> categoryNames = new LinkedHashMap<>();
for (UbCustomDeptCategory category :
categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED)) {
if (categoryId != null && !categoryId.equals(category.getId())) {
continue;
}
categoryNames.put(category.getId(), category.getName());
}
if (categoryNames.isEmpty()) {
if (categoryId != null) {
throw new DException("分类不存在或不属于该校");
}
return Collections.emptyList();
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (Integer liveCategoryId : categoryNames.keySet()) {
List<UbCustomDepartment> rows =
departmentRepository.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(liveCategoryId, NOT_DELETED);
Map<Integer, Long> memberCounts = countMembers(rows);
for (UbCustomDepartment row : rows) {
boolean enabled = row.isEnabledRow();
if (onlySelectable && !enabled) {
continue;
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(row.getSchoolId());
entry.setDepartmentType(DepartmentTypeEnum.CUSTOM.getKey());
entry.setDepartmentId(row.getId());
entry.setName(row.getName());
entry.setCategoryId(row.getCategoryId());
entry.setCategoryName(categoryNames.get(row.getCategoryId()));
entry.setEnabled(enabled);
entry.setDeleted(false);
entry.setSelectable(enabled);
entry.setMemberCount(memberCounts.getOrDefault(row.getId(), 0L));
result.add(entry);
}
}
return result;
}
private Map<Integer, Long> countMembers(List<UbCustomDepartment> rows) {
List<Integer> ids = new ArrayList<>();
for (UbCustomDepartment row : rows) {
ids.add(row.getId());
}
Map<Integer, Long> counts = new HashMap<>();
if (ids.isEmpty()) {
return counts;
}
for (UbCustomDepartmentMember member : memberRepository.findByDepartmentIdIn(ids)) {
counts.merge(member.getDepartmentId(), 1L, Long::sum);
}
return counts;
}
// ===================== 授权与锚点 =====================
/**
* 应用 × 学校可见性:签名过滤器只证明「是哪个应用」,能不能读这所学校由开通关系决定(A15)。
*/
private void assertAppCanReadSchool(Integer appId, Integer schoolId) {
if (appId == null) {
throw new DException("无法识别调用方应用,禁止读取部门目录");
}
if (schoolId == null) {
throw new DException("schoolId 不能为空");
}
SysGroup school = sysGroupRepository.findById(schoolId).orElse(null);
if (school == null || school.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("学校不存在或不是学校组");
}
if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(schoolId, appId)) {
throw new DException("该应用未在本校开通,无权读取该校部门目录");
}
}
/** 沿 parentId 上溯最近的学校组(CUSTOM 无此需求,只给 SYSTEM 组用);带 visited 防环。 */
private Integer schoolOfGroup(SysGroup group) {
Map<Integer, SysGroup> byId = new HashMap<>();
for (SysGroup each : sysGroupRepository.findAll()) {
byId.put(each.getId(), each);
}
Set<Integer> visited = new HashSet<>();
SysGroup cursor = group;
while (cursor != null && visited.add(cursor.getId())) {
if (cursor.getType() == GroupTypeEnum.SCHOOL_GROUP) {
return cursor.getId();
}
Integer parentId = cursor.getParentId();
if (parentId == null || parentId == 0) {
return null;
}
cursor = byId.get(parentId);
}
return null;
}
}
@@ -63,6 +63,13 @@ public class SysGroupService implements ISysGroupService {
/** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */
private static final int SYSTEM_USER_ID = 0;
/**
* 部门表格「人员」列的姓名预览上限。<br>
* 表格最多显示 3 个名字 + 计数,tooltip 展示这份预览;部门上百人时不把这些名字塞进 getTree 响应,
* 完整名单由前端点「查看全部」走 /api/sysuser/List?groupId=xxx 分页取。
*/
static final int MEMBER_PREVIEW_LIMIT = 50;
/**
* 层级类型约束:父类型 -> 允许直接创建的子类型集合。
* key 为 {@code null} 表示「根节点(教育云中心)」。
@@ -249,6 +256,30 @@ public class SysGroupService implements ISysGroupService {
eventPublisher.publishEvent(new GroupChangedEvent(this, "changeStatus"));
}
/**
* 「包含子组」翻转(2026-10-21 部门线):严格单列 UPDATE,范式与 {@link #changeStatus} 完全一致。<br>
* 与 changeStatus 的唯一差别:本属性只在**学校组**节点上有意义(对齐 SysGroup 字段注释「仅学校节点使用」),
* 故在此强制校验类型,非学校组直接拒绝——前端只在学校行放开关,服务端不能只靠它。<br>
* 必须发 {@link GroupChangedEvent}:GetTree / oapi 组树都吃 groupSnapshot 缓存,
* 不发则页面读到的 includeChildren 最多滞后 30min。
*/
@Override
@Transactional
public void changeIncludeChildren(Integer id, boolean include) {
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("系统组不存在");
}
eventPublisher.publishEvent(new GroupChangedEvent(this, "changeIncludeChildren"));
}
@Override
@Transactional
public void delete(Integer id, Integer schoolId) {
@@ -339,7 +370,9 @@ public class SysGroupService implements ISysGroupService {
Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap();
// 从根(pid=0)开始组装树
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
return roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList());
List<SysGroupVo> tree = roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList());
fillMemberStats(tree);
return tree;
}
@Override
@@ -351,7 +384,83 @@ public class SysGroupService implements ISysGroupService {
if (scope == null || scope.isSuper()) {
return getTree();
}
return buildTrimmedTree(scope, snapshot.getAll(), false);
List<SysGroupVo> tree = buildTrimmedTree(scope, snapshot.getAll(), false);
fillMemberStats(tree);
return tree;
}
/**
* 给树节点补直属成员数与前 N 个姓名预览(部门表格「人员」列)。<br>
* 口径与 oapi 部门目录一致:sys_user_group_rel 按 groupId 计数,不排除转出;
* 整棵树只发两次查询(关联表 in + 用户表 in),不受节点数影响。
*/
private void fillMemberStats(List<SysGroupVo> tree) {
List<Integer> groupIds = new ArrayList<>();
collectGroupIds(tree, groupIds);
if (groupIds.isEmpty()) {
return;
}
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByGroupIdIn(groupIds);
if (rels == null || rels.isEmpty()) {
return;
}
Map<Integer, List<Integer>> relByGroup = new HashMap<>();
Set<Integer> userIds = new HashSet<>();
for (SysUserGroupRel rel : rels) {
if (rel.getGroupId() == null || rel.getUserId() == null) {
continue;
}
relByGroup.computeIfAbsent(rel.getGroupId(), k -> new ArrayList<>()).add(rel.getUserId());
userIds.add(rel.getUserId());
}
Map<Integer, String> nameById = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
nameById.put(user.getId(),
StringUtils.hasText(user.getRealName()) ? user.getRealName() : user.getAccount());
}
Map<Integer, SysGroupVo> voById = new HashMap<>();
collectVoById(tree, voById);
for (Map.Entry<Integer, List<Integer>> entry : relByGroup.entrySet()) {
SysGroupVo vo = voById.get(entry.getKey());
if (vo == null) {
continue;
}
List<Integer> members = entry.getValue();
vo.setMemberCount((long) members.size());
List<String> preview = new ArrayList<>();
for (Integer uid : members) {
if (preview.size() >= MEMBER_PREVIEW_LIMIT) {
break;
}
String name = nameById.get(uid);
if (name != null) {
preview.add(name);
}
}
vo.setMemberNames(preview);
}
}
private void collectGroupIds(List<SysGroupVo> nodes, List<Integer> out) {
for (SysGroupVo vo : nodes) {
if (vo.getId() != null) {
out.add(vo.getId());
}
if (vo.getChildren() != null) {
collectGroupIds(vo.getChildren(), out);
}
}
}
private void collectVoById(List<SysGroupVo> nodes, Map<Integer, SysGroupVo> out) {
for (SysGroupVo vo : nodes) {
if (vo.getId() != null) {
out.put(vo.getId(), vo);
}
if (vo.getChildren() != null) {
collectVoById(vo.getChildren(), out);
}
}
}
@Override
@@ -0,0 +1,467 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.ICustomDepartmentService;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理实现(CUSTOM 侧权威写口)。<br>
* 权限:schoolId 全部由 {@link AccessScopeService} 从登录身份解析,请求体只作核对;
* 每个写口先定位对象、再校验其所属学校落在当前范围内(跨校一律拒绝,A09)。<br>
* 成员范围(D4):本校 + 教职工,排除学生组(type=8)子树下的用户。<br>
* 本期 CUSTOM 目录不加缓存(任务书 §0.2),写口不发驱逐事件;如后续加缓存须全部写口 100% 驱逐。
*/
@Service
public class UbCustomDepartmentService implements ICustomDepartmentService {
private static final int NOT_DELETED = 0;
/** 候选教职工一次最多返回多少条(人多时靠关键字缩小范围)。 */
private static final int CANDIDATE_LIMIT = 200;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentMemberRepository memberRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private ISysGroupService sysGroupService;
@Autowired
private AccessScopeService accessScopeService;
// ===================== 列表 =====================
@Override
public List<CustomDepartmentVo> list(Integer schoolId, Integer categoryId) {
Integer scoped = accessScopeService.resolveWritableSchoolId(schoolId);
UbCustomDeptCategory category = requireLiveCategory(categoryId);
if (!scoped.equals(category.getSchoolId())) {
throw new DException("分类不属于该学校范围");
}
List<UbCustomDepartment> rows = departmentRepository
.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(category.getId(), NOT_DELETED);
if (rows.isEmpty()) {
return Collections.emptyList();
}
Map<Integer, Long> memberCounts = countMembersByDepartment(rows);
List<CustomDepartmentVo> result = new ArrayList<>();
for (UbCustomDepartment row : rows) {
CustomDepartmentVo vo = toVo(row, category.getName());
vo.setMemberCount(memberCounts.getOrDefault(row.getId(), 0L));
result.add(vo);
}
return result;
}
// ===================== 部门增删改 =====================
@Override
@Transactional
public CustomDepartmentVo add(CustomDepartmentRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
UbCustomDeptCategory category = requireLiveCategory(request.getCategoryId());
if (!schoolId.equals(category.getSchoolId())) {
throw new DException("分类不属于该学校范围");
}
String name = requireName(request.getName());
requireNameFree(schoolId, category.getId(), name, null);
UbCustomDepartment entity = new UbCustomDepartment();
entity.setSchoolId(schoolId);
entity.setCategoryId(category.getId());
entity.setName(name);
Integer maxSort = departmentRepository.findMaxSortNoByCategory(category.getId());
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
// 新建默认启用(规格书 §2.2)
entity.setEnabled(1);
entity.setIsDeleted(NOT_DELETED);
entity.setDelMarker(0L);
departmentRepository.save(entity);
return toVo(entity, category.getName());
}
@Override
@Transactional
public CustomDepartmentVo rename(Integer id, CustomDepartmentRequest request) {
UbCustomDepartment entity = requireEditableDepartment(id);
String name = requireName(request.getName());
if (name.equals(entity.getName())) {
return toVo(entity, categoryName(entity.getCategoryId()));
}
requireNameFree(entity.getSchoolId(), entity.getCategoryId(), name, entity.getId());
entity.setName(name);
departmentRepository.save(entity);
return toVo(entity, categoryName(entity.getCategoryId()));
}
@Override
@Transactional
public CustomDepartmentVo move(Integer id, CustomDepartmentMoveRequest request) {
UbCustomDepartment entity = requireEditableDepartment(id);
UbCustomDeptCategory target = requireLiveCategory(request.getCategoryId());
// 只能移到本校其他分类:跨校目标分类直接拒绝(不按「范围里有就算」放宽)
if (!entity.getSchoolId().equals(target.getSchoolId())) {
throw new DException("只能移动到本校其他分类");
}
accessScopeService.requireSchoolInScope(target.getSchoolId());
if (target.getId().equals(entity.getCategoryId())) {
return toVo(entity, target.getName());
}
requireNameFree(entity.getSchoolId(), target.getId(), entity.getName(), entity.getId());
entity.setCategoryId(target.getId());
Integer maxSort = departmentRepository.findMaxSortNoByCategory(target.getId());
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
departmentRepository.save(entity);
return toVo(entity, target.getName());
}
@Override
@Transactional
public void sort(CustomDepartmentSortRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
List<Integer> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new DException("部门ID列表不能为空");
}
if (new HashSet<>(ids).size() != ids.size()) {
throw new DException("部门ID列表存在重复");
}
List<UbCustomDepartment> rows = new ArrayList<>();
Map<Integer, UbCustomDepartment> byId = new HashMap<>();
for (Integer id : ids) {
UbCustomDepartment row = (id == null) ? null : departmentRepository.findById(id).orElse(null);
if (row == null || row.isDeletedRow() || !schoolId.equals(row.getSchoolId())) {
throw new DException("部门不存在或不属于本校");
}
if (request.getCategoryId() != null && !request.getCategoryId().equals(row.getCategoryId())) {
throw new DException("部门不属于该分类");
}
rows.add(row);
byId.put(row.getId(), row);
}
for (int i = 0; i < ids.size(); i++) {
byId.get(ids.get(i)).setSortNo(i + 1);
}
departmentRepository.saveAll(rows);
}
/**
* 启停翻转(范式同 {@code SysGroupService.changeStatus}):只走 {@code updateEnabled} 单列 UPDATE,
* 不动成员、不动软删列、不发事件(本期 CUSTOM 无缓存)。<br>
* ⚠ enabled 语义 1=启用/0=停用,与 sys_group.status 方向相反。
*/
@Override
@Transactional
public void changeEnabled(Integer id, boolean enabled) {
requireEditableDepartment(id);
int updated = departmentRepository.updateEnabled(id, enabled ? 1 : 0);
if (updated == 0) {
throw new DException("自定义部门不存在");
}
}
@Override
@Transactional
public void softDelete(Integer id) {
UbCustomDepartment entity = requireEditableDepartment(id);
entity.setIsDeleted(1);
// del_marker=自身 id:让出 uk(school_id, category_id, name, del_marker) 的有效位,
// 之后同名新部门走新 id,旧引用不会指向新对象(A13);成员关系保留在库由查询侧过滤(A12)
entity.setDelMarker(entity.getId() == null ? 0L : entity.getId().longValue());
entity.setDeletedAt(LocalDateTime.now());
entity.setDeletedBy(UserContext.getCurrentUser());
departmentRepository.save(entity);
}
// ===================== 成员 =====================
@Override
public List<CustomDepartmentMemberVo> members(Integer id) {
UbCustomDepartment department = requireEditableDepartment(id);
List<UbCustomDepartmentMember> rows = memberRepository.findByDepartmentId(department.getId());
if (rows.isEmpty()) {
return Collections.emptyList();
}
List<Integer> userIds = new ArrayList<>();
for (UbCustomDepartmentMember row : rows) {
userIds.add(row.getUserId());
}
Map<Integer, SysUser> userMap = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
userMap.put(user.getId(), user);
}
List<CustomDepartmentMemberVo> result = new ArrayList<>();
for (Integer userId : userIds) {
SysUser user = userMap.get(userId);
// 账号已被删除的孤儿关系行不出现在成员列表(uk 仍占位,重加同 id 时按幂等跳过)
if (user == null) {
continue;
}
result.add(toMemberVo(user));
}
return result;
}
@Override
@Transactional
public void addMembers(Integer id, CustomDepartmentMemberRequest request) {
UbCustomDepartment department = requireEditableDepartment(id);
List<Integer> distinct = distinctUserIds(request.getUserIds());
Set<Integer> teacherUserIds = schoolTeacherUserIds(department.getSchoolId());
Set<Integer> existing = existingMemberUserIds(department.getId());
List<UbCustomDepartmentMember> toSave = new ArrayList<>();
for (Integer userId : distinct) {
if (!teacherUserIds.contains(userId)) {
// D4:非本校或未在教职工范围内(学生组成员)——逐个复核,任一不合格整批拒绝
throw new DException("用户「" + userLabel(userId) + "」不是本校教职工,不能加入自定义部门");
}
if (existing.contains(userId)) {
// 重复添加按幂等跳过(A08),不产生第二条有效成员关系
continue;
}
UbCustomDepartmentMember member = new UbCustomDepartmentMember();
member.setSchoolId(department.getSchoolId());
member.setDepartmentId(department.getId());
member.setUserId(userId);
toSave.add(member);
}
if (!toSave.isEmpty()) {
memberRepository.saveAll(toSave);
}
}
@Override
@Transactional
public void removeMembers(Integer id, CustomDepartmentMemberRequest request) {
UbCustomDepartment department = requireEditableDepartment(id);
List<Integer> distinct = distinctUserIds(request.getUserIds());
memberRepository.deleteByDepartmentIdAndUserIds(department.getId(), distinct);
}
@Override
public List<CustomDepartmentMemberVo> candidateUsers(Integer id, String keyword) {
UbCustomDepartment department = requireEditableDepartment(id);
Set<Integer> teacherUserIds = schoolTeacherUserIds(department.getSchoolId());
Set<Integer> existing = existingMemberUserIds(department.getId());
List<Integer> candidateIds = new ArrayList<>(teacherUserIds);
candidateIds.removeAll(existing);
if (candidateIds.isEmpty()) {
return Collections.emptyList();
}
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
List<CustomDepartmentMemberVo> result = new ArrayList<>();
for (SysUser user : sysUserRepository.findAllById(candidateIds)) {
if (!kw.isEmpty() && !matches(user, kw)) {
continue;
}
result.add(toMemberVo(user));
}
// 上限在关键字过滤之后截断(先截后滤会把命中的藏在后面);超限由前端提示继续输入关键字缩小范围
if (result.size() > CANDIDATE_LIMIT) {
return new ArrayList<>(result.subList(0, CANDIDATE_LIMIT));
}
return result;
}
private boolean matches(SysUser user, String kw) {
return (user.getAccount() != null && user.getAccount().toLowerCase().contains(kw))
|| (user.getRealName() != null && user.getRealName().toLowerCase().contains(kw));
}
// ===================== 校验与私有工具 =====================
/** 定位可操作的<b>有效</b>部门:软删行不参与常规写口(历史解析走 T2 的 resolve-batch)。 */
private UbCustomDepartment requireEditableDepartment(Integer id) {
UbCustomDepartment entity = (id == null) ? null : departmentRepository.findById(id).orElse(null);
if (entity == null || entity.isDeletedRow()) {
throw new DException("自定义部门不存在");
}
// 校维度授权以「对象所属学校」为准:范围外(含跨校)直接拒绝
accessScopeService.requireSchoolInScope(entity.getSchoolId());
return entity;
}
private UbCustomDeptCategory requireLiveCategory(Integer categoryId) {
UbCustomDeptCategory category = (categoryId == null) ? null
: categoryRepository.findById(categoryId).orElse(null);
if (category == null || category.isDeletedRow()) {
throw new DException("分类不存在");
}
accessScopeService.requireSchoolInScope(category.getSchoolId());
return category;
}
private String categoryName(Integer categoryId) {
return categoryRepository.findById(categoryId).map(UbCustomDeptCategory::getName).orElse(null);
}
/** 同校同分类有效部门重名前置校验(uk 兜底,这里给友好报错);跨分类与 SYSTEM/CUSTOM 之间允许重名(A06)。 */
private void requireNameFree(Integer schoolId, Integer categoryId, String name, Integer selfId) {
departmentRepository
.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(schoolId, categoryId, name, NOT_DELETED)
.ifPresent(hit -> {
if (!hit.getId().equals(selfId)) {
throw new DException("该分类下已存在同名部门「" + name + "」");
}
});
}
private List<Integer> distinctUserIds(List<Integer> userIds) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("成员列表不能为空");
}
Set<Integer> distinct = new LinkedHashSet<>(userIds);
distinct.remove(null);
if (distinct.isEmpty()) {
throw new DException("成员列表不能为空");
}
return new ArrayList<>(distinct);
}
private Set<Integer> existingMemberUserIds(Integer departmentId) {
Set<Integer> existing = new HashSet<>();
for (UbCustomDepartmentMember row : memberRepository.findByDepartmentId(departmentId)) {
existing.add(row.getUserId());
}
return existing;
}
/**
* 本校教职工用户集合(D4):落在学校子树内、且<b>不属于</b>该校学生组(type=8)子树的正常账号。<br>
* 判定只依据 {@code sys_user_group_rel} + {@code sys_group.type},与「按组查用户」既有范式一致
* ({@code findByGroupIdIn} 后按 userId 去重)。
*/
private Set<Integer> schoolTeacherUserIds(Integer schoolId) {
List<Integer> schoolGroupIds = sysGroupService.listSelfAndDescendantIds(schoolId);
if (schoolGroupIds.isEmpty()) {
return Collections.emptySet();
}
Set<Integer> studentGroupIds = new LinkedHashSet<>();
for (SysGroup group : sysGroupRepository.findAllById(schoolGroupIds)) {
if (group.getType() == GroupTypeEnum.STUDENT_GROUP) {
studentGroupIds.addAll(sysGroupService.listSelfAndDescendantIds(group.getId()));
}
}
Set<Integer> excluded = new HashSet<>();
if (!studentGroupIds.isEmpty()) {
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByGroupIdIn(new ArrayList<>(studentGroupIds))) {
excluded.add(rel.getUserId());
}
}
Set<Integer> candidates = new HashSet<>();
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByGroupIdIn(schoolGroupIds)) {
if (!excluded.contains(rel.getUserId())) {
candidates.add(rel.getUserId());
}
}
// 有效账号:禁用用户不能作为新成员(LinkedHashSet 保证顺序稳定,便于逐 id 报错定位)
Set<Integer> valid = new LinkedHashSet<>();
for (SysUser user : sysUserRepository.findAllById(new ArrayList<>(candidates))) {
if (user.getDisabled() != StatusEnum.DISABLED) {
valid.add(user.getId());
}
}
return valid;
}
private String userLabel(Integer userId) {
SysUser user = sysUserRepository.findById(userId).orElse(null);
if (user == null) {
return String.valueOf(userId);
}
return user.getRealName() == null ? user.getAccount() : user.getRealName();
}
private Map<Integer, Long> countMembersByDepartment(List<UbCustomDepartment> rows) {
List<Integer> ids = new ArrayList<>();
for (UbCustomDepartment row : rows) {
ids.add(row.getId());
}
Map<Integer, Long> counts = new HashMap<>();
for (UbCustomDepartmentMember member : memberRepository.findByDepartmentIdIn(ids)) {
counts.merge(member.getDepartmentId(), 1L, Long::sum);
}
return counts;
}
private String requireName(String rawName) {
String name = rawName == null ? "" : rawName.trim();
if (name.isEmpty()) {
throw new DException("部门名称不能为空");
}
if (name.length() > 64) {
throw new DException("部门名称不能超过 64 个字符");
}
return name;
}
private CustomDepartmentVo toVo(UbCustomDepartment entity, String categoryName) {
CustomDepartmentVo vo = new CustomDepartmentVo();
vo.setId(entity.getId());
vo.setSchoolId(entity.getSchoolId());
vo.setCategoryId(entity.getCategoryId());
vo.setCategoryName(categoryName);
vo.setName(entity.getName());
vo.setSortNo(entity.getSortNo());
vo.setEnabled(entity.isEnabledRow());
return vo;
}
private CustomDepartmentMemberVo toMemberVo(SysUser user) {
CustomDepartmentMemberVo vo = new CustomDepartmentMemberVo();
vo.setUserId(user.getId());
vo.setAccount(user.getAccount());
vo.setName(user.getRealName());
return vo;
}
}
@@ -0,0 +1,185 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.ICustomDeptCategoryService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理实现。<br>
* 三条铁律:① schoolId 一律由服务端从当前身份范围解析,请求体只作核对(T1.3);
* ② 软删用 is_deleted + del_marker(软删时置为行 id),name 永不改写;
* ③ 本期 CUSTOM 目录不加缓存(任务书 §0.2),故写口不发缓存驱逐事件——若后续接入缓存,
* 必须注册 CacheNames 并保证<b>全部</b>写口发事件驱逐。
*/
@Service
public class UbCustomDeptCategoryService implements ICustomDeptCategoryService {
private static final int NOT_DELETED = 0;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private AccessScopeService accessScopeService;
@Override
public List<CustomDeptCategoryVo> list(Integer schoolId) {
Integer scoped = accessScopeService.resolveWritableSchoolId(schoolId);
List<CustomDeptCategoryVo> result = new ArrayList<>();
for (UbCustomDeptCategory category : requireSchoolCategories(scoped)) {
result.add(toVo(category));
}
return result;
}
@Override
@Transactional
public CustomDeptCategoryVo add(CustomDeptCategoryRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
String name = requireName(request.getName());
if (categoryRepository.existsBySchoolIdAndNameAndIsDeleted(schoolId, name, NOT_DELETED)) {
throw new DException("本校已存在同名分类「" + name + "」");
}
UbCustomDeptCategory entity = new UbCustomDeptCategory();
entity.setSchoolId(schoolId);
entity.setName(name);
Integer maxSort = categoryRepository.findMaxSortNoBySchool(schoolId);
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
entity.setIsDeleted(NOT_DELETED);
entity.setDelMarker(0L);
categoryRepository.save(entity);
return toVo(entity);
}
@Override
@Transactional
public CustomDeptCategoryVo rename(Integer id, CustomDeptCategoryRequest request) {
UbCustomDeptCategory entity = requireEditableCategory(id);
String name = requireName(request.getName());
if (name.equals(entity.getName())) {
return toVo(entity);
}
categoryRepository.findFirstBySchoolIdAndNameAndIsDeleted(entity.getSchoolId(), name, NOT_DELETED)
.ifPresent(hit -> {
if (!hit.getId().equals(entity.getId())) {
throw new DException("本校已存在同名分类「" + name + "」");
}
});
entity.setName(name);
categoryRepository.save(entity);
return toVo(entity);
}
@Override
@Transactional
public void sort(CustomDeptCategorySortRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
List<Integer> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new DException("分类ID列表不能为空");
}
if (new HashSet<>(ids).size() != ids.size()) {
throw new DException("分类ID列表存在重复");
}
List<UbCustomDeptCategory> categories = requireSchoolCategories(schoolId);
Set<Integer> scopeIds = new HashSet<>();
for (UbCustomDeptCategory category : categories) {
scopeIds.add(category.getId());
}
for (Integer id : ids) {
if (!scopeIds.contains(id)) {
throw new DException("分类不存在或不属于本校");
}
}
// 按传入顺序落库(1 起),范式同 SysGroupService.sortChildren
Map<Integer, UbCustomDeptCategory> byId = new HashMap<>();
for (UbCustomDeptCategory category : categories) {
byId.put(category.getId(), category);
}
for (int i = 0; i < ids.size(); i++) {
byId.get(ids.get(i)).setSortNo(i + 1);
}
categoryRepository.saveAll(categories);
}
@Override
@Transactional
public void softDelete(Integer id) {
UbCustomDeptCategory entity = requireEditableCategory(id);
long live = departmentRepository.countByCategoryIdAndIsDeleted(entity.getId(), NOT_DELETED);
if (live > 0) {
// A14:明确拒绝,禁止任何级联删除
throw new DException("该分类下仍有 " + live + " 个部门,请先移动或删除这些部门");
}
markDeleted(entity);
}
// ===================== 私有 =====================
/** 定位可操作的<b>有效</b>分类并做校维度授权(跨校直接拒绝,A09)。 */
private UbCustomDeptCategory requireEditableCategory(Integer id) {
UbCustomDeptCategory entity = (id == null) ? null : categoryRepository.findById(id).orElse(null);
if (entity == null || entity.isDeletedRow()) {
throw new DException("分类不存在");
}
accessScopeService.requireSchoolInScope(entity.getSchoolId());
return entity;
}
private List<UbCustomDeptCategory> requireSchoolCategories(Integer schoolId) {
return categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED);
}
private void markDeleted(UbCustomDeptCategory entity) {
entity.setIsDeleted(1);
// del_marker=自身 id:让出 uk(school_id, name, del_marker) 的有效位,同名新分类可另起新 id(A13)
entity.setDelMarker(entity.getId() == null ? 0L : entity.getId().longValue());
entity.setDeletedAt(LocalDateTime.now());
entity.setDeletedBy(UserContext.getCurrentUser());
categoryRepository.save(entity);
}
private String requireName(String rawName) {
String name = rawName == null ? "" : rawName.trim();
if (name.isEmpty()) {
throw new DException("分类名称不能为空");
}
if (name.length() > 64) {
throw new DException("分类名称不能超过 64 个字符");
}
return name;
}
private CustomDeptCategoryVo toVo(UbCustomDeptCategory entity) {
CustomDeptCategoryVo vo = new CustomDeptCategoryVo();
vo.setId(entity.getId());
vo.setSchoolId(entity.getSchoolId());
vo.setName(entity.getName());
vo.setSortNo(entity.getSortNo());
vo.setDepartmentCount(departmentRepository.countByCategoryIdAndIsDeleted(entity.getId(), NOT_DELETED));
return vo;
}
}
@@ -363,6 +363,55 @@ public class AccessScopeService {
}
}
/**
* 学校维度 guard(2026-10-21 CUSTOM 部门线):schoolId 必须存在、类型为学校组,且落在当前用户数据范围内。<br>
* 现有 guard 都是「组」维度,而 CUSTOM 分类/部门只以学校这一层作组织锚点,故单独成校维度判定;
* 范围判定复用 {@link #scopeGroupIds()},与其同域(学校组 id 即范围根)。
*/
public void requireSchoolInScope(Integer schoolId) {
if (schoolId == null) {
throw new DException("学校ID不能为空");
}
SysGroup school = sysGroupRepository.findById(schoolId).orElse(null);
if (school == null) {
throw new DException("学校分组不存在");
}
if (school.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("学校ID必须为学校分组");
}
requireGroupInScope(schoolId);
}
/**
* 解析写口的生效学校 id —— CUSTOM 侧「schoolId 取自当前身份范围,请求体只作核对不作授权」的唯一入口。<br>
* 分级语义:SCHOOL 只能落在校级可管理学校集合内(多校管理员须显式指定且必须命中其一,未指定则要求补齐);
* SUPER 不限制、BUREAU 限区域内学校,二者均以请求值为准并由 {@link #requireSchoolInScope} 兜范围。
*
* @param requestedSchoolId 前端传来的学校 id(可为 null:单校管理员直接取本校)
* @return 服务端认定的权威学校 id
*/
public Integer resolveWritableSchoolId(Integer requestedSchoolId) {
AccessScope scope = resolveCurrentScope();
if (scope.isNone()) {
throw new DException("暂无权限");
}
if (scope.getLevel() == AccessLevelEnum.SCHOOL) {
List<Integer> mine = scope.schoolGroupIds();
if (requestedSchoolId != null) {
if (!mine.contains(requestedSchoolId)) {
throw new DException("暂无权限访问该组织");
}
return requestedSchoolId;
}
if (mine.size() != 1) {
throw new DException("请选择要管理的学校");
}
return mine.get(0);
}
requireSchoolInScope(requestedSchoolId);
return requestedSchoolId;
}
/**
* 目标用户的归属组必须与当前用户数据范围有交集(SUPER 不限制)。<br>
* 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。
@@ -0,0 +1,14 @@
-- app_module.custom_id 从 NOT NULL 放开为可空:子模块双定位(树节点 / 按钮权限)。
-- 背景:子模块设计上既可作树节点(有路由路径+业务编码),也可作按钮权限(两者皆无)。
-- 路径 path 本就可空;custom_id 原为 NOT NULL + 应用内唯一键,挡死了按钮权限型子模块。
-- 关键:必须存 NULL 不能存 ''——'' 在唯一键 uk_app_module_custom(app_id, custom_id) 下
-- 每个应用只能有一条,第二个按钮型子模块即撞键;NULL 在 MySQL 唯一索引中可多行并存。
-- 执行方式:dev / prod 手工执行(ddl-auto=update 只加列不改既有列属性,不会自动放 nullable)。
-- 幂等:可重复执行,重复 MODIFY 到相同定义是无害 no-op。
ALTER TABLE app_module MODIFY COLUMN custom_id VARCHAR(64) NULL COMMENT '自定义ID(应用内唯一,业务编码);null=按钮权限型子模块无编码';
-- 核对:custom_id 的 IS_NULLABLE 应为 YES
-- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'app_module' AND COLUMN_NAME = 'custom_id';
@@ -0,0 +1,108 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.AppModule;
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
import com.yunhai.userbase.repository.AppModuleRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 按钮权限型子模块(子模块双定位:树节点/按钮权限)——customId/path 可空。<br>
* 覆盖:空白 customId/path 归一为 null;空白 customId 跳过应用内唯一校验;
* 有值 customId 仍查重;编辑清空编码生效。背景见 sql/app_module_custom_id_nullable.sql
* ('' 在 uk_app_module_custom 下每应用仅一条,按钮型必须存 NULL)。
*/
class AppModuleServiceButtonModuleTest {
@InjectMocks
private AppModuleService appModuleService;
@Mock
private AppModuleRepository appModuleRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private SysGroupModulePermRepository sysGroupModulePermRepository;
@Mock
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
private AppModuleRequest request(String name, String customId, String path) {
AppModuleRequest request = new AppModuleRequest();
request.setAppId(10);
request.setName(name);
request.setCustomId(customId);
request.setPath(path);
return request;
}
@Test
void add_buttonModule_blankCustomIdAndPath_normalizedToNull() {
when(applicationInfoRepository.existsById(10)).thenReturn(true);
appModuleService.add(request("删除按钮", " ", ""));
ArgumentCaptor<AppModule> captor = ArgumentCaptor.forClass(AppModule.class);
verify(appModuleRepository).save(captor.capture());
assertNull(captor.getValue().getCustomId());
assertNull(captor.getValue().getPath());
// 空白编码不进唯一性查询('' 会撞 uk_app_module_custom,必须以 NULL 落库)
verify(appModuleRepository, never()).existsByAppIdAndCustomId(anyInt(), anyString());
}
@Test
void add_withCustomId_stillCheckedForUniqueness() {
when(applicationInfoRepository.existsById(10)).thenReturn(true);
when(appModuleRepository.existsByAppIdAndCustomId(10, "btn-save")).thenReturn(true);
DException ex = assertThrows(DException.class,
() -> appModuleService.add(request("保存按钮", "btn-save", null)));
assertEquals("自定义ID在该应用下已存在", ex.getMessage());
verify(appModuleRepository, never()).save(any(AppModule.class));
}
@Test
void edit_clearCustomIdAndPath_setsNullWithoutUniquenessQuery() {
AppModule entity = new AppModule();
entity.setId(1);
entity.setAppId(10);
entity.setName("编辑按钮");
entity.setCustomId("btn-edit");
entity.setPath("/app/edit");
when(appModuleRepository.findById(1)).thenReturn(Optional.of(entity));
appModuleService.edit(1, request("编辑按钮", "", " "));
assertNull(entity.getCustomId());
assertNull(entity.getPath());
verify(appModuleRepository, never()).existsByAppIdAndCustomIdAndIdNot(anyInt(), anyString(), anyInt());
verify(appModuleRepository).save(entity);
}
}
@@ -0,0 +1,84 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.platform.utils.exception.DException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ApplicationInfoAppCodeTest {
private ApplicationInfoService service;
private ApplicationInfoRepository repository;
private ApplicationInfo entity;
@BeforeEach
void setUp() {
service = new ApplicationInfoService();
repository = mock(ApplicationInfoRepository.class);
ReflectionTestUtils.setField(service, "applicationInfoRepository", repository);
ReflectionTestUtils.setField(service, "eventPublisher", mock(ApplicationEventPublisher.class));
entity = new ApplicationInfo();
entity.setId(1); entity.setAppCode("svc-hall");
when(repository.findById(1)).thenReturn(Optional.of(entity));
when(repository.save(any(ApplicationInfo.class))).thenAnswer(invocation -> invocation.getArgument(0));
}
private ApplicationInfoRequest request(String code) {
ApplicationInfoRequest r = new ApplicationInfoRequest();
r.setName("display-name"); r.setAppCode(code);
return r;
}
@Test
void editRejectsRenameBeforeMutatingEntity() {
for (String code : new String[]{"new-name", "SVC-HALL", "svc-hall "}) {
assertThrows(DException.class, () -> service.edit(1, request(code)));
}
assertEquals("svc-hall", entity.getAppCode());
verify(repository, never()).save(any());
}
@Test
void sameCodeOrOmittedCodeCanEditOtherFields() {
for (String code : new String[]{"svc-hall", null, "", " "}) {
service.edit(1, request(code));
assertEquals("svc-hall", entity.getAppCode());
assertEquals("display-name", entity.getName());
}
}
@Test
void unregisteredCodeMayBeFilledButUniquenessStillApplies() {
entity.setAppCode(null);
when(repository.existsByAppCodeAndIdNot("taken", 1)).thenReturn(true);
assertThrows(DException.class, () -> service.edit(1, request("taken")));
service.edit(1, request("svc-hall"));
assertEquals("svc-hall", entity.getAppCode());
}
@Test
void genericSaveCannotRenameOrClearPersistedCode() {
when(repository.findPersistedAppCodeById(1)).thenReturn("svc-hall");
for (String code : new String[]{"new-name", null, ""}) {
entity.setAppCode(code);
assertThrows(DException.class, () -> service.save(entity));
}
entity.setAppCode("svc-hall");
assertSame(entity, service.save(entity));
}
@Test
void genericSaveAllowsInitialRegistration() {
when(repository.findPersistedAppCodeById(1)).thenReturn(null);
assertSame(entity, service.save(entity));
entity.setId(null);
assertSame(entity, service.save(entity));
}
}
@@ -0,0 +1,250 @@
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.SysUser;
import com.yunhai.userbase.entity.SysUserChannelRel;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.repository.SysUserChannelRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Arrays;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 移动端渠道绑定服务单测(步 3 门禁):绑定唯一约束(uk_channel_openid / uk_user_channel 对应的
* 应用层前置校验)+ unionid 应用层查重(uk_user_channel 只约束半边,方案 §3.13 ⚠)。
* 纯 Mockito,不依赖数据库。
*/
class ChannelBindServiceTest {
@Mock
private SysUserChannelRelRepository relRepository;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private UserCenterService userCenterService;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private ChannelBindService service;
private SysUser user;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
user = new SysUser();
user.setId(1001);
user.setAccount("s1001");
user.setPassword("$bcrypt$");
user.setDisabled(StatusEnum.NORMAL);
when(passwordEncoder.matches(anyString(), anyString())).thenAnswer(inv -> {
Object[] args = inv.getArguments();
return "right-pwd".equals(args[0]) && "$bcrypt$".equals(args[1]);
});
}
private BoaUser boa(int id, String account) {
return new BoaUser(id, account, "用户" + id);
}
// ==================== findBoundUser ====================
@Test
void findBoundUser_unbound_returnsNull() {
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
assertNull(service.findBoundUser("wxgzh", "o-1"));
}
@Test
void findBoundUser_bound_returnsBoaUser() {
SysUserChannelRel rel = new SysUserChannelRel();
rel.setUserId(1001);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.of(rel));
when(userCenterService.getUserInfoWithDeptById(1001)).thenReturn(boa(1001, "s1001"));
BoaUser result = service.findBoundUser("wxgzh", "o-1");
assertNotNull(result);
assertEquals(1001, result.getId());
}
// ==================== bindByCredential:密码与账号 ====================
@Test
void bindByCredential_wrongPassword_rejectedWithoutSaving() {
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "wrong-pwd"));
assertTrue(ex.getMessage().contains("账号或密码错误"));
verify(relRepository, never()).save(any());
}
@Test
void bindByCredential_unknownAccount_sameErrorAsWrongPassword() {
when(sysUserRepository.findByAccount("nobody")).thenReturn(null);
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "nobody", "right-pwd"));
assertTrue(ex.getMessage().contains("账号或密码错误"));
}
@Test
void bindByCredential_success_savesRelWithBoundAt() {
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
when(relRepository.findByUserIdAndChannel(1001, "wxgzh")).thenReturn(Optional.empty());
when(relRepository.findByUnionId("u-1")).thenReturn(Arrays.asList());
when(userCenterService.getUserInfoWithDeptById(1001)).thenReturn(boa(1001, "s1001"));
BoaUser result = service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd");
assertNotNull(result);
ArgumentCaptor<SysUserChannelRel> captor = ArgumentCaptor.forClass(SysUserChannelRel.class);
verify(relRepository).save(captor.capture());
assertEquals(1001, captor.getValue().getUserId());
assertEquals("wxgzh", captor.getValue().getChannel());
assertEquals("o-1", captor.getValue().getOpenid());
assertEquals("u-1", captor.getValue().getUnionId());
assertNotNull(captor.getValue().getBoundAt());
}
// ==================== bindByCredential:唯一约束前置校验 ====================
@Test
void bindByCredential_openidBoundToAnotherUser_rejected() {
// uk_channel_openid 场景:openid 已绑其他账号(并发窗口兜底)
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
SysUserChannelRel other = new SysUserChannelRel();
other.setUserId(2002);
other.setChannel("wxgzh");
other.setOpenid("o-1");
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.of(other));
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
assertTrue(ex.getMessage().contains("已绑定"));
verify(relRepository, never()).save(any());
}
@Test
void bindByCredential_userAlreadyBoundAnotherOpenid_rejected() {
// uk_user_channel 场景:账号在此渠道已绑其他 openid(换绑须先解绑)
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
SysUserChannelRel existing = new SysUserChannelRel();
existing.setUserId(1001);
existing.setChannel("wxgzh");
existing.setOpenid("o-old");
when(relRepository.findByUserIdAndChannel(1001, "wxgzh")).thenReturn(Optional.of(existing));
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
assertTrue(ex.getMessage().contains("请先解绑"));
verify(relRepository, never()).save(any());
}
@Test
void bindByCredential_unionidBoundToAnotherUser_rejected() {
// uk_user_channel 只约束半边:同 unionid 绑两个账号须在应用层拦(方案 §3.13 ⚠)
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
when(relRepository.findByUserIdAndChannel(1001, "wxgzh")).thenReturn(Optional.empty());
SysUserChannelRel otherUnion = new SysUserChannelRel();
otherUnion.setUserId(2002);
otherUnion.setChannel("miniapp");
otherUnion.setOpenid("o-2");
otherUnion.setUnionId("u-1");
when(relRepository.findByUnionId("u-1")).thenReturn(Arrays.asList(otherUnion));
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
assertTrue(ex.getMessage().contains("已绑定"));
verify(relRepository, never()).save(any());
}
@Test
void bindByCredential_unionidOwnedBySameUser_allowed() {
// 同一用户另一端的绑定(公众号+小程序同 unionid)不拦
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
when(relRepository.findByUserIdAndChannel(1001, "wxgzh")).thenReturn(Optional.empty());
SysUserChannelRel ownUnion = new SysUserChannelRel();
ownUnion.setUserId(1001);
ownUnion.setChannel("miniapp");
ownUnion.setOpenid("o-2");
ownUnion.setUnionId("u-1");
when(relRepository.findByUnionId("u-1")).thenReturn(Arrays.asList(ownUnion));
when(userCenterService.getUserInfoWithDeptById(1001)).thenReturn(boa(1001, "s1001"));
assertNotNull(service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
}
@Test
void bindByCredential_disabledUser_rejected() {
user.setDisabled(StatusEnum.DISABLED);
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
assertTrue(ex.getMessage().contains("禁用"));
}
// ==================== bindByPhone ====================
@Test
void bindByPhone_success() {
when(sysUserRepository.findByPhone("13800000001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("miniapp", "o-1")).thenReturn(Optional.empty());
when(relRepository.findByUserIdAndChannel(1001, "miniapp")).thenReturn(Optional.empty());
when(relRepository.findByUnionId(anyString())).thenReturn(Arrays.asList());
when(userCenterService.getUserInfoWithDeptById(1001)).thenReturn(boa(1001, "s1001"));
BoaUser result = service.bindByPhone("miniapp", "o-1", "u-1", "13800000001");
assertNotNull(result);
verify(relRepository).save(any(SysUserChannelRel.class));
}
@Test
void bindByPhone_unknownPhone_rejected() {
when(sysUserRepository.findByPhone("13900000000")).thenReturn(null);
DException ex = assertThrows(DException.class,
() -> service.bindByPhone("miniapp", "o-1", "u-1", "13900000000"));
assertTrue(ex.getMessage().contains("手机号"));
}
// ==================== unbind ====================
@Test
void unbind_deletesByUserIdAndChannel() {
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
service.unbind("wxgzh", "s1001");
verify(relRepository).deleteByUserIdAndChannel(1001, "wxgzh");
}
@Test
void unbind_unknownAccount_silentNoop() {
when(sysUserRepository.findByAccount("nobody")).thenReturn(null);
service.unbind("wxgzh", "nobody");
verify(relRepository, never()).deleteByUserIdAndChannel(any(), any());
}
}
@@ -0,0 +1,436 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
/**
* {@code DepartmentDirectoryService}(2026-10-21 T2 通用部门目录契约)单元测试。<br>
* 覆盖验收映射:A15(仅验签≠可读任意学校)、A10/A11 解析侧(停用条目不进气选目录、历史解析仍可见)、
* A12(软删部门只经 resolve-batch 返回)、A06/A19(同名不靠名称回填、别校引用解析不到)、
* 以及 SYSTEM 部门口径=教师组直接子节点(含 withChildren 与下级 parentId)。
*
* <p>夹具组树:5=云海小学 / 6=云海中学;5 下 7=教师组、8=学生组;
* 教师组下 20=校长室(启用)、22=教务处(停用);20 下 21=校办;
* 6 下 9=教师组、40=别校校长室。CUSTOM:分类 10 下 100 启用 / 101 停用 / 102 已软删。</p>
*/
class DepartmentDirectoryServiceTest {
private static final int APP = 3;
private static final int SCHOOL_A = 5;
private static final int SCHOOL_B = 6;
private static final int CATEGORY = 10;
@InjectMocks
private DepartmentDirectoryService directoryService;
@Mock
private UbCustomDeptCategoryRepository categoryRepository;
@Mock
private UbCustomDepartmentRepository departmentRepository;
@Mock
private UbCustomDepartmentMemberRepository memberRepository;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private SysGroupAppRelRepository sysGroupAppRelRepository;
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
when(sysGroupRepository.findAll()).thenReturn(Arrays.asList(
group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0, 0),
group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0, 0),
group(7, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A, 0),
group(8, GroupTypeEnum.STUDENT_GROUP, SCHOOL_A, 0),
group(20, GroupTypeEnum.NORMAL_GROUP, 7, 0),
group(21, GroupTypeEnum.NORMAL_GROUP, 20, 0),
group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1),
group(30, GroupTypeEnum.GRADE_GROUP, 8, 0),
group(9, GroupTypeEnum.TEACHER_GROUP, SCHOOL_B, 0),
group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)
));
when(sysGroupRepository.findById(SCHOOL_A)).thenReturn(Optional.of(group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0, 0)));
when(sysGroupRepository.findById(SCHOOL_B)).thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0, 0)));
// 只有小学开通了本应用
when(sysGroupAppRelRepository.existsByGroupIdAndAppId(SCHOOL_A, APP)).thenReturn(true);
when(sysGroupAppRelRepository.existsByGroupIdAndAppId(SCHOOL_B, APP)).thenReturn(false);
when(sysUserGroupRelRepository.countByGroupId(org.mockito.ArgumentMatchers.anyInt())).thenReturn(1L);
stubCustom();
}
private SysGroup group(Integer id, GroupTypeEnum type, Integer parentId, Integer status) {
SysGroup g = new SysGroup();
g.setId(id);
g.setType(type);
g.setParentId(parentId);
g.setName("组" + id);
g.setStatus(status);
return g;
}
private UbCustomDepartment custom(Integer id, Integer categoryId, String name, Integer enabled, Integer isDeleted) {
UbCustomDepartment d = new UbCustomDepartment();
d.setId(id);
d.setSchoolId(SCHOOL_A);
d.setCategoryId(categoryId);
d.setName(name);
d.setEnabled(enabled);
d.setIsDeleted(isDeleted);
d.setDelMarker(isDeleted == 1 ? id.longValue() : 0L);
d.setSortNo(1);
return d;
}
private UbCustomDeptCategory liveCategory() {
UbCustomDeptCategory c = new UbCustomDeptCategory();
c.setId(CATEGORY);
c.setSchoolId(SCHOOL_A);
c.setName("审核部门");
c.setSortNo(1);
c.setIsDeleted(0);
c.setDelMarker(0L);
return c;
}
private void stubCustom() {
when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL_A, 0))
.thenReturn(Collections.singletonList(liveCategory()));
when(departmentRepository.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(CATEGORY, 0))
.thenReturn(Arrays.asList(custom(100, CATEGORY, "校长室", 1, 0), custom(101, CATEGORY, "教务处", 0, 0)));
when(departmentRepository.findById(100)).thenReturn(Optional.of(custom(100, CATEGORY, "校长室", 1, 0)));
when(departmentRepository.findById(101)).thenReturn(Optional.of(custom(101, CATEGORY, "教务处", 0, 0)));
when(departmentRepository.findById(102)).thenReturn(Optional.of(custom(102, CATEGORY, "已撤并部门", 1, 1)));
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(liveCategory()));
when(memberRepository.findByDepartmentIdIn(anyList())).thenReturn(Collections.emptyList());
when(memberRepository.countByDepartmentId(org.mockito.ArgumentMatchers.anyInt())).thenReturn(0L);
}
// ===================== A15:应用 × 学校 =====================
/** 仅验签不等于可读任意学校:未在该校开通的应用读目录直接拒绝 */
@Test
void listRejectsAppNotOpenedAtThatSchool() {
DException error = assertThrows(DException.class,
() -> directoryService.list(APP, SCHOOL_B, DepartmentTypeEnum.SYSTEM, null, false));
assertTrue(error.getMessage().contains("未在本校开通"));
}
@Test
void listRejectsUnresolvableCallerApp() {
DException error = assertThrows(DException.class,
() -> directoryService.list(null, SCHOOL_A, null, null, false));
assertTrue(error.getMessage().contains("无法识别调用方应用"));
}
@Test
void categoriesAndMembersAlsoCheckSchoolVisibility() {
assertThrows(DException.class, () -> directoryService.categories(APP, SCHOOL_B));
// SYSTEM 组 40 属于云海中学,本应用未开通该校
when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 40, false));
}
@Test
void listRejectsSchoolIdThatIsNotASchoolGroup() {
when(sysGroupRepository.findById(7)).thenReturn(Optional.of(group(7, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A, 0)));
DException error = assertThrows(DException.class,
() -> directoryService.list(APP, 7, null, null, false));
assertTrue(error.getMessage().contains("不是学校组"));
}
// ===================== 目录过滤 =====================
/** SYSTEM 部门口径=教师组直接子节点;停用不进可选目录;学生组下的节点不算部门 */
@Test
void listSystemReturnsOnlyEnabledTeacherGroupChildren() {
List<DepartmentEntryVo> entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, null, false);
assertEquals(Collections.singletonList(20), ids(entries));
DepartmentEntryVo first = entries.get(0);
assertEquals(SCHOOL_A, first.getSchoolId());
assertEquals("SYSTEM", first.getDepartmentType());
assertEquals(Boolean.TRUE, first.getSelectable());
assertNull(first.getCategoryId(), "SYSTEM 条目不带分类");
}
@Test
void listSystemWithChildrenAppendsDescendantsWithParentId() {
List<DepartmentEntryVo> entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, null, true);
assertEquals(Arrays.asList(20, 21), ids(entries));
assertEquals(20, entries.get(1).getParentId(), "下级条目靠 parentId 还原层级");
}
@Test
void listCustomExcludesDisabledButKeepsEnabled() {
List<DepartmentEntryVo> entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.CUSTOM, CATEGORY, false);
assertEquals(Collections.singletonList(100), ids(entries));
assertEquals("审核部门", entries.get(0).getCategoryName());
assertEquals("CUSTOM", entries.get(0).getDepartmentType());
}
@Test
void listBothTypesWhenTypeOmitted() {
List<DepartmentEntryVo> entries = directoryService.list(APP, SCHOOL_A, null, null, false);
assertEquals(Arrays.asList(20, 100), ids(entries));
}
@Test
void listRejectsCategoryIdOnSystemBranch() {
assertThrows(DException.class,
() -> directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, CATEGORY, false));
}
@Test
void listCustomRejectsUnknownCategory() {
assertThrows(DException.class,
() -> directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.CUSTOM, 999, false));
}
// ===================== A12 / A10 历史解析 =====================
/** 软删与停用部门只能经 resolve-batch 拿到,且 selectable=false */
@Test
void resolveBatchReturnsDisabledAndSoftDeletedWithHistoryFlags() {
List<DepartmentEntryVo> entries = directoryService.resolveBatch(APP, Arrays.asList(
ref(SCHOOL_A, "CUSTOM", 101),
ref(SCHOOL_A, "CUSTOM", 102)));
assertEquals(Boolean.FALSE, entries.get(0).getEnabled());
assertEquals(Boolean.FALSE, entries.get(0).getDeleted());
assertEquals(Boolean.FALSE, entries.get(0).getSelectable(), "停用不可新选(A10)");
assertEquals("已撤并部门", entries.get(1).getName(), "软删行保留原名,历史展示不受影响");
assertEquals(Boolean.TRUE, entries.get(1).getDeleted());
assertEquals(Boolean.FALSE, entries.get(1).getSelectable(), "软删不可新选(A12)");
}
/** A13:软删部门保留原 id,旧引用不会指向同名新对象 */
@Test
void resolveBatchReturnsStaleIdWithoutRedirectingToNewObject() {
UbCustomDepartment fresh = custom(103, CATEGORY, "已撤并部门", 1, 0);
when(departmentRepository.findById(103)).thenReturn(Optional.of(fresh));
List<DepartmentEntryVo> entries = directoryService.resolveBatch(APP,
Collections.singletonList(ref(SCHOOL_A, "CUSTOM", 102)));
assertEquals(102, entries.get(0).getDepartmentId());
assertEquals(Boolean.TRUE, entries.get(0).getDeleted());
assertEquals(103, directoryService.resolveBatch(APP, Collections.singletonList(ref(SCHOOL_A, "CUSTOM", 103)))
.get(0).getDepartmentId());
}
/** A19:别校同名组不被当成本校历史值(按 schoolId + type + id 校验归属) */
@Test
void resolveBatchMarksCrossSchoolReferenceUnresolvable() {
when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
List<DepartmentEntryVo> entries = directoryService.resolveBatch(APP,
Collections.singletonList(ref(SCHOOL_A, "SYSTEM", 40)));
assertNull(entries.get(0).getName());
assertEquals(Boolean.TRUE, entries.get(0).getDeleted());
assertEquals(Boolean.FALSE, entries.get(0).getSelectable());
}
@Test
void resolveBatchRejectsBatchContainingUnauthorizedSchool() {
when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
List<DepartmentRefRequest> refs = Arrays.asList(ref(SCHOOL_A, "SYSTEM", 20), ref(SCHOOL_B, "SYSTEM", 40));
assertThrows(DException.class, () -> directoryService.resolveBatch(APP, refs));
}
@Test
void resolveBatchRejectsUnknownDepartmentType() {
List<DepartmentRefRequest> refs = Collections.singletonList(ref(SCHOOL_A, "FLOW", 20));
DException error = assertThrows(DException.class, () -> directoryService.resolveBatch(APP, refs));
assertTrue(error.getMessage().contains("departmentType"));
}
@Test
void resolveBatchOfEmptyRefsReturnsEmptyWithoutAuthCheck() {
assertTrue(directoryService.resolveBatch(APP, Collections.<DepartmentRefRequest>emptyList()).isEmpty());
}
/** SYSTEM 历史解析:停用组仍解析出名称,只是不可选 */
@Test
void resolveSystemReturnsDisabledGroup() {
when(sysGroupRepository.findById(22)).thenReturn(Optional.of(group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1)));
DepartmentEntryVo entry = directoryService.resolveBatch(APP,
Collections.singletonList(ref(SCHOOL_A, "SYSTEM", 22))).get(0);
assertEquals(Boolean.FALSE, entry.getEnabled());
assertEquals(Boolean.FALSE, entry.getSelectable());
assertEquals(Boolean.FALSE, entry.getDeleted(), "SYSTEM 不支持删除,deleted 恒 false");
assertEquals(7, entry.getParentId());
}
// ===================== 成员解析(select / history) =====================
@Test
void membersSelectRejectsDisabledCustomDepartment() {
DException error = assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 101, true));
assertTrue(error.getMessage().contains("停用"));
}
@Test
void membersSelectRejectsSoftDeletedCustomDepartment() {
DException error = assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 102, true));
assertTrue(error.getMessage().contains("已删除"));
}
/** A17:历史解析不因停用/软删而失败,成员照常返回 */
@Test
void membersHistoryReturnsMembersOfDisabledDepartment() {
when(memberRepository.findByDepartmentId(101)).thenReturn(Arrays.asList(member(101, 11), member(101, 12)));
when(sysUserRepository.findAllById(anyList())).thenReturn(Arrays.asList(
user(11, "甲", StatusEnum.NORMAL), user(12, "乙", StatusEnum.DISABLED)));
List<BoaUser> users = directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 101, false);
assertEquals(2, users.size(), "禁用账号不静默剔除,靠 status 交给消费方判(与 /oapi/group/users 同口径)");
assertEquals(Integer.valueOf(1), users.get(0).getStatus());
assertEquals(Integer.valueOf(0), users.get(1).getStatus());
}
@Test
void membersHistoryResolvesSoftDeletedDepartmentMembers() {
when(memberRepository.findByDepartmentId(102)).thenReturn(Collections.singletonList(member(102, 11)));
when(sysUserRepository.findAllById(anyList()))
.thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
assertEquals(1, directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 102, false).size());
}
@Test
void membersOrphanRelationRowsAreSkipped() {
when(memberRepository.findByDepartmentId(100)).thenReturn(Arrays.asList(member(100, 11), member(100, 999)));
when(sysUserRepository.findAllById(anyList()))
.thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
List<BoaUser> users = directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 100, true);
assertEquals(1, users.size());
assertEquals("acc11", users.get(0).getAccount());
}
@Test
void membersSelectRejectsDisabledSystemGroup() {
when(sysGroupRepository.findById(22)).thenReturn(Optional.of(group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1)));
assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 22, true));
}
@Test
void membersSystemHistoryReturnsGroupUsers() {
when(sysGroupRepository.findById(20)).thenReturn(Optional.of(group(20, GroupTypeEnum.NORMAL_GROUP, 7, 0)));
when(sysUserGroupRelRepository.findByGroupId(20)).thenReturn(Collections.singletonList(rel(11, 20)));
when(sysUserRepository.findAllById(anyList()))
.thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
List<BoaUser> users = directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 20, false);
assertEquals(1, users.size());
assertFalse(users.get(0).getName().isEmpty());
}
@Test
void membersRejectsMissingDepartment() {
assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 12345, true));
assertThrows(DException.class,
() -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, null, true));
}
// ===================== 辅助 =====================
private List<Integer> ids(List<DepartmentEntryVo> entries) {
List<Integer> ids = new java.util.ArrayList<>();
for (DepartmentEntryVo entry : entries) {
ids.add(entry.getDepartmentId());
}
return ids;
}
private DepartmentRefRequest ref(Integer schoolId, String type, Integer id) {
DepartmentRefRequest ref = new DepartmentRefRequest();
ref.setSchoolId(schoolId);
ref.setDepartmentType(type);
ref.setDepartmentId(id);
return ref;
}
private UbCustomDepartmentMember member(Integer departmentId, Integer userId) {
UbCustomDepartmentMember m = new UbCustomDepartmentMember();
m.setSchoolId(SCHOOL_A);
m.setDepartmentId(departmentId);
m.setUserId(userId);
return m;
}
private SysUserGroupRel rel(Integer userId, Integer groupId) {
SysUserGroupRel r = new SysUserGroupRel();
r.setUserId(userId);
r.setGroupId(groupId);
return r;
}
private SysUser user(Integer id, String name, StatusEnum disabled) {
SysUser u = new SysUser();
u.setId(id);
u.setAccount("acc" + id);
u.setRealName(name);
u.setDisabled(disabled);
return u;
}
}
@@ -0,0 +1,161 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.event.GroupChangedEvent;
import com.yunhai.userbase.repository.SysGroupRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@code SysGroupService.changeIncludeChildren}(2026-10-21 部门线)单元测试。<br>
* 覆盖:① 学校组上置位/复位只走 updateIncludeChildren 单列 UPDATE(不 save 不 delete);
* ② 组不存在 / id 为空抛 DException 且零写入;③ updated=0 分支抛错且**不发**缓存驱逐事件;
* ④ 非学校组节点(教师组/普通组等)拒绝,不写库不发事件;⑤ 成功路径必须发 GroupChangedEvent。
*/
class SysGroupServiceIncludeChildrenTest {
@InjectMocks
private SysGroupService sysGroupService;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private GroupSnapshotService groupSnapshotService;
@Captor
private ArgumentCaptor<GroupChangedEvent> eventCaptor;
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
}
private SysGroup node(Integer id, GroupTypeEnum type, Integer includeChildren) {
SysGroup g = new SysGroup();
g.setId(id);
g.setParentId(1);
g.setType(type);
g.setIncludeChildren(includeChildren);
return g;
}
private SysGroup school(Integer id) {
return node(id, GroupTypeEnum.SCHOOL_GROUP, 1);
}
// ===================== 置位:单列 UPDATE =====================
@Test
void turningOffUsesSingleColumnUpdateOnly() {
when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(1);
sysGroupService.changeIncludeChildren(5, false);
verify(sysGroupRepository).updateIncludeChildren(5, 0);
// 单列 UPDATE 断言:不走实体 save(会触发审计列/监听器),不碰删除
verify(sysGroupRepository, never()).save(any(SysGroup.class));
verify(sysGroupRepository, never()).deleteById(anyInt());
verify(sysGroupRepository, never()).delete(any(SysGroup.class));
}
@Test
void turningOnWritesOne() {
when(sysGroupRepository.findById(5)).thenReturn(Optional.of(node(5, GroupTypeEnum.SCHOOL_GROUP, 0)));
when(sysGroupRepository.updateIncludeChildren(5, 1)).thenReturn(1);
sysGroupService.changeIncludeChildren(5, true);
verify(sysGroupRepository).updateIncludeChildren(5, 1);
verify(sysGroupRepository, never()).save(any(SysGroup.class));
}
@Test
void successEvictsGroupSnapshotCache() {
when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(1);
sysGroupService.changeIncludeChildren(5, false);
// 不发 GroupChangedEvent = 快照最多 30min 不刷新,GetTree / oapi 组树读到旧 includeChildren
verify(eventPublisher).publishEvent(eventCaptor.capture());
assertEquals("changeIncludeChildren", eventCaptor.getValue().getSourceOp());
}
// ===================== 拒绝路径:零写入、零事件 =====================
@Test
void missingGroupRejectedWithoutAnyWrite() {
when(sysGroupRepository.findById(99)).thenReturn(Optional.empty());
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(99, false));
verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
verify(sysGroupRepository, never()).save(any(SysGroup.class));
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
@Test
void nullIdRejectedWithoutLookup() {
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(null, true));
verify(sysGroupRepository, never()).findById(any());
verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
/** 类型守卫:include_children 只是学校节点属性,误调到其他层(教师组/普通组)必须拒绝 */
@Test
void nonSchoolGroupRejectedWithoutWrite() {
when(sysGroupRepository.findById(7)).thenReturn(Optional.of(node(7, GroupTypeEnum.TEACHER_GROUP, 1)));
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(7, false));
verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
verify(sysGroupRepository, never()).save(any(SysGroup.class));
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
@Test
void legacyNullTypeRejectedWithoutWrite() {
when(sysGroupRepository.findById(8)).thenReturn(Optional.of(node(8, null, 1)));
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(8, false));
verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
/** 存在性校验与 UPDATE 之间组被并发删除:updated=0 视作不存在,且不留下虚假的缓存驱逐 */
@Test
void zeroRowsUpdatedThrowsAndDoesNotPublishEvent() {
when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(0);
assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(5, false));
verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
}
}
@@ -0,0 +1,601 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@code UbCustomDepartmentService}(2026-10-21 T1 CUSTOM 部门与成员写口)单元测试。<br>
* 覆盖验收映射:A04~A09(跨校/重名/成员归属)、A10~A13(启停两态、软删保留成员与名称、
* 同名解锁走新 id)、A14 的部门侧前置;以及 D4「本校教职工、排除学生组」的服务端复核。
*/
class UbCustomDepartmentServiceTest {
private static final int SCHOOL = 5;
private static final int OTHER_SCHOOL = 6;
private static final int CATEGORY = 10;
private static final int DEPT = 20;
@InjectMocks
private UbCustomDepartmentService departmentService;
@Mock
private UbCustomDepartmentRepository departmentRepository;
@Mock
private UbCustomDeptCategoryRepository categoryRepository;
@Mock
private UbCustomDepartmentMemberRepository memberRepository;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private ISysGroupService sysGroupService;
@Mock
private AccessScopeService accessScopeService;
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
}
@AfterEach
void tearDown() {
UserContext.clear();
}
// ===================== 夹具 =====================
private UbCustomDeptCategory category(Integer id, Integer schoolId) {
UbCustomDeptCategory c = new UbCustomDeptCategory();
c.setId(id);
c.setSchoolId(schoolId);
c.setName("审核部门");
c.setSortNo(1);
c.setIsDeleted(0);
c.setDelMarker(0L);
return c;
}
private UbCustomDepartment dept(Integer id, Integer schoolId, Integer categoryId, String name) {
UbCustomDepartment d = new UbCustomDepartment();
d.setId(id);
d.setSchoolId(schoolId);
d.setCategoryId(categoryId);
d.setName(name);
d.setSortNo(1);
d.setEnabled(1);
d.setIsDeleted(0);
d.setDelMarker(0L);
return d;
}
private SysUser user(Integer id, String realName) {
SysUser u = new SysUser();
u.setId(id);
u.setAccount("acc" + id);
u.setRealName(realName);
u.setDisabled(StatusEnum.NORMAL);
return u;
}
private SysUserGroupRel rel(Integer userId, Integer groupId) {
SysUserGroupRel r = new SysUserGroupRel();
r.setUserId(userId);
r.setGroupId(groupId);
return r;
}
private SysGroup group(Integer id, GroupTypeEnum type) {
SysGroup g = new SysGroup();
g.setId(id);
g.setParentId(SCHOOL);
g.setType(type);
return g;
}
private void stubLiveCategory() {
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, SCHOOL)));
}
private void stubLiveDepartment() {
when(departmentRepository.findById(DEPT))
.thenReturn(Optional.of(dept(DEPT, SCHOOL, CATEGORY, "校长室")));
}
/**
* findAllById 必须按<b>实际入参</b>返回(桩若无视参数,学生 30 会被当成教职工,
* 「禁用账号不能加入」也会假通过)。
*/
private void stubUserRepository(SysUser... users) {
final Map<Integer, SysUser> byId = new HashMap<>();
for (SysUser user : users) {
byId.put(user.getId(), user);
}
when(sysUserRepository.findAllById(anyList())).thenAnswer(invocation -> {
Iterable<Integer> ids = invocation.getArgument(0);
List<SysUser> hits = new ArrayList<>();
for (Integer id : ids) {
if (byId.containsKey(id)) {
hits.add(byId.get(id));
}
}
return hits;
});
}
/**
* 学校子树 5:教师组 7 / 学生组 8(下挂年级组 21)。<br>
* 11、12 只在教师组下(教职工);30 同时挂教师组与学生组子树(按 D4 排除)。
*/
private void stubSchoolMembership() {
when(sysGroupService.listSelfAndDescendantIds(SCHOOL))
.thenReturn(Arrays.asList(SCHOOL, 7, 8, 21));
when(sysGroupService.listSelfAndDescendantIds(8)).thenReturn(Arrays.asList(8, 21));
when(sysGroupRepository.findAllById(anyList())).thenReturn(Arrays.asList(
group(SCHOOL, GroupTypeEnum.SCHOOL_GROUP),
group(7, GroupTypeEnum.TEACHER_GROUP),
group(8, GroupTypeEnum.STUDENT_GROUP),
group(21, GroupTypeEnum.GRADE_GROUP)));
when(sysUserGroupRelRepository.findByGroupIdIn(Arrays.asList(8, 21)))
.thenReturn(Collections.singletonList(rel(30, 21)));
when(sysUserGroupRelRepository.findByGroupIdIn(Arrays.asList(SCHOOL, 7, 8, 21)))
.thenReturn(Arrays.asList(rel(11, 7), rel(12, 7), rel(30, 21)));
stubUserRepository(user(11, "甲"), user(12, "乙"), user(30, "丙"));
}
// ===================== 创建 / 重名 =====================
@Test
void addCreatesEnabledDepartmentAtTailOfSort() {
stubLiveCategory();
when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
.thenReturn(Optional.empty());
when(departmentRepository.findMaxSortNoByCategory(CATEGORY)).thenReturn(3);
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
departmentService.add(request);
ArgumentCaptor<UbCustomDepartment> captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
verify(departmentRepository).save(captor.capture());
UbCustomDepartment saved = captor.getValue();
assertEquals(1, saved.getEnabled(), "新建默认启用(规格书 §2.2)");
assertEquals(0, saved.getIsDeleted());
assertEquals(4, saved.getSortNo());
assertEquals(SCHOOL, saved.getSchoolId(), "落库学校取服务端解析值,不是前端传值");
}
/** A06/A05:同校同分类重名拒绝;uk 之外给友好报错,且不落库 */
@Test
void addRejectsDuplicateNameInSameCategory() {
stubLiveCategory();
when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
.thenReturn(Optional.of(dept(21, SCHOOL, CATEGORY, "校长室")));
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
DException error = assertThrows(DException.class, () -> departmentService.add(request));
assertTrue(error.getMessage().contains("同名部门"));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
/** A09:分类跨校(请求校与分类校不一致)直接拒绝 */
@Test
void addRejectsCategoryOfAnotherSchool() {
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, OTHER_SCHOOL)));
when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
assertThrows(DException.class, () -> departmentService.add(request));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
@Test
void addRejectsMissingCategoryWithoutPersist() {
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.empty());
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
assertThrows(DException.class, () -> departmentService.add(request));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
// ===================== 移动 =====================
@Test
void moveRejectsCrossSchoolTargetCategory() {
stubLiveDepartment();
when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, OTHER_SCHOOL)));
CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(11);
assertThrows(DException.class, () -> departmentService.move(DEPT, request));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
@Test
void moveRejectsDuplicateNameInTargetCategory() {
stubLiveDepartment();
when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, SCHOOL)));
when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, 11, "校长室", 0))
.thenReturn(Optional.of(dept(22, SCHOOL, 11, "校长室")));
CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(11);
assertThrows(DException.class, () -> departmentService.move(DEPT, request));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
@Test
void moveAcceptsSameSchoolTargetCategory() {
stubLiveDepartment();
when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, SCHOOL)));
when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, 11, "校长室", 0))
.thenReturn(Optional.empty());
when(departmentRepository.findMaxSortNoByCategory(11)).thenReturn(null);
CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(11);
departmentService.move(DEPT, request);
ArgumentCaptor<UbCustomDepartment> captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
verify(departmentRepository).save(captor.capture());
assertEquals(11, captor.getValue().getCategoryId());
assertEquals(1, captor.getValue().getSortNo(), "空目标分类落到首位");
}
// ===================== 启停(A10/A11) =====================
/** A10:停用走单列 UPDATE,不走实体 save(避免动审计列/其他列),成员与软删列不受影响 */
@Test
void disableUsesSingleColumnUpdateOnly() {
stubLiveDepartment();
when(departmentRepository.updateEnabled(DEPT, 0)).thenReturn(1);
departmentService.changeEnabled(DEPT, false);
verify(departmentRepository).updateEnabled(DEPT, 0);
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
/** A11:启用同样单列翻转,恢复可选,原 id 与成员不变 */
@Test
void enableWritesOneAndKeepsIdentity() {
stubLiveDepartment();
when(departmentRepository.updateEnabled(DEPT, 1)).thenReturn(1);
departmentService.changeEnabled(DEPT, true);
verify(departmentRepository).updateEnabled(DEPT, 1);
verify(memberRepository, never()).deleteByDepartmentIdAndUserIds(anyInt(), anyList());
}
@Test
void changeEnabledRejectsMissingDepartmentAndZeroRowUpdate() {
when(departmentRepository.findById(99)).thenReturn(Optional.empty());
assertThrows(DException.class, () -> departmentService.changeEnabled(99, false));
stubLiveDepartment();
when(departmentRepository.updateEnabled(DEPT, 0)).thenReturn(0);
assertThrows(DException.class, () -> departmentService.changeEnabled(DEPT, false));
}
@Test
void changeEnabledRejectsSoftDeletedDepartment() {
UbCustomDepartment deleted = dept(DEPT, SCHOOL, CATEGORY, "已删部门");
deleted.setIsDeleted(1);
deleted.setDelMarker((long) DEPT);
when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(deleted));
assertThrows(DException.class, () -> departmentService.changeEnabled(DEPT, true));
verify(departmentRepository, never()).updateEnabled(anyInt(), anyInt());
}
// ===================== 软删(A12/A13) =====================
/** A12:软删只打标记,成员关系保留在库(由查询侧过滤),不物理删任何东西 */
@Test
void softDeleteKeepsMemberRowsAndName() {
UserContext.setCurrentUser(91);
stubLiveDepartment();
departmentService.softDelete(DEPT);
ArgumentCaptor<UbCustomDepartment> captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
verify(departmentRepository).save(captor.capture());
UbCustomDepartment deleted = captor.getValue();
assertEquals(1, deleted.getIsDeleted());
assertEquals((long) DEPT, deleted.getDelMarker().longValue());
assertEquals("校长室", deleted.getName(), "名称永不改写,历史解析拿到的就是原名");
assertEquals(Integer.valueOf(91), deleted.getDeletedBy());
verify(memberRepository, never()).deleteByDepartmentIdAndUserIds(anyInt(), anyList());
verify(memberRepository, never()).delete(any(UbCustomDepartmentMember.class));
}
/**
* A13:软删行不再占用同名校验(查询带 is_deleted=0),同名新建放行且一定是<b>新 id</b>
* (自增主键,实体保存前 id 仍为空);旧引用不会指向新对象。
*/
@Test
void sameNameAllowedAfterSoftDeleteAndGetsNewId() {
stubLiveCategory();
when(departmentRepository
.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
.thenReturn(Optional.empty());
CustomDepartmentRequest request = new CustomDepartmentRequest();
request.setSchoolId(SCHOOL);
request.setCategoryId(CATEGORY);
request.setName("校长室");
departmentService.add(request);
ArgumentCaptor<UbCustomDepartment> captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
verify(departmentRepository).save(captor.capture());
assertEquals(null, captor.getValue().getId(), "新建走新 id,不复用软删行主键");
}
// ===================== 成员(A07/A08/D4) =====================
/** D4:学生组下的用户即使同校也不能加入;教职工放行 */
@Test
void addMembersRejectsStudentUser() {
stubLiveDepartment();
stubSchoolMembership();
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Collections.singletonList(30));
DException error = assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
assertTrue(error.getMessage().contains("本校教职工"));
verify(memberRepository, never()).saveAll(anyList());
}
@Test
void addMembersRejectsUserOutsideSchoolScope() {
stubLiveDepartment();
stubSchoolMembership();
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Collections.singletonList(777));
assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
verify(memberRepository, never()).saveAll(anyList());
}
/** A08:重复添加幂等——已存在的成员不再插入第二条有效关系 */
@Test
void addMembersSkipsExistingRelationsIdempotently() {
stubLiveDepartment();
stubSchoolMembership();
UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
existing.setDepartmentId(DEPT);
existing.setUserId(11);
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(new ArrayList<>(Arrays.asList(11, 12)));
departmentService.addMembers(DEPT, request);
ArgumentCaptor<List<UbCustomDepartmentMember>> captor = ArgumentCaptor.forClass(List.class);
verify(memberRepository).saveAll(captor.capture());
assertEquals(1, captor.getValue().size());
assertEquals(Integer.valueOf(12), captor.getValue().get(0).getUserId());
assertEquals(SCHOOL, captor.getValue().get(0).getSchoolId(), "成员行冗余学校须与部门同校");
}
@Test
void addMembersWithAllDuplicatesIsNoop() {
stubLiveDepartment();
stubSchoolMembership();
UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
existing.setDepartmentId(DEPT);
existing.setUserId(11);
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Arrays.asList(11, 11));
departmentService.addMembers(DEPT, request);
verify(memberRepository, never()).saveAll(anyList());
}
/** D4 补充:禁用账号不能作为新成员 */
@Test
void addMembersRejectsDisabledAccount() {
stubLiveDepartment();
stubSchoolMembership();
SysUser disabled = user(11, "甲");
disabled.setDisabled(StatusEnum.DISABLED);
stubUserRepository(disabled, user(12, "乙"), user(30, "丙"));
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Collections.singletonList(11));
assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
verify(memberRepository, never()).saveAll(anyList());
}
@Test
void addMembersRejectsEmptyList() {
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Collections.emptyList());
assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
}
/** 移除成员=物理删关系行 */
@Test
void removeMembersDeletesRelationRowsPhysically() {
stubLiveDepartment();
CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
request.setSchoolId(SCHOOL);
request.setUserIds(Arrays.asList(11, 11, null));
departmentService.removeMembers(DEPT, request);
verify(memberRepository).deleteByDepartmentIdAndUserIds(DEPT, Arrays.asList(11));
}
@Test
void membersListSkipsOrphanUserRows() {
stubLiveDepartment();
UbCustomDepartmentMember live = new UbCustomDepartmentMember();
live.setDepartmentId(DEPT);
live.setUserId(11);
UbCustomDepartmentMember orphan = new UbCustomDepartmentMember();
orphan.setDepartmentId(DEPT);
orphan.setUserId(999);
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Arrays.asList(live, orphan));
when(sysUserRepository.findAllById(anyList())).thenReturn(Collections.singletonList(user(11, "甲")));
assertEquals(1, departmentService.members(DEPT).size());
}
// ===================== 跨校(A09) =====================
@Test
void crossSchoolDepartmentWriteIsRejected() {
UbCustomDepartment foreign = dept(DEPT, OTHER_SCHOOL, CATEGORY, "别校部门");
when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(foreign));
org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
.when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
assertThrows(DException.class, () -> departmentService.softDelete(DEPT));
assertThrows(DException.class, () -> departmentService.members(DEPT));
verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
}
@Test
void listRejectsForeignCategory() {
when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, OTHER_SCHOOL)));
assertThrows(DException.class, () -> departmentService.list(SCHOOL, CATEGORY));
}
// ===================== 候选教职工(选人弹窗数据源) =====================
/** 候选口径与写口一致:学生(30)不出现,已在部门内的(11)不重复出现 */
@Test
void candidateUsersExcludeStudentsAndExistingMembers() {
stubLiveDepartment();
stubSchoolMembership();
UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
existing.setDepartmentId(DEPT);
existing.setUserId(11);
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
List<CustomDepartmentMemberVo> candidates = departmentService.candidateUsers(DEPT, null);
assertEquals(1, candidates.size());
assertEquals(Integer.valueOf(12), candidates.get(0).getUserId());
}
@Test
void candidateUsersFilterByKeywordIgnoringCase() {
stubLiveDepartment();
stubSchoolMembership();
when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
List<CustomDepartmentMemberVo> hits = departmentService.candidateUsers(DEPT, " ACC12 ");
assertEquals(1, hits.size());
assertEquals(Integer.valueOf(12), hits.get(0).getUserId());
}
@Test
void candidateUsersRejectCrossSchoolDepartment() {
UbCustomDepartment foreign = dept(DEPT, OTHER_SCHOOL, CATEGORY, "别校部门");
when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(foreign));
org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
.when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
assertThrows(DException.class, () -> departmentService.candidateUsers(DEPT, null));
}
}
@@ -0,0 +1,250 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Arrays;
import java.util.Collections;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@code UbCustomDeptCategoryService}(2026-10-21 T1 CUSTOM 分类写口)单元测试。<br>
* 覆盖验收映射:A09 跨校写拒绝、A14 非空分类软删明确拒绝(无级联)、
* A13 软删只让位唯一键(del_marker=行 id、name 不改写)、重名前置校验、排序范围校验。
*/
class UbCustomDeptCategoryServiceTest {
private static final int SCHOOL = 5;
private static final int OTHER_SCHOOL = 6;
@InjectMocks
private UbCustomDeptCategoryService categoryService;
@Mock
private UbCustomDeptCategoryRepository categoryRepository;
@Mock
private UbCustomDepartmentRepository departmentRepository;
@Mock
private AccessScopeService accessScopeService;
@BeforeEach
void setUp() {
MockitoAnnotations.initMocks(this);
when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
}
@AfterEach
void tearDown() {
UserContext.clear();
}
private UbCustomDeptCategory category(Integer id, Integer schoolId, String name) {
UbCustomDeptCategory c = new UbCustomDeptCategory();
c.setId(id);
c.setSchoolId(schoolId);
c.setName(name);
c.setSortNo(1);
c.setIsDeleted(0);
c.setDelMarker(0L);
return c;
}
private CustomDeptCategoryRequest request(Integer schoolId, String name) {
CustomDeptCategoryRequest request = new CustomDeptCategoryRequest();
request.setSchoolId(schoolId);
request.setName(name);
return request;
}
// ===================== 创建 =====================
@Test
void addPersistsNewCategoryAtTailOfSort() {
when(categoryRepository.existsBySchoolIdAndNameAndIsDeleted(SCHOOL, "审核部门", 0)).thenReturn(false);
when(categoryRepository.findMaxSortNoBySchool(SCHOOL)).thenReturn(7);
categoryService.add(request(SCHOOL, "审核部门"));
ArgumentCaptor<UbCustomDeptCategory> captor = ArgumentCaptor.forClass(UbCustomDeptCategory.class);
verify(categoryRepository).save(captor.capture());
UbCustomDeptCategory saved = captor.getValue();
assertEquals(SCHOOL, saved.getSchoolId());
assertEquals("审核部门", saved.getName());
assertEquals(8, saved.getSortNo(), "新建分类排到最后");
assertEquals(0, saved.getIsDeleted());
assertEquals(0L, saved.getDelMarker(), "有效行 del_marker 必须为 0,否则让不出同名唯一键");
}
@Test
void addRejectsDuplicateNameWithinSchool() {
when(categoryRepository.existsBySchoolIdAndNameAndIsDeleted(SCHOOL, "审核部门", 0)).thenReturn(true);
DException error = assertThrows(DException.class, () -> categoryService.add(request(SCHOOL, "审核部门")));
assertTrue(error.getMessage().contains("同名分类"));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
/** A09:请求体的 schoolId 只作核对,越权由学校维度 guard 拒绝,服务层不落库 */
@Test
void addRejectsCrossSchoolWriteWithoutPersist() {
when(accessScopeService.resolveWritableSchoolId(OTHER_SCHOOL))
.thenThrow(new DException("暂无权限访问该组织"));
assertThrows(DException.class, () -> categoryService.add(request(OTHER_SCHOOL, "审核部门")));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
verify(categoryRepository, never()).existsBySchoolIdAndNameAndIsDeleted(anyInt(), anyString(), anyInt());
}
@Test
void addTrimsBlankNameAsError() {
assertThrows(DException.class, () -> categoryService.add(request(SCHOOL, " ")));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
// ===================== 改名 =====================
@Test
void renameRejectsNameTakenByAnotherLiveCategory() {
when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "旧分类")));
when(categoryRepository.findFirstBySchoolIdAndNameAndIsDeleted(SCHOOL, "事项部门", 0))
.thenReturn(Optional.of(category(4, SCHOOL, "事项部门")));
assertThrows(DException.class, () -> categoryService.rename(3, request(SCHOOL, "事项部门")));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
@Test
void renameRejectsCrossSchoolCategory() {
when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, OTHER_SCHOOL, "别校分类")));
org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
.when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
assertThrows(DException.class, () -> categoryService.rename(3, request(SCHOOL, "改名")));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
// ===================== 软删(A14 / A13) =====================
/** A14:分类下仍有有效部门 → 明确拒绝,不允许任何级联删除 */
@Test
void softDeleteRejectedWhenCategoryStillHasDepartments() {
when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "审核部门")));
when(departmentRepository.countByCategoryIdAndIsDeleted(3, 0)).thenReturn(2L);
DException error = assertThrows(DException.class, () -> categoryService.softDelete(3));
assertTrue(error.getMessage().contains("2"), "报错需给出剩余部门数,便于用户先处理");
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
}
/** A13 前提:软删只让位唯一键(del_marker=行 id),name 永不改写,旧引用仍指向原名原 id */
@Test
void softDeleteMarksDelMarkerAndKeepsName() {
UserContext.setCurrentUser(91);
when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "审核部门")));
when(departmentRepository.countByCategoryIdAndIsDeleted(3, 0)).thenReturn(0L);
categoryService.softDelete(3);
ArgumentCaptor<UbCustomDeptCategory> captor = ArgumentCaptor.forClass(UbCustomDeptCategory.class);
verify(categoryRepository).save(captor.capture());
UbCustomDeptCategory deleted = captor.getValue();
assertEquals(1, deleted.getIsDeleted());
assertEquals(3L, deleted.getDelMarker(), "del_marker 必须置为自身 id,uk 才让位");
assertEquals("审核部门", deleted.getName(), "软删不得改写名称");
assertEquals(Integer.valueOf(91), deleted.getDeletedBy());
assertTrue(deleted.getDeletedAt() != null);
}
@Test
void softDeleteRejectsMissingOrAlreadyDeletedCategory() {
UbCustomDeptCategory deleted = category(4, SCHOOL, "已删分类");
deleted.setIsDeleted(1);
deleted.setDelMarker(4L);
when(categoryRepository.findById(3)).thenReturn(Optional.empty());
when(categoryRepository.findById(4)).thenReturn(Optional.of(deleted));
assertThrows(DException.class, () -> categoryService.softDelete(3));
assertThrows(DException.class, () -> categoryService.softDelete(4));
// 空 id 走同一分支:不查库、不落库
assertThrows(DException.class, () -> categoryService.softDelete(null));
verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
verify(departmentRepository, never()).countByCategoryIdAndIsDeleted(anyInt(), anyInt());
}
// ===================== 排序 =====================
@Test
void sortRejectsForeignCategoryIds() {
when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL, 0))
.thenReturn(Collections.singletonList(category(3, SCHOOL, "甲")));
CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
request.setSchoolId(SCHOOL);
request.setIds(Arrays.asList(3, 99));
assertThrows(DException.class, () -> categoryService.sort(request));
verify(categoryRepository, never()).saveAll(anyList());
}
@Test
void sortWritesAscendingSequenceByGivenOrder() {
UbCustomDeptCategory a = category(3, SCHOOL, "甲");
UbCustomDeptCategory b = category(4, SCHOOL, "乙");
when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL, 0))
.thenReturn(Arrays.asList(a, b));
CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
request.setSchoolId(SCHOOL);
request.setIds(Arrays.asList(4, 3));
categoryService.sort(request);
assertEquals(1, b.getSortNo());
assertEquals(2, a.getSortNo());
verify(categoryRepository).saveAll(eq(Arrays.asList(a, b)));
}
@Test
void sortRejectsDuplicateIds() {
CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
request.setSchoolId(SCHOOL);
request.setIds(Arrays.asList(3, 3));
assertThrows(DException.class, () -> categoryService.sort(request));
verify(categoryRepository, never()).saveAll(anyList());
}
}
@@ -0,0 +1,241 @@
package com.yunhai.userbase.service.resolve;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserCurrentRole;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.enums.SysRoleEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Arrays;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.when;
/**
* 学校维度 guard 单测(2026-10-21 T1 CUSTOM 部门线新增)。<br>
* CUSTOM 分类/部门只以学校作组织锚点,现有 guard 全是「组」维度,
* 故 {@code requireSchoolInScope} / {@code resolveWritableSchoolId} 必须独立验证:
* 请求体 schoolId 只作核对、不作授权(A09)。
*/
class AccessScopeServiceSchoolGuardTest {
private static final Integer USER_ID = 1;
private static final Integer SCHOOL_A = 10;
private static final Integer SCHOOL_B = 20;
private static final Integer TEACHER_GROUP_IN_A = 101;
/** 教师组下的部门(普通组),用于验证「组在范围内但不是学校」也按学校维度拒绝 */
private static final Integer DEPT_GROUP_IN_A = 102;
@InjectMocks
private AccessScopeService accessScopeService;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private SysUserRoleRelRepository sysUserRoleRelRepository;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
@Mock
private MainGroupResolver mainGroupResolver;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
AccessScopeService.clearCache();
UserContext.clear();
when(sysGroupRepository.findAll()).thenReturn(Arrays.asList(
group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0),
group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0),
group(TEACHER_GROUP_IN_A, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A),
group(DEPT_GROUP_IN_A, GroupTypeEnum.NORMAL_GROUP, TEACHER_GROUP_IN_A)
));
}
@AfterEach
void tearDown() {
AccessScopeService.clearCache();
UserContext.clear();
}
private SysGroup group(Integer id, GroupTypeEnum type, Integer parentId) {
SysGroup g = new SysGroup();
g.setId(id);
g.setType(type);
g.setParentId(parentId);
return g;
}
private SysUser user(UserTypeEnum type) {
SysUser u = new SysUser();
u.setId(USER_ID);
u.setType(type);
return u;
}
private SysUserRoleRel adminRel(Integer schoolId) {
SysUserRoleRel rel = new SysUserRoleRel();
rel.setUserId(USER_ID);
rel.setRoleId(SysRoleEnum.ADMIN.getKey());
rel.setSchoolId(schoolId);
rel.setStatus(StatusEnum.NORMAL);
return rel;
}
/** 学校管理员(单校):身份行小学 + 当前激活身份小学 */
private void givenSingleSchoolAdmin() {
UserContext.setCurrentUser(USER_ID);
when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SCHOOL_ADMIN)));
when(sysUserRoleRelRepository.findByUserIdAndStatus(USER_ID, StatusEnum.NORMAL))
.thenReturn(java.util.Collections.singletonList(adminRel(SCHOOL_A)));
SysUserCurrentRole current = new SysUserCurrentRole();
current.setUserId(USER_ID);
current.setSchoolId(SCHOOL_A);
when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.of(current));
}
private void givenSuper() {
UserContext.setCurrentUser(USER_ID);
when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SUPER_ADMIN)));
}
/** 多校管理员且未指定学校:主组解析不出(保留历史宽口径 → 两校都在范围内) */
private void givenTwoSchoolAdminWithoutActiveRole() {
UserContext.setCurrentUser(USER_ID);
when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SCHOOL_ADMIN)));
when(sysUserRoleRelRepository.findByUserIdAndStatus(USER_ID, StatusEnum.NORMAL))
.thenReturn(Arrays.asList(adminRel(SCHOOL_A), adminRel(SCHOOL_B)));
when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.empty());
when(mainGroupResolver.resolve(anyInt())).thenReturn(Optional.<SysUserGroupRel>empty());
}
// ===================== requireSchoolInScope =====================
@Test
void requireSchoolInScopeRejectsNull() {
givenSuper();
DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(null));
assertEquals("学校ID不能为空", error.getMessage());
}
@Test
void requireSchoolInScopeRejectsUnknownGroup() {
givenSuper();
when(sysGroupRepository.findById(999)).thenReturn(Optional.empty());
DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(999));
assertEquals("学校分组不存在", error.getMessage());
}
/** 教师组在自己的数据范围内,但不是学校 → 学校维度仍要拒绝(不能拿组维度 guard 蒙混) */
@Test
void requireSchoolInScopeRejectsNonSchoolTypeEvenInsideScope() {
givenSingleSchoolAdmin();
when(sysGroupRepository.findById(TEACHER_GROUP_IN_A))
.thenReturn(Optional.of(group(TEACHER_GROUP_IN_A, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A)));
DException error = assertThrows(DException.class,
() -> accessScopeService.requireSchoolInScope(TEACHER_GROUP_IN_A));
assertEquals("学校ID必须为学校分组", error.getMessage());
}
/** A09:学校管理员只能命中本校 */
@Test
void requireSchoolInScopeRejectsAnotherSchoolForSchoolAdmin() {
givenSingleSchoolAdmin();
when(sysGroupRepository.findById(SCHOOL_B))
.thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(SCHOOL_B));
assertTrue(error.getMessage().contains("暂无权限"));
}
@Test
void requireSchoolInScopeAcceptsOwnSchool() {
givenSingleSchoolAdmin();
when(sysGroupRepository.findById(SCHOOL_A))
.thenReturn(Optional.of(group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0)));
accessScopeService.requireSchoolInScope(SCHOOL_A);
}
@Test
void requireSchoolInScopeAcceptsAnySchoolForSuper() {
givenSuper();
when(sysGroupRepository.findById(SCHOOL_B))
.thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
accessScopeService.requireSchoolInScope(SCHOOL_B);
}
// ===================== resolveWritableSchoolId =====================
/** 单校管理员不传 schoolId → 取本校(写口仍落在权威学校上) */
@Test
void resolveWritableSchoolIdUsesOwnSchoolWhenNotSpecified() {
givenSingleSchoolAdmin();
assertEquals(SCHOOL_A, accessScopeService.resolveWritableSchoolId(null));
}
/** 单校管理员传别校 id → 拒绝,且不回落到本校(不能静默改写用户意图) */
@Test
void resolveWritableSchoolIdRejectsForeignSchool() {
givenSingleSchoolAdmin();
DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(SCHOOL_B));
assertTrue(error.getMessage().contains("暂无权限"));
}
@Test
void resolveWritableSchoolIdAcceptsMatchedOwnSchool() {
givenSingleSchoolAdmin();
assertEquals(SCHOOL_A, accessScopeService.resolveWritableSchoolId(SCHOOL_A));
}
/** 多校管理员未指定学校 → 要求补齐,不猜学校 */
@Test
void resolveWritableSchoolIdRequiresChoiceForMultiSchoolAdmin() {
givenTwoSchoolAdminWithoutActiveRole();
DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(null));
assertTrue(error.getMessage().contains("请选择"));
}
/** 未登录(UserContext 为空 → NONE)→ 暂无权限 */
@Test
void resolveWritableSchoolIdRejectsAnonymous() {
DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(SCHOOL_A));
assertEquals("暂无权限", error.getMessage());
}
/** SUPER:以请求学校为准(校维度 guard 兜范围) */
@Test
void resolveWritableSchoolIdUsesRequestedSchoolForSuper() {
givenSuper();
when(sysGroupRepository.findById(SCHOOL_B))
.thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
assertEquals(SCHOOL_B, accessScopeService.resolveWritableSchoolId(SCHOOL_B));
}
}