Compare commits
29
Commits
6cc1bc23f7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f60d65195 | ||
|
|
809d88c838 | ||
|
|
72fe0f218a | ||
|
|
b1b769a1b8 | ||
|
|
bf3f3344c1 | ||
|
|
996eed3caf | ||
|
|
8e7b69c795 | ||
|
|
7247c1193a | ||
|
|
ddaa3a6f42 | ||
|
|
cb40524b8e | ||
|
|
195936236d | ||
|
|
ad434dbc36 | ||
|
|
035df02f63 | ||
|
|
2b754c08ca | ||
|
|
336b50675c | ||
|
|
fe6c07cc4b | ||
|
|
c790187f35 | ||
|
|
d7d239e77c | ||
|
|
34750c03ac | ||
|
|
96855a8565 | ||
|
|
705cbde7cf | ||
|
|
90c08a5f14 | ||
|
|
f0eaebd186 | ||
|
|
8f8a6b8cca | ||
|
|
7e222f19b8 | ||
|
|
fe19954d8b | ||
|
|
257ba2dd64 | ||
|
|
3846ec21a9 | ||
|
|
0b4a596bce |
@@ -25,7 +25,22 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl(alpine)
|
- name: 安装 git / curl(alpine)
|
||||||
run: apk add --no-cache git curl
|
run: |
|
||||||
|
set -e
|
||||||
|
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
|
||||||
|
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
|
||||||
|
chosen=""
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
echo "apk 源不可达: ${host}"
|
||||||
|
done
|
||||||
|
if [ -z "${chosen}" ]; then echo "所有 apk 源均不可达,请检查 runner 容器网络/代理"; exit 1; fi
|
||||||
|
echo "使用 apk 源: ${chosen}"
|
||||||
|
sed -i "s#dl-cdn.alpinelinux.org#${chosen}#g" /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl
|
||||||
|
|
||||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -56,6 +71,18 @@ jobs:
|
|||||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
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 rm -f mavenbuild-${MB} 2>/dev/null || true
|
||||||
docker run -d --name mavenbuild-${MB} \
|
docker run -d --name mavenbuild-${MB} \
|
||||||
--network infra-infra-net \
|
--network infra-infra-net \
|
||||||
|
|||||||
@@ -195,6 +195,18 @@
|
|||||||
<groupId>com.yunhai.platform</groupId>
|
<groupId>com.yunhai.platform</groupId>
|
||||||
<artifactId>yh-env-config-spring-boot-starter</artifactId>
|
<artifactId>yh-env-config-spring-boot-starter</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- 服务间出站调用引擎(YschOpenCall 自动装配):外观代理端点的上游调用链。
|
||||||
|
UserBase 是聚合 starter 例外,须显式引入(Notice/svc-hall/flow-center 走聚合包已含)。 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.yunhai.platform</groupId>
|
||||||
|
<artifactId>yh-openapi-spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 代理 UserHome)。
|
||||||
|
平台件,不进聚合包;前端零消费(通道 init 裸 fetch),alova 勿生成定义。 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.yunhai.platform</groupId>
|
||||||
|
<artifactId>yh-appearance-spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与
|
<!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与
|
||||||
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
|
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 部门管理 ·「包含子组」落库(方案:部门管理-包含子组落库方案.md)
|
||||||
|
-- sys_group 增列 include_children:学校节点上的一个开关,
|
||||||
|
-- 1 = 部门列表连同下级部门一起列出(默认)/ 0 = 只列一级部门
|
||||||
|
-- 交付文件,不在本机执行;由人工在 160 执行
|
||||||
|
-- 执行前请备份;JPA 实体已同名映射,执行后重启后端即可生效
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 1. 增列 include_children
|
||||||
|
-- 粒度:1 学校 : 1 值,与 status 同为节点自身属性,故直接挂在 sys_group,不另建表
|
||||||
|
-- 默认 1:与升级前的页面表现一致,存量学校无需回填
|
||||||
|
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动加列,但**不带** NOT NULL/DEFAULT/COMMENT,
|
||||||
|
-- 存量会留 NULL。因此本脚本建议先执行,保证列定义可控;即使漏执行,前端已按 null = 含子组 兜底
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'sys_group'
|
||||||
|
AND COLUMN_NAME = 'include_children');
|
||||||
|
|
||||||
|
SET @sql := IF(@col = 0,
|
||||||
|
'ALTER TABLE sys_group ADD COLUMN include_children TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''包含子组:1=部门列表含下级部门,0=仅本级'' AFTER status',
|
||||||
|
'SELECT ''include_children already exists'' AS msg');
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 2. 核验(可选)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- SHOW COLUMNS FROM sys_group LIKE 'include_children';
|
||||||
|
-- SELECT COUNT(*) AS total, SUM(include_children = 1) AS include_on FROM sys_group;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 回滚(仅当确认不再需要该能力时执行;只加列不改列,代码回滚无需删列)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ALTER TABLE sys_group DROP COLUMN include_children;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- UserBase 双来源部门 · CUSTOM 侧建表(任务书 USERBASE_DEPARTMENT_QODER_TASK.md T1 / 规格书 UB-1)
|
||||||
|
-- 三张新表:ub_custom_dept_category(自定义分类)/ ub_custom_department(自定义部门)
|
||||||
|
-- / ub_custom_department_member(部门成员关系)
|
||||||
|
-- 交付文件,不在本机执行;由人工在 160 执行
|
||||||
|
-- 执行前请备份;JPA 实体与列同名映射,执行后重启后端即可生效
|
||||||
|
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动建表,但**不带** NOT NULL/DEFAULT/COMMENT/唯一键,
|
||||||
|
-- 且 del_marker 之类参与唯一约束的列必须由本脚本定义,故务必先执行本脚本
|
||||||
|
-- 口径:school_id = sys_group 中 type=2(学校组)节点的 id,与 AccessScopeService.scopeGroupIds() 同域
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 1. 自定义分类(学校的容器层,本身无启停、无成员)
|
||||||
|
-- 名称唯一:del_marker 技巧——MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住「软删后再激活同名」,
|
||||||
|
-- 故软删时把 del_marker 置为该行 id,未删行恒为 0,同校同名只能有一条**有效**行;
|
||||||
|
-- name 列永不改写(软删保留原名,保证历史解析拿到原名、旧引用不指向新对象)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS ub_custom_dept_category (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(不可变稳定 ID)',
|
||||||
|
school_id INT NOT NULL COMMENT '学校组 id(sys_group.type=2 的节点 id)',
|
||||||
|
name VARCHAR(64) NOT NULL COMMENT '分类名称,学校自定;永不改写',
|
||||||
|
sort_no INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
|
||||||
|
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(不是业务状态)',
|
||||||
|
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键,保证同名只挡有效行',
|
||||||
|
deleted_at DATETIME NULL COMMENT '软删时间',
|
||||||
|
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
|
||||||
|
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
|
||||||
|
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
|
||||||
|
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_school_name_marker (school_id, name, del_marker),
|
||||||
|
KEY idx_school_sort (school_id, is_deleted, sort_no)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门分类(学校级容器,非可选部门)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 2. 自定义部门(可配成员、可启停的实际可选对象)
|
||||||
|
-- 唯一键=同校同分类下未软删的部门名不重复;跨分类、SYSTEM 与 CUSTOM 之间允许重名(A06)
|
||||||
|
-- enabled 语义直读 1=启用/0=停用,**与 sys_group.status(0=启用/1=停用)方向相反,严禁混用**
|
||||||
|
-- 软删不级联删成员:成员关系保留在库,由查询侧按部门 is_deleted 过滤(A12)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS ub_custom_department (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(CUSTOM 侧独立 ID,与 SYSTEM 组 id 不同域,引用须带 departmentType)',
|
||||||
|
school_id INT NOT NULL COMMENT '学校组 id(冗余自分类,写口服务端复核)',
|
||||||
|
category_id INT NOT NULL COMMENT '所属分类 ub_custom_dept_category.id,必须同校',
|
||||||
|
name VARCHAR(64) NOT NULL COMMENT '部门名称,学校自定;永不改写',
|
||||||
|
sort_no INT NOT NULL DEFAULT 0 COMMENT '同分类内展示顺序,越小越靠前',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启停:1=启用 / 0=停用(新建默认启用)',
|
||||||
|
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(删除不是第三种业务状态)',
|
||||||
|
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键',
|
||||||
|
deleted_at DATETIME NULL COMMENT '软删时间',
|
||||||
|
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
|
||||||
|
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
|
||||||
|
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
|
||||||
|
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_school_category_name_marker (school_id, category_id, name, del_marker),
|
||||||
|
KEY idx_category_list (category_id, is_deleted, sort_no),
|
||||||
|
KEY idx_school_list (school_id, is_deleted, enabled)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学校自定义部门(与 SYSTEM 组织树完全分离)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 3. 自定义部门成员关系(引用 sys_user.id,不复制账号)
|
||||||
|
-- 移除成员=物理删该关系行(本仓无软删惯例,增删审计靠 @OperationLog),不得残留有效成员
|
||||||
|
-- uk 挡不住并发重复插入之外的场景(如软删部门后重新激活)由服务端逐个复核兜底
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS ub_custom_department_member (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
school_id INT NOT NULL COMMENT '学校组 id(与部门同校,写口服务端复核)',
|
||||||
|
department_id INT NOT NULL COMMENT 'ub_custom_department.id',
|
||||||
|
user_id INT NOT NULL COMMENT 'sys_user.id(本校教职工,排除学生组成员)',
|
||||||
|
create_user INT NOT NULL COMMENT '添加人 sys_user.id(BaseEntity)',
|
||||||
|
create_time DATETIME NOT NULL COMMENT '添加时间(BaseEntity)',
|
||||||
|
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
|
||||||
|
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_department_user (department_id, user_id),
|
||||||
|
KEY idx_department (department_id),
|
||||||
|
KEY idx_school (school_id),
|
||||||
|
KEY idx_user (user_id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门成员关系(一个用户可属同校多个部门)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 4. 核验(可选,执行后逐条确认)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- SHOW CREATE TABLE ub_custom_dept_category;
|
||||||
|
-- SHOW CREATE TABLE ub_custom_department;
|
||||||
|
-- SHOW CREATE TABLE ub_custom_department_member;
|
||||||
|
-- SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
|
||||||
|
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'ub_custom%';
|
||||||
|
-- 唯一键生效自检(手工插入应报 1062):
|
||||||
|
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW());
|
||||||
|
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW()); -- 期望失败
|
||||||
|
-- DELETE FROM ub_custom_dept_category WHERE school_id = 1 AND name = '自检分类';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 回滚(本期未上线,回滚即删表;CUSTOM 数据不与 sys_group 有任何写耦合)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- DROP TABLE IF EXISTS ub_custom_department_member;
|
||||||
|
-- DROP TABLE IF EXISTS ub_custom_department;
|
||||||
|
-- DROP TABLE IF EXISTS ub_custom_dept_category;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- sys_group 加启停状态列(2026-10-20 部门线 P0 最小批)
|
||||||
|
-- 目标环境:160(MySQL 8,容器网 mysql8:3306,库见 UserBase 生产连接串)
|
||||||
|
-- 幂等:INFORMATION_SCHEMA 判列存在即跳过,可重复执行;160 一律以本脚本为准(不依赖 ddl-auto)
|
||||||
|
-- 回滚:ALTER TABLE sys_group DROP COLUMN status;(存量数据全 0=启用,回滚无行为影响)
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS add_sys_group_status $$
|
||||||
|
CREATE PROCEDURE add_sys_group_status()
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'sys_group'
|
||||||
|
AND COLUMN_NAME = 'status'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sys_group
|
||||||
|
ADD COLUMN status TINYINT NOT NULL DEFAULT 0
|
||||||
|
COMMENT '0 启用 / 1 停用(停用后新建选不到,已有引用与在途流程不受影响)';
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
|
||||||
|
CALL add_sys_group_status() $$
|
||||||
|
DROP PROCEDURE IF EXISTS add_sys_group_status $$
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- 执行后核对(计数入台账,业务数据不入):
|
||||||
|
-- SELECT status, COUNT(*) FROM sys_group GROUP BY status; -- 预期全部 status=0
|
||||||
@@ -19,7 +19,7 @@ import java.util.List;
|
|||||||
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
|
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
|
||||||
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
|
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
|
||||||
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
|
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
|
||||||
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br>
|
* 注册于 /api/**(显式前缀,路径闭集),排除 /oapi/**(服务间签名调用,行为不变)。<br>
|
||||||
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
|
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
|
||||||
*
|
*
|
||||||
* @author chenx
|
* @author chenx
|
||||||
@@ -29,16 +29,18 @@ import java.util.List;
|
|||||||
public class ManageAccessInterceptor implements HandlerInterceptor {
|
public class ManageAccessInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 白名单(Ant 风格,* 匹配 platform 前缀):
|
* 白名单(显式 /api 前缀,路径闭集):
|
||||||
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
|
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
|
||||||
|
* appearance 外观偏好读(仅登录即可读自身偏好,平台 yh-appearance-spring-boot-starter 端点);
|
||||||
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
|
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
|
||||||
*/
|
*/
|
||||||
private static final List<String> WHITELIST = Arrays.asList(
|
private static final List<String> WHITELIST = Arrays.asList(
|
||||||
"/*/base/GetUserInfo",
|
"/api/base/GetUserInfo",
|
||||||
"/*/base/GetUserScope",
|
"/api/base/GetUserScope",
|
||||||
"/*/base/GetSysDict",
|
"/api/base/GetSysDict",
|
||||||
"/*/base/RedirectTo/*",
|
"/api/base/RedirectTo/*",
|
||||||
"/*/env-config");
|
"/api/appearance",
|
||||||
|
"/api/env-config");
|
||||||
|
|
||||||
private static final AntPathMatcher MATCHER = new AntPathMatcher();
|
private static final AntPathMatcher MATCHER = new AntPathMatcher();
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,20 @@ public final class CacheNames {
|
|||||||
*/
|
*/
|
||||||
public static final String USER_APP_LIST = "userAppList";
|
public static final String USER_APP_LIST = "userAppList";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校维度应用清单(GET /oapi/app/list-by-school 读路径,2026-10-10;流程中心 flw_app 源池)。<br>
|
||||||
|
* key 规则:{@code 's:' + schoolId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())}——
|
||||||
|
* 学校 id 不可枚举,用户级变更不涉及(学校层无用户),授权/组/应用/别名事件一律整清,
|
||||||
|
* 其余直改 DB 场景靠 TTL 自愈(同 USER_APP_LIST 显式学校后缀策略);<br>
|
||||||
|
* TTL 60s(同 USER_APP_LIST 档位:脏了=流程中心显示过期应用池,短档足够);<br>
|
||||||
|
* 存放:Redis;依赖表:sys_group_app_rel、app_category、application_info、
|
||||||
|
* sys_school_app_override、sys_school_category_override;<br>
|
||||||
|
* 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(授权域写口整清,含学校别名)+
|
||||||
|
* {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
|
||||||
|
* (组开通面变化 / 应用启停排序删除整清)。
|
||||||
|
*/
|
||||||
|
public static final String SCHOOL_APP_LIST = "schoolAppList";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。<br>
|
* 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。<br>
|
||||||
* key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
|
* key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
|
||||||
@@ -234,6 +248,12 @@ public final class CacheNames {
|
|||||||
"'u:'+userId(整张 groupId→status Map)",
|
"'u:'+userId(整张 groupId→status Map)",
|
||||||
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
|
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
|
||||||
"sys_user_group_rel"));
|
"sys_user_group_rel"));
|
||||||
|
m.put(SCHOOL_APP_LIST, new Meta(SCHOOL_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
|
||||||
|
"'s:'+schoolId+':c:'+(clientType?.name()?:'all')(8 client × 学校 id,不可枚举→整清)",
|
||||||
|
"AppAuthzChangedEvent(整清,含学校别名写口)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent"
|
||||||
|
+ "(整清)→ CacheInvalidationListener;直改 DB 靠 TTL 自愈",
|
||||||
|
"sys_group_app_rel, app_category, application_info, sys_group,"
|
||||||
|
+ " sys_school_app_override, sys_school_category_override"));
|
||||||
REGISTRY = Collections.unmodifiableMap(m);
|
REGISTRY = Collections.unmodifiableMap(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ public enum Config
|
|||||||
Inst;
|
Inst;
|
||||||
|
|
||||||
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
|
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
|
||||||
// "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
|
// "/api//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
|
||||||
// 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。
|
// 会导致 /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 static final String OPEN_API_PATH = "/oapi/";
|
||||||
|
|
||||||
public String getString(String key)
|
public String getString(String key)
|
||||||
|
|||||||
@@ -38,11 +38,12 @@ public class DWebMvcConfig implements WebMvcConfigurer
|
|||||||
.addPathPatterns("/**")
|
.addPathPatterns("/**")
|
||||||
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
|
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
|
||||||
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。
|
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。
|
||||||
// /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。
|
// 注册 /api/**(显式前缀,路径闭集:/manage、/{platform} 通配退役,旧前缀随之 404,
|
||||||
|
// 同时堵掉"任意前缀绕过白名单"的洞);显式排除 /oapi/**(服务间签名调用不受影响)。
|
||||||
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
|
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
|
||||||
// /v3/api-docs、/swagger-ui:接口文档工具链。
|
// /v3/api-docs、/swagger-ui:接口文档工具链。
|
||||||
registry.addInterceptor(manageAccessInterceptor)
|
registry.addInterceptor(manageAccessInterceptor)
|
||||||
.addPathPatterns("/{platform}/**")
|
.addPathPatterns("/api/**")
|
||||||
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
|
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
|
||||||
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
|
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
|
||||||
"/webjars/**");
|
"/webjars/**");
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.yunhai.userbase.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「整应用授权」的 module_id 哨兵编码。<br>
|
||||||
|
* <p>
|
||||||
|
* 授权关系表 {@code sys_group_module_perm} / {@code sys_app_role_module_rel} 都只有
|
||||||
|
* (owner_id, module_id) 两列,<b>没有 app_id</b>。当某个应用下没有任何 {@code app_module} 记录时
|
||||||
|
* (如「一周安排」这类单页应用),模块级授权无处可挂,于是用 <b>-appId</b> 作为该应用
|
||||||
|
* 「整体已授权」的哨兵值:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>不是 workaround</b>:module_id 命名空间里真实模块 id 恒为正整数,负数段天然是空闲的
|
||||||
|
* 应用级通道,与真实模块不会冲突;</li>
|
||||||
|
* <li>不同应用编码不同,满足 {@code uk_group_module(group_id, module_id)} /
|
||||||
|
* {@code uk_app_role_module(role_id, module_id)} 唯一键,同组/同角色可同时授权多个无模块应用;</li>
|
||||||
|
* <li>权限判定链路(hasModulePermission / collectEnabledRoleModuleIds 等)按真实 moduleId 匹配,
|
||||||
|
* 哨兵值永远匹配不上,不会被误判成某个模块的权限。</li>
|
||||||
|
* </ul>
|
||||||
|
* 写入侧由 Service 内部把「入参 moduleId=0(整应用语义)」翻译成本编码,前端无需感知。
|
||||||
|
*/
|
||||||
|
public final class AppLevelModuleId {
|
||||||
|
|
||||||
|
private AppLevelModuleId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用ID → 整应用授权的哨兵 module_id(-appId)。appId 为空时返回 null。
|
||||||
|
*/
|
||||||
|
public static Integer of(Integer appId) {
|
||||||
|
return appId == null ? null : -appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 哨兵 module_id → 应用ID;传入真实模块 id(正数)或 null 时返回 null。
|
||||||
|
*/
|
||||||
|
public static Integer resolveAppId(Integer moduleId) {
|
||||||
|
return (moduleId != null && moduleId < 0) ? -moduleId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为整应用授权的哨兵值。
|
||||||
|
*/
|
||||||
|
public static boolean isAppLevel(Integer moduleId) {
|
||||||
|
return moduleId != null && moduleId < 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.yunhai.userbase.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用性质(application_info.app_nature)。<br>
|
||||||
|
* BASE=基石应用:平台基建身份(UserHome / 移动门户等),需要出站签名身份但不进门户开通/授权/模块管理;<br>
|
||||||
|
* BUSINESS=业务应用:门户可见,参与开通/授权。<br>
|
||||||
|
* 与 app_category 的「基础应用」分类无关——那边是"类型",这边是"性质"(两界面不同屏,存储层并存)。
|
||||||
|
*/
|
||||||
|
public enum AppNature { BASE, BUSINESS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserBase 自身的 appKey —— appKey 从 100000 起自增,"基石应用段"起点(application_bootstrap.sql)。<br>
|
||||||
|
* 它是验签的管理者,不拿自己的身份被验签,故不标 app_nature,由 {@link #isUserbaseSelf} 覆盖。
|
||||||
|
*/
|
||||||
|
public static final int USER_CENTER_APP_KEY = 100000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 条件一:UserBase 自身(延续原「appKey > 100000」排除逻辑的取反语义,行为不变)。
|
||||||
|
*/
|
||||||
|
public static boolean isUserbaseSelf(Integer appKey) {
|
||||||
|
return appKey != null && appKey <= USER_CENTER_APP_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 条件二:显式标记的基石应用(UserHome / 移动门户等)。
|
||||||
|
*/
|
||||||
|
public static boolean isMarkedBase(String appNature) {
|
||||||
|
return BASE.name().equals(appNature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否基石应用(不参与应用开通 / 授权 / 模块管理等业务功能)。<br>
|
||||||
|
* 两条件取「或」。注意 JPQL @Query 内无法调用本方法——Repository 三处为常量拼接的 SQL 形态,
|
||||||
|
* 语义单点仍在本枚举,改动须同步(含三值逻辑 NULL 分支)。
|
||||||
|
*/
|
||||||
|
public static boolean isBaseApp(Integer appKey, String appNature) {
|
||||||
|
return isUserbaseSelf(appKey) || isMarkedBase(appNature);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ import com.yunhai.userbase.service.resolve.AccessScopeService;
|
|||||||
import com.yunhai.userbase.utils.PageUtils;
|
import com.yunhai.userbase.utils.PageUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.web.SortDefault;
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -195,8 +194,14 @@ public class AppSettingsController {
|
|||||||
|
|
||||||
@GetMapping("/AppList")
|
@GetMapping("/AppList")
|
||||||
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
|
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
|
||||||
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort,
|
|
||||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||||
|
// 排序必须带唯一兜底键:同键行 MySQL 不保证稳定序,加删一行即整组洗牌(应用列表乱序的根因)。
|
||||||
|
// 全部(未选类别)= id 升序——sort_no 是「类型内」序号,跨类型打平无全局意义;
|
||||||
|
// 选中类别 = 未排序(NULL)沉底 + sortNo 升序 + id 兜底——由 Specification 内嵌 orderBy 实现
|
||||||
|
// (NULL 沉底须 case 表达式,Sort 只走属性解析表达不了;此处传 unsorted,否则 Pageable 排序会覆盖 spec 的 orderBy)
|
||||||
|
Sort sort = query.getCategoryId() == null
|
||||||
|
? Sort.by(Sort.Direction.ASC, "id")
|
||||||
|
: Sort.unsorted();
|
||||||
if (isSchoolLevel()) {
|
if (isSchoolLevel()) {
|
||||||
// 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校)
|
// 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校)
|
||||||
AccessScope scope = accessScopeService.resolveCurrentScope();
|
AccessScope scope = accessScopeService.resolveCurrentScope();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import java.util.TreeSet;
|
|||||||
/**
|
/**
|
||||||
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
|
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
|
||||||
*
|
*
|
||||||
* {@code GET /{platform}/cache/registry} 输出三块内容:<br>
|
* {@code GET /api/cache/registry} 输出三块内容:<br>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
|
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
|
||||||
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
|
* × 运行时是否已出现 × 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.common.validation.ValidationGroups;
|
||||||
import com.yunhai.userbase.config.Config;
|
import com.yunhai.userbase.config.Config;
|
||||||
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
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.SysGroupMoveRequest;
|
||||||
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
||||||
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||||
@@ -65,6 +66,46 @@ public class SysGroupController {
|
|||||||
sysGroupService.delete(id);
|
sysGroupService.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=1,不碰其他任何表与列。<br>
|
||||||
|
* 不判组类型(status 当前无消费方);删除行为不动。范式对齐 SysUserController.Disable。
|
||||||
|
*/
|
||||||
|
@OperationLog("停用组")
|
||||||
|
@Operation(operationId = "sysGroupDisable")
|
||||||
|
@PostMapping("Disable/{id}")
|
||||||
|
public void disable(@PathVariable Integer id) {
|
||||||
|
accessScopeService.requireGroupInScope(id);
|
||||||
|
accessScopeService.requireGroupModifiable(id);
|
||||||
|
sysGroupService.changeStatus(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=0。
|
||||||
|
*/
|
||||||
|
@OperationLog("启用组")
|
||||||
|
@Operation(operationId = "sysGroupEnable")
|
||||||
|
@PostMapping("Enable/{id}")
|
||||||
|
public void enable(@PathVariable Integer id) {
|
||||||
|
accessScopeService.requireGroupInScope(id);
|
||||||
|
accessScopeService.requireGroupModifiable(id);
|
||||||
|
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")
|
@Operation(operationId = "sysGroupList")
|
||||||
@GetMapping("/List")
|
@GetMapping("/List")
|
||||||
public List<SysGroupVo> list(SysGroupQuery query) {
|
public List<SysGroupVo> list(SysGroupQuery query) {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author chenx
|
* @author chenx
|
||||||
@@ -203,4 +206,22 @@ public class SysUserController {
|
|||||||
requireQueryGroupInScope(groupId);
|
requireQueryGroupInScope(groupId);
|
||||||
sysUserService.exportUsers(groupId, withSub, name, response);
|
sysUserService.exportUsers(groupId, withSub, name, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 id 批量取用户展示信息(id/账号/姓名),管理面聚合页(消息/待办追溯台收件明细)回显名称用。<br>
|
||||||
|
* 只读窄投影:不透出手机号/身份证等敏感列;用户目录属平台主数据,仅超管。
|
||||||
|
*/
|
||||||
|
@GetMapping("ByIds")
|
||||||
|
public List<Map<String, Object>> byIds(@RequestParam("ids") List<Integer> ids) {
|
||||||
|
accessScopeService.requireSuper();
|
||||||
|
return sysUserService.listByIds(ids).stream()
|
||||||
|
.map(u -> {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("id", u.getId());
|
||||||
|
item.put("account", u.getAccount());
|
||||||
|
item.put("realName", u.getRealName());
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import com.yunhai.platform.utils.enums.IEnum;
|
|||||||
import com.yunhai.platform.utils.model.BoaAppType;
|
import com.yunhai.platform.utils.model.BoaAppType;
|
||||||
import com.yunhai.userbase.config.Config;
|
import com.yunhai.userbase.config.Config;
|
||||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.model.application.AppActivatedVo;
|
||||||
|
import com.yunhai.userbase.model.application.AppIconMapVo;
|
||||||
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||||
import com.yunhai.userbase.service.IAppCategoryService;
|
import com.yunhai.userbase.service.IAppCategoryService;
|
||||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||||
|
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -33,6 +37,12 @@ public class AppController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private IApplicationInfoService applicationInfoService;
|
private IApplicationInfoService applicationInfoService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IGroupAuthzService groupAuthzService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.yunhai.userbase.service.ISysGroupService sysGroupService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按主键 ID 查询应用信息(带缓存)。<br>
|
* 按主键 ID 查询应用信息(带缓存)。<br>
|
||||||
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
|
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
|
||||||
@@ -43,7 +53,53 @@ public class AppController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("{appId}")
|
@GetMapping("{appId}")
|
||||||
public ApplicationInfoVo byId(@PathVariable Integer appId) {
|
public ApplicationInfoVo byId(@PathVariable Integer appId) {
|
||||||
ApplicationInfo info = applicationInfoService.getById(appId);
|
return toVo(applicationInfoService.getById(appId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按技术标识查询应用信息(跳转查询键主用端点)。<br>
|
||||||
|
* app_code = projman app.name(如 notice/flow-center),即消息 {@code jump_app} 的值域;<br>
|
||||||
|
* 查不到返回 null(与 byId 契约一致)。返回视图清空 appSecret。
|
||||||
|
*
|
||||||
|
* @param appCode 技术标识(必填)
|
||||||
|
* @return 应用信息视图;不存在时返回 null
|
||||||
|
*/
|
||||||
|
@GetMapping("by-code/{appCode}")
|
||||||
|
public ApplicationInfoVo byCode(@PathVariable String appCode) {
|
||||||
|
return toVo(applicationInfoService.getByAppCode(appCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按整型 appKey 查询应用信息(兼容兜底端点,存量自发场景按数字回落)。<br>
|
||||||
|
* ⚠️ 查的是 app_key 列而非主键 id——实测 id ≠ app_key(15 行中仅 2 行相等,
|
||||||
|
* 如流程中心 appKey=100009 的主键是 100011),按 id 查会把「流程中心」查成「课堂作业」。<br>
|
||||||
|
* 查不到返回 null;返回视图清空 appSecret。
|
||||||
|
*
|
||||||
|
* @param appKey 应用标识(必填)
|
||||||
|
* @return 应用信息视图;不存在时返回 null
|
||||||
|
*/
|
||||||
|
@GetMapping("by-key/{appKey}")
|
||||||
|
public ApplicationInfoVo byKey(@PathVariable Integer appKey) {
|
||||||
|
return toVo(applicationInfoService.getByAppKey(appKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户有权应用的 app_code 集合(消息跳转判权,C1)。<br>
|
||||||
|
* 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}(2026-10-12 硬闸门 D2 收敛:
|
||||||
|
* 走生效学校锚点单点开通集合,不沿祖先、角色不补充未开通应用,超级管理员返回全部),
|
||||||
|
* 再映射为 app_code(空值行跳过)。只读,不透出 appSecret。<br>
|
||||||
|
* 消费方(UserHome 消息代理)以「集合是否包含消息 jump_app」决定是否显示跳转入口;
|
||||||
|
* 本端点不可达时消费方放行(不判权),绝不因判权通道故障批量隐藏入口。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID(必填)
|
||||||
|
* @return 有权应用的 app_code 集合(可能为空,不为 null)
|
||||||
|
*/
|
||||||
|
@GetMapping("authorized-codes")
|
||||||
|
public List<String> authorizedCodes(@RequestParam("userId") Integer userId) {
|
||||||
|
return applicationInfoService.listAppCodesByIds(groupAuthzService.findUserAuthorizedAppIds(userId, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApplicationInfoVo toVo(ApplicationInfo info) {
|
||||||
if (info == null) {
|
if (info == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -56,8 +112,9 @@ public class AppController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br>
|
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br>
|
||||||
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
|
* 权限过滤(2026-10-12 学校单点开通硬闸门)复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)}
|
||||||
* 超级管理员返回全部;不透出 appSecret。<br>
|
* ——生效学校锚点单点开通的应用集合(不沿祖先、角色授权不再补充学校未开通的应用),超级管理员返回全部;
|
||||||
|
* 不透出 appSecret。<br>
|
||||||
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br>
|
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br>
|
||||||
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
|
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
|
||||||
* 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
|
* 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
|
||||||
@@ -75,4 +132,60 @@ public class AppController {
|
|||||||
: IEnum.getByKey(ClientEnum.class, clientType);
|
: IEnum.getByKey(ClientEnum.class, clientType);
|
||||||
return appCategoryService.listBoaAppType(userId, clientEnum, schoolId);
|
return appCategoryService.listBoaAppType(userId, clientEnum, schoolId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按学校维度查询该校已开通的应用列表(按应用类型分组,已合并学校别名,2026-10-10)。<br>
|
||||||
|
* 开通口径 = 学校组<b>单点</b>已开通(sys_group_app_rel where group_id=schoolId;开通不继承,
|
||||||
|
* 不并入子组/角色模块应用)——学校把应用只开给子组时本端点看不到,属口径自然结果。<br>
|
||||||
|
* 基石应用(UserBase 自身 / BASE)不返回;不透出 appSecret。<br>
|
||||||
|
* schoolId 必填且须为合法学校组锚点,否则 code=5。<br>
|
||||||
|
* 消费方(flow-center 应用设置)可传 clientType=null 不过滤端,避免漏纯移动端应用。
|
||||||
|
*
|
||||||
|
* @param schoolId 学校组ID(必填)
|
||||||
|
* @param clientType 客户端类型 key(可选;null=不按端过滤)
|
||||||
|
* @return 应用类型分组列表;未开通应用的类型 apps 为空数组
|
||||||
|
*/
|
||||||
|
@GetMapping("list-by-school")
|
||||||
|
public List<BoaAppType> listBySchool(@RequestParam("schoolId") Integer schoolId,
|
||||||
|
@RequestParam(value = "clientType", required = false) Integer clientType) {
|
||||||
|
ClientEnum clientEnum = (clientType == null) ? null
|
||||||
|
: IEnum.getByKey(ClientEnum.class, clientType);
|
||||||
|
return appCategoryService.listBoaAppTypeBySchool(schoolId, clientEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量应用矢量图标映射(AppIcon 资产库通道):[{id, iconKey, iconColor}],仅含已配置行。<br>
|
||||||
|
* 背景:BoaApp 属 BasePlatform SDK 本期不动,/oapi/app/list 出参无矢量字段;
|
||||||
|
* UserHome(PC GetUserApp)与 MobilePortal(移动端桌面)拉本端点后在各自出参上合并注入。<br>
|
||||||
|
* 只读,无敏感字段;消费方拉取失败时按空映射降级,端侧回退链不受影响。
|
||||||
|
*/
|
||||||
|
@GetMapping("icon-map")
|
||||||
|
public List<AppIconMapVo> iconMap() {
|
||||||
|
return applicationInfoService.listIconMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用×学校单点开通判定(2026-10-25 T01 最小只读口)。<br>
|
||||||
|
* 权威事实源 = {@code sys_group_app_rel}(组应用开通表,学校组单点、不沿祖先、不并角色模块),
|
||||||
|
* 与 {@code GET /oapi/app/list-by-school} 同一口径,只是收敛成 boolean 判定——
|
||||||
|
* form-center/flow-center 等下游把本端点作为「该应用是否在该校开通」的平台资格第二重证明
|
||||||
|
* (fail-closed:本端点不可达时下游必须拒绝,不得降级为"仅验签通过")。<br>
|
||||||
|
* 应用不存在(含已注销)返回 activated=false,不区分"没开通"与"没这应用",
|
||||||
|
* 避免把应用存在性做成探测面;schoolId 非法(非学校组锚点)按既有 {@code assertSchoolId}
|
||||||
|
* 口径抛业务错误(code=5)。只读、无敏感字段。
|
||||||
|
*
|
||||||
|
* @param appId 应用主键 ID(必填;稳定主体,非 appKey 凭据索引)
|
||||||
|
* @param schoolId 学校组 ID(必填)
|
||||||
|
*/
|
||||||
|
@GetMapping("activated")
|
||||||
|
public AppActivatedVo activated(@RequestParam("appId") Integer appId,
|
||||||
|
@RequestParam("schoolId") Integer schoolId) {
|
||||||
|
if (appId == null || schoolId == null) {
|
||||||
|
throw new DException("appId 与 schoolId 必填");
|
||||||
|
}
|
||||||
|
sysGroupService.assertSchoolId(schoolId);
|
||||||
|
boolean appExists = applicationInfoService.getById(appId) != null;
|
||||||
|
boolean activated = appExists && groupAuthzService.findAuthorizedAppIds(schoolId).contains(appId);
|
||||||
|
return new AppActivatedVo(appId, schoolId, activated);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,8 @@ public class IdentityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户全部可选身份(正常态),按 主身份优先 → sortNo → id 排序,供「切换身份」页渲染。
|
* 用户全部可选身份(正常态),按 主身份所在学校置顶 → 其他学校 schoolId 升序 → 校内 roleId 升序,
|
||||||
|
* 供「切换身份」页渲染。
|
||||||
*/
|
*/
|
||||||
@GetMapping("available")
|
@GetMapping("available")
|
||||||
public List<BoaRoleInfo> available(@RequestParam("userId") Integer userId) {
|
public List<BoaRoleInfo> available(@RequestParam("userId") Integer userId) {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import com.yunhai.platform.utils.model.BoaUser;
|
|||||||
import com.yunhai.userbase.common.annotation.OapiLog;
|
import com.yunhai.userbase.common.annotation.OapiLog;
|
||||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
import com.yunhai.userbase.config.Config;
|
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.BatchChangeStatusRequest;
|
||||||
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
|
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
|
||||||
|
import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
|
||||||
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
|
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
|
||||||
import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest;
|
import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest;
|
||||||
import com.yunhai.userbase.model.sysuser.SysUserMoveRequest;
|
import com.yunhai.userbase.model.sysuser.SysUserMoveRequest;
|
||||||
@@ -90,30 +92,49 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
|
* 查询用户在<b>指定应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
|
||||||
* <br>
|
* <br>
|
||||||
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br>
|
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按单一应用维度过滤。应用来源两态:<br>
|
||||||
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter
|
* ① 传了可选参数 appCode → 按 application_info.app_code 解析<b>目标应用</b>(供托管型应用
|
||||||
* 已保证非空);<br>
|
* 如 form-center 代快应用渲染导航时查询目标应用的模块清单;查不到返回空列表并 warn,
|
||||||
* ② 查 application_info 映射到具体 appId;<br>
|
* 口径同现有兜底);<br>
|
||||||
|
* ② 未传 appCode → 现行为不变:通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的
|
||||||
|
* appKey(RequireAppSignFilter 已保证非空),查 application_info 映射到具体 appId;<br>
|
||||||
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
|
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
|
||||||
* <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 userId 用户ID(必填)
|
||||||
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
|
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
|
||||||
* 为空时取用户全部真实归属组,与权限一览一致)
|
* 为空时取用户全部真实归属组,与权限一览一致)
|
||||||
|
* @param appCode 目标应用技术标识(可选;传=查该应用的模块树,不传=按签名调用方解析)
|
||||||
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
|
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
|
||||||
*/
|
*/
|
||||||
@GetMapping("modules")
|
@GetMapping("modules")
|
||||||
public List<BoaModule> modules(HttpServletRequest request,
|
public List<BoaModule> modules(HttpServletRequest request,
|
||||||
@RequestParam("userId") Integer userId,
|
@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) {
|
if (userId == null) {
|
||||||
return Collections.emptyList();
|
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) {
|
if (appId == null) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
@@ -240,6 +261,19 @@ public class UserController {
|
|||||||
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
|
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本人联系方式修改(手机号/邮箱):body {userId, phone, email},phone/email 至少一项非空,
|
||||||
|
* 空白=不修改该字段;只更新 sys_user 的 phone/email 两字段(其余一律不动),
|
||||||
|
* 手机号/邮箱格式与长度校验、手机号唯一查重在 Service 层。返回更新后的用户
|
||||||
|
* (getUserInfoWithDeptById,事务提交后缓存已被 UserChangedEvent 驱逐,回读即新值)。<br>
|
||||||
|
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
|
||||||
|
*/
|
||||||
|
@PostMapping("self/contact")
|
||||||
|
public BoaUser selfContact(@RequestBody @Validated SysUserContactRequest request) {
|
||||||
|
sysUserService.updateSelfContact(request.getUserId(), request.getPhone(), request.getEmail());
|
||||||
|
return userCenterService.getUserInfoWithDeptById(request.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
|
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
|
||||||
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
|
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
|
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
|
||||||
|
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
|
||||||
*/
|
*/
|
||||||
@NotBlank
|
@Column(name = "custom_id", nullable = true, length = 64)
|
||||||
@Column(name = "custom_id", nullable = false, length = 64)
|
|
||||||
private String customId;
|
private String customId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ import java.util.List;
|
|||||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
public class ApplicationInfo extends BaseEntity {
|
public class ApplicationInfo extends BaseEntity {
|
||||||
|
|
||||||
|
/** Immutable application incarnation; never derive it from reusable numeric IDs or credentials. */
|
||||||
|
@Column(name = "source_principal_id", unique = true, updatable = false, length = 36)
|
||||||
|
private String sourcePrincipalId;
|
||||||
|
|
||||||
|
@javax.persistence.PrePersist
|
||||||
|
void assignSourcePrincipal() {
|
||||||
|
// Always generate server-side, including when an entity supplied a value.
|
||||||
|
sourcePrincipalId = java.util.UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用名称(页面*必填)
|
* 应用名称(页面*必填)
|
||||||
*/
|
*/
|
||||||
@@ -62,6 +72,19 @@ public class ApplicationInfo extends BaseEntity {
|
|||||||
@Column(name = "app_secret", nullable = false, length = 128)
|
@Column(name = "app_secret", nullable = false, length = 128)
|
||||||
private String appSecret;
|
private String appSecret;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 技术标识 = projman app.name(如 notice/flow-center),跨系统稳定引用键。<br>
|
||||||
|
* 跳转查询键(sys_message.jump_app 值域);引用方存的是字符串,改名须盘点引用。
|
||||||
|
*/
|
||||||
|
@Column(name = "app_code", length = 32)
|
||||||
|
private String appCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用性质:BASE=基石 / BUSINESS=业务(判定口径单点见 {@link com.yunhai.userbase.constant.AppNature})。
|
||||||
|
*/
|
||||||
|
@Column(name = "app_nature", length = 16)
|
||||||
|
private String appNature;
|
||||||
|
|
||||||
//应用前端类型
|
//应用前端类型
|
||||||
@Convert(converter = ListIntegerJsonConverter.class)
|
@Convert(converter = ListIntegerJsonConverter.class)
|
||||||
@Column(name = "clients", columnDefinition = "json")
|
@Column(name = "clients", columnDefinition = "json")
|
||||||
@@ -91,12 +114,31 @@ public class ApplicationInfo extends BaseEntity {
|
|||||||
@Column(name = "app_icon")
|
@Column(name = "app_icon")
|
||||||
private String appIcon;
|
private String appIcon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标键(AppIcon 资产库图标名,如 tongzhi)。空 = 未配置,端侧走 关键词映射 → URL 位图 → 首字 原回退链。<br>
|
||||||
|
* 不复用 pc_icon/app_icon:那两列存完整 URL,被 oapi BoaApp 出参与 appIconPath() 按 URL 语义消费(方案 §5.2)。
|
||||||
|
*/
|
||||||
|
@Column(name = "icon_key", length = 64)
|
||||||
|
private String iconKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标着色(CSS 色值,如 #366EF4)。空 = 端侧默认(场景 token / 分组色)。人工配置值非派生值,落库安全(方案 §5.3)。
|
||||||
|
*/
|
||||||
|
@Column(name = "icon_color", length = 16)
|
||||||
|
private String iconColor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公众号应用地址
|
* 公众号应用地址
|
||||||
*/
|
*/
|
||||||
@Column(name = "official_account_url")
|
@Column(name = "official_account_url")
|
||||||
private String officialAccountUrl;
|
private String officialAccountUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序应用地址
|
||||||
|
*/
|
||||||
|
@Column(name = "miniprogram_url")
|
||||||
|
private String miniprogramUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 钉钉应用地址
|
* 钉钉应用地址
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ public class SysGroup extends BaseEntity {
|
|||||||
|
|
||||||
private Integer sort;
|
private Integer sort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启停状态(2026-10-20 部门线 P0 最小批):0=启用 / 1=停用,对齐 flw_dept 枚举。<br>
|
||||||
|
* 停用只影响「选」(新建选择器过滤),不影响「看」(展示/指南页)与「办」(在途流程解析);
|
||||||
|
* 当前无消费方,语义在未来部门场景(P1)生效。翻转只允许走 {@code updateStatus} 单列 UPDATE。
|
||||||
|
*/
|
||||||
|
@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)
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.yunhai.userbase.model.application;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用×学校单点开通判定结果(2026-10-25 T01 最小只读口)。
|
||||||
|
* 只回判定所需三件,不透出 secret/别名等管理信息;消费方(form-center/flow-center)
|
||||||
|
* 把它作为「该应用是否在该校开通」的平台事实第二重证明,fail-closed 消费。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AppActivatedVo {
|
||||||
|
|
||||||
|
/** 应用主键 ID(稳定主体,与入参一致) */
|
||||||
|
private Integer appId;
|
||||||
|
|
||||||
|
/** 学校组 ID(与入参一致) */
|
||||||
|
private Integer schoolId;
|
||||||
|
|
||||||
|
/** true=该校单点开通集合包含该应用(sys_group_app_rel 口径,不沿祖先) */
|
||||||
|
private boolean activated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.yunhai.userbase.model.application;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用矢量图标映射条目({@code /oapi/app/icon-map} 出参)。<br>
|
||||||
|
* 消费方:UserHome(PC GetUserApp 合并注入)与 MobilePortal(移动端桌面 AppItemVo 合并)。<br>
|
||||||
|
* 只透出 id/iconKey/iconColor 三字段,不含名称/地址等其余应用信息。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AppIconMapVo {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用主键 ID(与 BoaApp.id 同源,消费方按此合并)。
|
||||||
|
*/
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标键(AppIcon 资产库图标名);null 表示该行不携带,消费方跳过。
|
||||||
|
*/
|
||||||
|
private String iconKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标着色(CSS 色值);null 表示端侧默认。
|
||||||
|
*/
|
||||||
|
private String iconColor;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import lombok.Data;
|
|||||||
|
|
||||||
import javax.validation.constraints.NotBlank;
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
|
import javax.validation.constraints.Size;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,22 @@ public class ApplicationInfoRequest {
|
|||||||
@NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空")
|
@NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空")
|
||||||
private Integer categoryId;
|
private Integer categoryId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 技术标识(= projman app.name,如 notice),全库唯一,新增与编辑均必填。<br>
|
||||||
|
* 编辑必填(2026-09-28 拍板):app_code 是应用身份锚点(消息 jump_app/服务目录键),不许置空;
|
||||||
|
* 存量 null 历史行不追溯,但编辑提交时必须补填。值域为引用键,改名须盘点引用。
|
||||||
|
*/
|
||||||
|
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "技术标识不能为空")
|
||||||
|
@Size(max = 32, message = "技术标识长度不能超过32")
|
||||||
|
private String appCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用性质:BASE=基石 / BUSINESS=业务,新增与编辑均必填(2026-09-28 拍板,存量 null 行不追溯)。
|
||||||
|
*/
|
||||||
|
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用性质不能为空")
|
||||||
|
@Size(max = 16, message = "应用性质长度不能超过16")
|
||||||
|
private String appNature;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用描述。<br>
|
* 应用描述。<br>
|
||||||
*/
|
*/
|
||||||
@@ -64,11 +81,26 @@ public class ApplicationInfoRequest {
|
|||||||
*/
|
*/
|
||||||
private String appIcon;
|
private String appIcon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标键(AppIcon 资产库图标名)。空 = 不指定(编辑时空值不回写,沿用原值)。<br>
|
||||||
|
*/
|
||||||
|
private String iconKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标着色(CSS 色值)。空 = 端侧默认。编辑时空值不回写。<br>
|
||||||
|
*/
|
||||||
|
private String iconColor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公众号应用地址。<br>
|
* 公众号应用地址。<br>
|
||||||
*/
|
*/
|
||||||
private String officialAccountUrl;
|
private String officialAccountUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序应用地址。<br>
|
||||||
|
*/
|
||||||
|
private String miniprogramUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 钉钉应用地址。<br>
|
* 钉钉应用地址。<br>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class ApplicationInfoVo {
|
public class ApplicationInfoVo {
|
||||||
|
/** Stable, non-secret application incarnation used for source isolation. */
|
||||||
|
private String sourcePrincipalId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主键ID。<br>
|
* 主键ID。<br>
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +50,16 @@ public class ApplicationInfoVo {
|
|||||||
*/
|
*/
|
||||||
private String appSecret;
|
private String appSecret;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 技术标识(= projman app.name,如 notice)。<br>
|
||||||
|
*/
|
||||||
|
private String appCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用性质:BASE=基石 / BUSINESS=业务。<br>
|
||||||
|
*/
|
||||||
|
private String appNature;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用前端类型列表。<br>
|
* 应用前端类型列表。<br>
|
||||||
*/
|
*/
|
||||||
@@ -72,11 +85,26 @@ public class ApplicationInfoVo {
|
|||||||
*/
|
*/
|
||||||
private String appIcon;
|
private String appIcon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标键(AppIcon 资产库图标名);空 = 未配置。<br>
|
||||||
|
*/
|
||||||
|
private String iconKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标着色(CSS 色值);空 = 端侧默认。<br>
|
||||||
|
*/
|
||||||
|
private String iconColor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公众号应用地址。<br>
|
* 公众号应用地址。<br>
|
||||||
*/
|
*/
|
||||||
private String officialAccountUrl;
|
private String officialAccountUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序应用地址。<br>
|
||||||
|
*/
|
||||||
|
private String miniprogramUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 钉钉应用地址。<br>
|
* 钉钉应用地址。<br>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ public class AppModuleRequest {
|
|||||||
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
|
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空")
|
/**
|
||||||
|
* 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
|
||||||
|
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
|
||||||
|
*/
|
||||||
private String customId;
|
private String customId;
|
||||||
|
|
||||||
/** 模块路径(前端路由/资源路径)。<br> */
|
/** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
|
||||||
private String path;
|
private String path;
|
||||||
|
|
||||||
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
|
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ public class AppRoleAuthzAppVo {
|
|||||||
/** 排序号。 */
|
/** 排序号。 */
|
||||||
private Integer sortNo;
|
private Integer sortNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用级勾选态:<b>仅当该应用下没有任何模块时</b>才有效。<br>
|
||||||
|
* 此时授权落在 sys_app_role_module_rel 的 -appId 哨兵记录上(见 {@code AppLevelModuleId});
|
||||||
|
* 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
|
||||||
|
*/
|
||||||
|
private Boolean checked = Boolean.FALSE;
|
||||||
|
|
||||||
/** 该应用下的模块权限树(含勾选回显)。 */
|
/** 该应用下的模块权限树(含勾选回显)。 */
|
||||||
private List<AppRoleAuthzModuleVo> modules = new ArrayList<>();
|
private List<AppRoleAuthzModuleVo> modules = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -30,6 +30,16 @@ public class GroupAuthzAppVo {
|
|||||||
|
|
||||||
private Integer sortNo;
|
private Integer sortNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用级勾选态:<b>仅当该应用下没有任何模块时</b>才有效。<br>
|
||||||
|
* 此时授权落在 sys_group_module_perm 的 module_id=0 记录上(整应用授权);
|
||||||
|
* 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
|
||||||
|
*/
|
||||||
|
private Boolean checked = Boolean.FALSE;
|
||||||
|
|
||||||
|
/** 应用级子组继承:仅当该应用下没有模块时有意义,对应 module_id=0 记录的 inherit_flag。 */
|
||||||
|
private Boolean inheritFlag = Boolean.FALSE;
|
||||||
|
|
||||||
/** 该应用下的模块权限树;默认空列表,避免 JSON 序列化出现 null。 */
|
/** 该应用下的模块权限树;默认空列表,避免 JSON 序列化出现 null。 */
|
||||||
private List<GroupAuthzModuleVo> modules = new ArrayList<>();
|
private List<GroupAuthzModuleVo> modules = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ public class GroupModuleAuthzItem {
|
|||||||
/**
|
/**
|
||||||
* 模块ID;传 0 表示「该应用下全部模块」。<br>
|
* 模块ID;传 0 表示「该应用下全部模块」。<br>
|
||||||
* 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,<br>
|
* 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,<br>
|
||||||
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。
|
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。<br>
|
||||||
|
* 特殊情形:应用下<b>一个模块都没有</b>时,改写成 -appId 的整应用哨兵记录
|
||||||
|
* (见 {@code AppLevelModuleId}),保证这类应用也能被授权与回显。
|
||||||
*/
|
*/
|
||||||
@NotNull(message = "模块ID不能为空(整应用传 0)")
|
@NotNull(message = "模块ID不能为空(整应用传 0)")
|
||||||
private Integer moduleId;
|
private Integer moduleId;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.yunhai.userbase.model.sysgroup;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oapi 组树出参扩展(2026-10-20 部门线 P0 最小批):在平台模型 {@link BoaGroup} 之上透出启停状态。<br>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code status}:0=启用 / 1=停用(sys_group.status 原值,语义=停用只影响「选」不影响「看」与「办」);</li>
|
||||||
|
* <li>反序列化兼容:字段只增不改,消费方(svc-hall / flow-center)经 starter 注入的 Spring ObjectMapper
|
||||||
|
* 解析,未知字段默认忽略——不升级消费方 SDK 也能照常工作;需要读 status 时按同名 JSON 键取即可;</li>
|
||||||
|
* <li>不动 BasePlatform 的 BoaGroup(平台模型加字段要发 SDK 版本,超出最小批)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class BoaGroupStatusVo extends BoaGroup {
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
public Integer getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(Integer status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,6 +16,17 @@ public class SysGroupVo {
|
|||||||
private String name;
|
private String name;
|
||||||
private Integer type;
|
private Integer type;
|
||||||
private Integer sort;
|
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。 */
|
/** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */
|
||||||
private List<SysGroupVo> children = new ArrayList<>();
|
private List<SysGroupVo> children = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.yunhai.userbase.model.sysuser;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import javax.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本人联系方式修改请求(oapi {@code /oapi/user/self/contact} 专用)。<br>
|
||||||
|
* 手机号/邮箱至少一项非空(Service 层校验);字段级格式、长度与唯一性校验同在 Service 层
|
||||||
|
* (手机号口径与 {@code validateProfile} 一致,另做邮箱格式 + 手机号查重)。
|
||||||
|
*
|
||||||
|
* @author chenx
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SysUserContactRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID(必填)。
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号(可选;非空时走 1[3-9] 段位格式校验 + 全库唯一查重)。
|
||||||
|
*/
|
||||||
|
@Size(max = 20, message = "手机号长度不能超过20字符")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱(可选;非空时走邮箱格式校验)。
|
||||||
|
*/
|
||||||
|
@Size(max = 100, message = "邮箱长度不能超过100字符")
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
@@ -17,7 +17,16 @@ import java.util.List;
|
|||||||
* @description
|
* @description
|
||||||
*/
|
*/
|
||||||
public interface ApplicationInfoRepository extends JpaRepository<ApplicationInfo, Integer>, JpaSpecificationExecutor<ApplicationInfo> {
|
public interface ApplicationInfoRepository extends JpaRepository<ApplicationInfo, Integer>, JpaSpecificationExecutor<ApplicationInfo> {
|
||||||
@Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000")
|
/** 读取持久化标识快照;禁止查询前自动 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 " +
|
||||||
|
"AND (a.appNature IS NULL OR a.appNature <> 'BASE')")
|
||||||
Integer countByCategoryId(@Param("categoryId") Integer id);
|
Integer countByCategoryId(@Param("categoryId") Integer id);
|
||||||
|
|
||||||
boolean existsByName(String name);
|
boolean existsByName(String name);
|
||||||
@@ -58,23 +67,39 @@ public interface ApplicationInfoRepository extends JpaRepository<ApplicationInf
|
|||||||
/**
|
/**
|
||||||
* 按应用类型查询排序所需的精简视图(仅 id/name/sortNo),不分页。<br>
|
* 按应用类型查询排序所需的精简视图(仅 id/name/sortNo),不分页。<br>
|
||||||
* 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。<br>
|
* 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。<br>
|
||||||
* 排除用户中心自身的基石应用(appKey=100000)。
|
* 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。
|
||||||
*
|
*
|
||||||
* @param categoryId 应用类型ID
|
* @param categoryId 应用类型ID
|
||||||
* @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底
|
* @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底
|
||||||
*/
|
*/
|
||||||
@Query("SELECT new com.yunhai.userbase.model.application.ApplicationInfoSortVo(a.id, a.name, a.sortNo) " +
|
@Query("SELECT new com.yunhai.userbase.model.application.ApplicationInfoSortVo(a.id, a.name, a.sortNo) " +
|
||||||
"FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 ORDER BY a.sortNo ASC, a.id ASC")
|
"FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " +
|
||||||
|
"AND (a.appNature IS NULL OR a.appNature <> 'BASE') ORDER BY a.sortNo ASC, a.id ASC")
|
||||||
List<ApplicationInfoSortVo> findSortVoByCategoryId(@Param("categoryId") Integer categoryId);
|
List<ApplicationInfoSortVo> findSortVoByCategoryId(@Param("categoryId") Integer categoryId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。<br>
|
* 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。<br>
|
||||||
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。<br>
|
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。<br>
|
||||||
* 排除用户中心自身的基石应用(appKey=100000)。
|
* 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。
|
||||||
*
|
*
|
||||||
* @return 全部应用的授权投影行
|
* @return 全部应用的授权投影行
|
||||||
*/
|
*/
|
||||||
@Query("SELECT new com.yunhai.userbase.model.appauthz.AppAuthzAppRow(a.categoryId, a.id, a.name, a.pcIcon) " +
|
@Query("SELECT new com.yunhai.userbase.model.appauthz.AppAuthzAppRow(a.categoryId, a.id, a.name, a.pcIcon) " +
|
||||||
"FROM ApplicationInfo a WHERE a.appKey > 100000 ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC")
|
"FROM ApplicationInfo a WHERE a.appKey > 100000 " +
|
||||||
|
"AND (a.appNature IS NULL OR a.appNature <> 'BASE') ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC")
|
||||||
List<AppAuthzAppRow> findAllAuthzRows();
|
List<AppAuthzAppRow> findAllAuthzRows();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按技术标识查单个应用(app_code = projman app.name,跳转查询键 /oapi/app/by-code 的数据面)。<br>
|
||||||
|
*
|
||||||
|
* @param appCode 技术标识(如 notice)
|
||||||
|
* @return 匹配的应用实体,无则 null
|
||||||
|
*/
|
||||||
|
ApplicationInfo findByAppCode(String appCode);
|
||||||
|
|
||||||
|
/** 技术标识唯一性校验(新增用)。 */
|
||||||
|
boolean existsByAppCode(String appCode);
|
||||||
|
|
||||||
|
/** 技术标识唯一性校验(编辑排除自身用)。 */
|
||||||
|
boolean existsByAppCodeAndIdNot(String appCode, Integer id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package com.yunhai.userbase.repository;
|
|||||||
import com.yunhai.userbase.entity.SysGroup;
|
import com.yunhai.userbase.entity.SysGroup;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
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.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -15,6 +17,22 @@ import java.util.Optional;
|
|||||||
*/
|
*/
|
||||||
public interface SysGroupRepository extends JpaRepository<SysGroup, Integer>, JpaSpecificationExecutor<SysGroup> {
|
public interface SysGroupRepository extends JpaRepository<SysGroup, Integer>, JpaSpecificationExecutor<SysGroup> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启停状态单列 UPDATE(2026-10-20 部门线 P0):@Modifying JPQL 绕过实体监听器与审计列,
|
||||||
|
* 严格只动 sys_group.status 一列(验收断言:翻转前后其余列逐字不变)。
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@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")
|
@Query("SELECT MAX(g.sort) FROM SysGroup g")
|
||||||
Integer findMaxSort();
|
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);
|
||||||
|
}
|
||||||
@@ -39,7 +39,9 @@ public interface IAppCategoryService {
|
|||||||
* 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组),
|
* 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组),
|
||||||
* 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。<br>
|
* 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。<br>
|
||||||
* 规则:<br>
|
* 规则:<br>
|
||||||
* ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});<br>
|
* ① 权限过滤(2026-10-12 学校单点开通硬闸门):仅返回<b>生效学校锚点单点开通</b>的应用
|
||||||
|
* (复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)} 收窄口径,
|
||||||
|
* 不沿祖先链、角色授权不补充未开通应用;超管保留全量 bypass);<br>
|
||||||
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);<br>
|
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);<br>
|
||||||
* ③ 不透出 appSecret;<br>
|
* ③ 不透出 appSecret;<br>
|
||||||
* ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖,
|
* ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖,
|
||||||
@@ -55,4 +57,21 @@ public interface IAppCategoryService {
|
|||||||
*/
|
*/
|
||||||
List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId);
|
List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用:按学校维度查询该校已开通的应用列表(按应用类型分组,2026-10-10 流程中心应用设置源池)。<br>
|
||||||
|
* 规则:<br>
|
||||||
|
* ① 开通口径:<b>仅学校组自身已开通</b>(sys_group_app_rel where group_id=schoolId,开通不继承、
|
||||||
|
* 不并入子组/角色模块应用)——学校层无用户,这是"学校维度应用清单"的唯一定义;<br>
|
||||||
|
* ② 基石应用过滤:同 {@link #listBoaAppType}(UserBase 自身 / BASE 应用不出现);<br>
|
||||||
|
* ③ 客户端过滤:clientType 位掩码(null=不按端过滤);<br>
|
||||||
|
* ④ 不透出 appSecret;<br>
|
||||||
|
* ⑤ 学校别名:按该校合并 sys_school_app_override / sys_school_category_override(同 listBoaAppType ④)。<br>
|
||||||
|
* schoolId 必填且须为合法学校锚点(assertSchoolId),非法抛 DException(code=5)。
|
||||||
|
*
|
||||||
|
* @param schoolId 学校组ID(必填)
|
||||||
|
* @param clientType 客户端类型(null=不按端过滤)
|
||||||
|
* @return 应用类型 + 该校已开通应用列表(已合并学校别名)
|
||||||
|
*/
|
||||||
|
List<BoaAppType> listBoaAppTypeBySchool(Integer schoolId, ClientEnum clientType);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yunhai.userbase.service;
|
package com.yunhai.userbase.service;
|
||||||
|
|
||||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.model.application.AppIconMapVo;
|
||||||
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
||||||
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
||||||
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
||||||
@@ -11,6 +12,7 @@ import com.yunhai.userbase.model.application.PushSettingVo;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +30,32 @@ public interface IApplicationInfoService {
|
|||||||
*/
|
*/
|
||||||
Integer findIdByAppKey(Integer appKey);
|
Integer findIdByAppKey(Integer appKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按技术标识(app_code = projman app.name)查应用:/oapi/app/by-code 端点的数据面。<br>
|
||||||
|
*
|
||||||
|
* @param appCode 技术标识(如 notice)
|
||||||
|
* @return 应用实体;无则 null
|
||||||
|
*/
|
||||||
|
ApplicationInfo getByAppCode(String appCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按整型 appKey 查应用(查 app_key 列,非主键 id):/oapi/app/by-key 兜底端点的数据面。<br>
|
||||||
|
* 复用 appKeyToId + appInfo 两级缓存。
|
||||||
|
*
|
||||||
|
* @param appKey 应用标识
|
||||||
|
* @return 应用实体;无则 null
|
||||||
|
*/
|
||||||
|
ApplicationInfo getByAppKey(Integer appKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按应用 id 集合取技术标识 app_code 列表(/oapi/app/authorized-codes 数据面,判权由调用方完成)。<br>
|
||||||
|
* 顺序跟随入参集合;app_code 为空的行跳过(存量历史行允许 null);结果按首次出现去重。
|
||||||
|
*
|
||||||
|
* @param ids 应用主键 id 集合
|
||||||
|
* @return app_code 列表(可能为空,不为 null)
|
||||||
|
*/
|
||||||
|
List<String> listAppCodesByIds(Collection<Integer> ids);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存/更新应用信息(写后同步更新缓存)。
|
* 保存/更新应用信息(写后同步更新缓存)。
|
||||||
*/
|
*/
|
||||||
@@ -106,4 +134,11 @@ public interface IApplicationInfoService {
|
|||||||
* @return 推送设置 VO(仅含推送相关字段)
|
* @return 推送设置 VO(仅含推送相关字段)
|
||||||
*/
|
*/
|
||||||
PushSettingVo getPushSetting(Integer id);
|
PushSettingVo getPushSetting(Integer id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量应用的矢量图标映射(id → iconKey/iconColor),{@code /oapi/app/icon-map} 数据面。<br>
|
||||||
|
* 仅返回 iconKey 非空的行;供 UserHome / MobilePortal 在既有 BoaApp 列表之上合并注入
|
||||||
|
* (BoaApp 属 BasePlatform SDK 本期不动,矢量字段走本独立通道补拉)。
|
||||||
|
*/
|
||||||
|
List<AppIconMapVo> listIconMap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -151,6 +151,10 @@ public interface IGroupAuthzService {
|
|||||||
* (子树内无归属组则仅靠角色授权,不用 schId 冒充本组);schId 为空=取用户全部真实归属组
|
* (子树内无归属组则仅靠角色授权,不用 schId 冒充本组);schId 为空=取用户全部真实归属组
|
||||||
* (与权限一览「用户全局模式」一致)。<br>
|
* (与权限一览「用户全局模式」一致)。<br>
|
||||||
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。<br>
|
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。<br>
|
||||||
|
* 学校单点开通硬闸门(2026-10-12):超管短路之后、归属组解析之前,先判定目标应用是否在
|
||||||
|
* <b>生效学校锚点自身</b>的开通集合({@link #findAuthorizedAppIds(Integer)} 口径,不沿祖先、不并角色);
|
||||||
|
* 未开通直接返回空列表——角色授权与祖先组开通均不得绕过。生效锚点=显式 schId(过 assertSchoolId)
|
||||||
|
* → 当前激活身份学校 → 主归属组向上最近锚点;均解析不到=空。<br>
|
||||||
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。<br>
|
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。<br>
|
||||||
* <br>
|
* <br>
|
||||||
* 树形语义(2026-09-15 定稿):顶层只放真根(parentId 为空),兄弟按 customId 升序,叶子 children 为空列表;
|
* 树形语义(2026-09-15 定稿):顶层只放真根(parentId 为空),兄弟按 customId 升序,叶子 children 为空列表;
|
||||||
@@ -209,13 +213,31 @@ public interface IGroupAuthzService {
|
|||||||
void setGroupModulePerms(List<GroupModuleAuthzItem> items);
|
void setGroupModulePerms(List<GroupModuleAuthzItem> items);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询用户有权访问的应用ID集合(oapi 开放接口 getUserApp 权限过滤用)。<br>
|
* 查询用户有权访问的应用ID集合(历史全局口径:组开通沿祖先 ∪ 启用角色模块所属应用,超管全部)。<br>
|
||||||
* 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:<br>
|
* 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:<br>
|
||||||
* - 超级管理员:全部应用;<br>
|
* - 超级管理员:全部应用;<br>
|
||||||
* - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。<br>
|
* - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。<br>
|
||||||
|
* <br>
|
||||||
|
* ⚠️ 2026-10-12 学校单点开通硬闸门后,用户端应用可见性(门户列表 / authorized-codes 判权)一律走学校收窄重载
|
||||||
|
* {@link #findUserAuthorizedAppIds(Integer, Integer)};本方法现仅由该重载的超管分支内部委托,
|
||||||
|
* 不再作为对外应用可见性口径。
|
||||||
*
|
*
|
||||||
* @param userId 用户ID
|
* @param userId 用户ID
|
||||||
* @return 有权应用ID列表(无则空列表)
|
* @return 有权应用ID列表(无则空列表)
|
||||||
*/
|
*/
|
||||||
List<Integer> findUserAuthorizedAppIds(Integer userId);
|
List<Integer> findUserAuthorizedAppIds(Integer userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按生效学校收窄的用户有权应用集合(2026-10-12 学校单点开通硬闸门,门户应用列表链口径)。<br>
|
||||||
|
* 语义 = <b>生效学校锚点单点开通的应用集合</b>(sys_group_app_rel 本组,不沿祖先、不并角色模块):<br>
|
||||||
|
* - 超级管理员:保留 bypass,返回全部应用(同无学校版口径);<br>
|
||||||
|
* - schoolId 非空:过 assertSchoolId 锚点校验后按该校自身单点判定;<br>
|
||||||
|
* - schoolId 为空:按「当前激活身份学校 → 主归属组向上最近锚点(学校/区域/根)」兜底,
|
||||||
|
* 区域/根锚点按该锚点自身单点判定;均解析不到(无学校语境)→ 空列表。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID(必填)
|
||||||
|
* @param schoolId 学校上下文(可选;null 走身份/主归属兜底)
|
||||||
|
* @return 生效学校单点开通的应用ID列表(无则空列表)
|
||||||
|
*/
|
||||||
|
List<Integer> findUserAuthorizedAppIds(Integer userId, Integer schoolId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,24 @@ public interface ISysGroupService {
|
|||||||
|
|
||||||
void delete(Integer id);
|
void delete(Integer id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启停状态翻转(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status,不碰其他任何表与列。<br>
|
||||||
|
* 不判组类型(status 当前无消费方,语义只在未来部门场景生效);删除行为一字不动(沿用 deleteInternal 现行规则)。
|
||||||
|
*
|
||||||
|
* @param id 组ID
|
||||||
|
* @param disabled true=停用(status=1)/ false=启用(status=0)
|
||||||
|
*/
|
||||||
|
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>
|
* 开放接口专用:在学校(组)范围内删除组。<br>
|
||||||
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
|
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.SysUser;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -25,6 +27,13 @@ public interface ISysUserService {
|
|||||||
/** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。返回创建后的用户ID。 */
|
/** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。返回创建后的用户ID。 */
|
||||||
Integer add(SysUserRequest request);
|
Integer add(SysUserRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 id 批量取用户实体(窄用途:管理面聚合页回显 id/账号/姓名)。<br>
|
||||||
|
* 只读;入参去重、去 null、截断到 200 个;查无返回空列表。调用方(控制器)自行投影白名单字段,
|
||||||
|
* 不得把实体直接序列化出站(实体含手机号/身份证等敏感列)。
|
||||||
|
*/
|
||||||
|
List<SysUser> listByIds(List<Integer> ids);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编辑用户(管理端);密码为空时不修改密码。<br>
|
* 编辑用户(管理端);密码为空时不修改密码。<br>
|
||||||
* 用户身份按「schoolId 该校全量覆盖」写(勾选框语义);带了 schoolId 却不带 roleIds 直接报错,
|
* 用户身份按「schoolId 该校全量覆盖」写(勾选框语义);带了 schoolId 却不带 roleIds 直接报错,
|
||||||
@@ -209,6 +218,19 @@ public interface ISysUserService {
|
|||||||
*/
|
*/
|
||||||
void changePassword(Integer userId, String oldPassword, String newPassword);
|
void changePassword(Integer userId, String oldPassword, String newPassword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改本人联系方式(手机号/邮箱,oapi 开放接口 {@code /oapi/user/self/contact} 专用)。<br>
|
||||||
|
* 只写 sys_user 的 phone/email 两字段,其余字段一律不动;phone/email 至少一项非空,
|
||||||
|
* 空白字段视为「不修改该字段」(不写库不清空);手机号改值时做全库唯一查重(phone 是
|
||||||
|
* byPhone 身份维度,重复会破坏手机号找人)。更新后发 UserChangedEvent 精确驱逐 boaUser
|
||||||
|
* 缓存(含旧 phone: 维度 key 提示),调用方紧跟着的 byId 回读即新值。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID(目标用户本人)
|
||||||
|
* @param phone 新手机号(可空=不改)
|
||||||
|
* @param email 新邮箱(可空=不改)
|
||||||
|
*/
|
||||||
|
void updateSelfContact(Integer userId, String phone, String email);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储至 avatar 子目录 →
|
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储至 avatar 子目录 →
|
||||||
* 写入 profile_picture。oapi 开放接口专用;返回新头像完整 Web URL。
|
* 写入 profile_picture。oapi 开放接口专用;返回新头像完整 Web URL。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.yunhai.platform.utils.model.BoaApp;
|
|||||||
import com.yunhai.platform.utils.model.BoaAppType;
|
import com.yunhai.platform.utils.model.BoaAppType;
|
||||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||||
import com.yunhai.userbase.config.CacheNames;
|
import com.yunhai.userbase.config.CacheNames;
|
||||||
|
import com.yunhai.userbase.constant.AppNature;
|
||||||
import com.yunhai.userbase.entity.AppCategory;
|
import com.yunhai.userbase.entity.AppCategory;
|
||||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||||
@@ -197,7 +198,10 @@ public class AppCategoryService implements IAppCategoryService {
|
|||||||
public List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) {
|
public List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) {
|
||||||
// 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并
|
// 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并
|
||||||
Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId);
|
Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId);
|
||||||
Set<Integer> authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId));
|
// 权限过滤走学校单点开通收窄口径(2026-10-12 硬闸门):生效锚点自身开通的应用集合,
|
||||||
|
// 角色授权不再补充「学校未开通」的应用;锚点解析不到(无学校语境)= 空集合。
|
||||||
|
Set<Integer> authorizedAppIds =
|
||||||
|
new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId, effectiveSchool));
|
||||||
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
|
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
|
||||||
List<ApplicationInfo> apps = applicationInfoRepository.findAll();
|
List<ApplicationInfo> apps = applicationInfoRepository.findAll();
|
||||||
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
|
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
|
||||||
@@ -220,6 +224,14 @@ public class AppCategoryService implements IAppCategoryService {
|
|||||||
type.setName(category.getName());
|
type.setName(category.getName());
|
||||||
List<BoaApp> boaApps = new ArrayList<>();
|
List<BoaApp> boaApps = new ArrayList<>();
|
||||||
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
|
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
|
||||||
|
// 基石应用过滤(口径单点 = AppNature.isBaseApp,与管理端 ①②③⑤ 同侧):
|
||||||
|
// UserBase 自身 / app_nature=BASE(UserHome、移动门户)不参与用户端应用列表,
|
||||||
|
// 否则超管(findUserAuthorizedAppIds 返回全量)会把基石带进门户 /apps。
|
||||||
|
// 与 manage 端列表(ApplicationInfoService.select,仅同步 isUserbaseSelf 语义、
|
||||||
|
// 保留基石供重置密钥)口径不同,勿合并。
|
||||||
|
if (AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// 用户权限过滤
|
// 用户权限过滤
|
||||||
if (!authorizedAppIds.contains(app.getId())) {
|
if (!authorizedAppIds.contains(app.getId())) {
|
||||||
continue;
|
continue;
|
||||||
@@ -236,6 +248,64 @@ public class AppCategoryService implements IAppCategoryService {
|
|||||||
return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, effectiveSchool);
|
return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, effectiveSchool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校维度应用清单(2026-10-10,GET /oapi/app/list-by-school 读路径;流程中心 flw_app 源池)。<br>
|
||||||
|
* 开通口径 = 学校组<b>单点</b>已开通(findAuthorizedAppIds(schId),不沿祖先/不并角色模块):
|
||||||
|
* 学校层无用户,"学校开了什么"就是 sys_group_app_rel 的单点事实——学校把应用只开给子组时
|
||||||
|
* 本端点看不到它,属口径自然结果(组开通继承的是模块权限池,不是学校清单)。<br>
|
||||||
|
* 骨架复用 {@link #listBoaAppType}(分类分组/基石过滤/端过滤/convertToBoaApp 不透 secret/
|
||||||
|
* applyAlias 别名合并),仅权限集合来源不同;返回前合并学校别名。<br>
|
||||||
|
* 缓存 key 规则:{@code 's:'+schoolId+':c:'+(clientType?'all')}——学校 id 不可枚举,
|
||||||
|
* 一律整清(授权/组/应用/别名事件)+ 60s TTL 自愈(同 USER_APP_LIST 显式学校后缀策略)。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Cacheable(value = CacheNames.SCHOOL_APP_LIST,
|
||||||
|
key = "'s:' + #schoolId + ':c:' + (#clientType == null ? 'all' : #clientType.name())",
|
||||||
|
sync = true)
|
||||||
|
public List<BoaAppType> listBoaAppTypeBySchool(Integer schoolId, ClientEnum clientType) {
|
||||||
|
if (schoolId == null) {
|
||||||
|
throw new DException("schoolId 必填");
|
||||||
|
}
|
||||||
|
sysGroupService.assertSchoolId(schoolId);
|
||||||
|
Set<Integer> openedAppIds = new HashSet<>(groupAuthzService.findAuthorizedAppIds(schoolId));
|
||||||
|
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
|
||||||
|
List<ApplicationInfo> apps = applicationInfoRepository.findAll();
|
||||||
|
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
|
||||||
|
.thenComparing(ApplicationInfo::getId, Comparator.nullsLast(Integer::compareTo)));
|
||||||
|
Map<Integer, List<ApplicationInfo>> appMap = new LinkedHashMap<>();
|
||||||
|
for (ApplicationInfo app : apps) {
|
||||||
|
if (app.getCategoryId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app);
|
||||||
|
}
|
||||||
|
Map<Integer, Integer> categoryBaseSortNo = new HashMap<>();
|
||||||
|
List<BoaAppType> result = new ArrayList<>();
|
||||||
|
for (AppCategory category : categories) {
|
||||||
|
categoryBaseSortNo.put(category.getId(), category.getSortNo());
|
||||||
|
BoaAppType type = new BoaAppType();
|
||||||
|
type.setId(category.getId());
|
||||||
|
type.setName(category.getName());
|
||||||
|
List<BoaApp> boaApps = new ArrayList<>();
|
||||||
|
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
|
||||||
|
if (AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 学校组单点开通过滤(与用户维度的权限过滤唯一差异)
|
||||||
|
if (!openedAppIds.contains(app.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (clientType != null && !containsClient(app.getClients(), clientType.getKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boaApps.add(convertToBoaApp(app));
|
||||||
|
}
|
||||||
|
type.setApps(boaApps);
|
||||||
|
result.add(type);
|
||||||
|
}
|
||||||
|
return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* oapi 生效学校三级兜底:<br>
|
* oapi 生效学校三级兜底:<br>
|
||||||
* ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组),
|
* ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组),
|
||||||
@@ -295,6 +365,7 @@ public class AppCategoryService implements IAppCategoryService {
|
|||||||
boa.setMobiIcon(app.getAppIcon());
|
boa.setMobiIcon(app.getAppIcon());
|
||||||
boa.setClients(toClientMask(app.getClients()));
|
boa.setClients(toClientMask(app.getClients()));
|
||||||
boa.setWxgzhAddress(app.getOfficialAccountUrl());
|
boa.setWxgzhAddress(app.getOfficialAccountUrl());
|
||||||
|
boa.setMiniprogramAddress(app.getMiniprogramUrl());
|
||||||
boa.setDingAddress(app.getDingtalkUrl());
|
boa.setDingAddress(app.getDingtalkUrl());
|
||||||
return boa;
|
return boa;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,12 +58,16 @@ public class AppModuleService implements IAppModuleService {
|
|||||||
if (!applicationInfoRepository.existsById(request.getAppId())) {
|
if (!applicationInfoRepository.existsById(request.getAppId())) {
|
||||||
throw new DException("所属应用不存在");
|
throw new DException("所属应用不存在");
|
||||||
}
|
}
|
||||||
if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) {
|
|
||||||
throw new DException("自定义ID在该应用下已存在");
|
|
||||||
}
|
|
||||||
AppModule entity = new AppModule();
|
AppModule entity = new AppModule();
|
||||||
BeanUtils.copyProperties(request, entity);
|
BeanUtils.copyProperties(request, entity);
|
||||||
entity.setId(null);
|
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()));
|
entity.setLevel(resolveLevel(request.getAppId(), request.getParentId()));
|
||||||
// sortNo 默认为空(由前端编辑时带入),不自动递增
|
// sortNo 默认为空(由前端编辑时带入),不自动递增
|
||||||
appModuleRepository.save(entity);
|
appModuleRepository.save(entity);
|
||||||
@@ -77,13 +81,15 @@ public class AppModuleService implements IAppModuleService {
|
|||||||
Objects.requireNonNull(request, "模块编辑入参不能为空");
|
Objects.requireNonNull(request, "模块编辑入参不能为空");
|
||||||
AppModule entity = appModuleRepository.findById(id)
|
AppModule entity = appModuleRepository.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("模块不存在: " + 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在该应用下已存在");
|
throw new DException("自定义ID在该应用下已存在");
|
||||||
}
|
}
|
||||||
// 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
|
|
||||||
entity.setName(request.getName());
|
entity.setName(request.getName());
|
||||||
entity.setCustomId(request.getCustomId());
|
entity.setCustomId(customId);
|
||||||
entity.setPath(request.getPath());
|
entity.setPath(normalizeBlankToNull(request.getPath()));
|
||||||
entity.setIcon(request.getIcon());
|
entity.setIcon(request.getIcon());
|
||||||
entity.setSortNo(request.getSortNo());
|
entity.setSortNo(request.getSortNo());
|
||||||
// parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级
|
// 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) {
|
private AppModuleVo convertToVo(AppModule entity) {
|
||||||
AppModuleVo vo = new AppModuleVo();
|
AppModuleVo vo = new AppModuleVo();
|
||||||
BeanUtils.copyProperties(entity, vo);
|
BeanUtils.copyProperties(entity, vo);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import com.yunhai.userbase.entity.SysGroup;
|
|||||||
import com.yunhai.userbase.entity.SysUser;
|
import com.yunhai.userbase.entity.SysUser;
|
||||||
import com.yunhai.userbase.entity.SysUserAppRoleRel;
|
import com.yunhai.userbase.entity.SysUserAppRoleRel;
|
||||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||||
|
import com.yunhai.userbase.constant.AppLevelModuleId;
|
||||||
|
import com.yunhai.userbase.constant.AppNature;
|
||||||
import com.yunhai.userbase.event.AppAuthzChangedEvent;
|
import com.yunhai.userbase.event.AppAuthzChangedEvent;
|
||||||
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
|
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
|
||||||
import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo;
|
import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo;
|
||||||
@@ -68,10 +70,6 @@ import java.util.Set;
|
|||||||
@Service
|
@Service
|
||||||
public class AppRoleService implements IAppRoleService {
|
public class AppRoleService implements IAppRoleService {
|
||||||
|
|
||||||
/** 用户中心自身基石应用的 appKey(application_info.appKey 从 100000 起自增,100000 即用户中心平台,
|
|
||||||
* 无应用类型;全应用授权范围须排除它——与 ApplicationInfoRepository「appKey > 100000」口径一致。 */
|
|
||||||
private static final int USER_CENTER_APP_KEY = 100000;
|
|
||||||
|
|
||||||
/** 组根节点的 parentId 约定值(与 GroupAuthzService.ROOT_PARENT_ID 同语义)。 */
|
/** 组根节点的 parentId 约定值(与 GroupAuthzService.ROOT_PARENT_ID 同语义)。 */
|
||||||
private static final int ROOT_PARENT_ID = 0;
|
private static final int ROOT_PARENT_ID = 0;
|
||||||
|
|
||||||
@@ -206,13 +204,14 @@ public class AppRoleService implements IAppRoleService {
|
|||||||
Objects.requireNonNull(roleId, "角色ID不能为空");
|
Objects.requireNonNull(roleId, "角色ID不能为空");
|
||||||
SysAppRole role = sysAppRoleRepository.findById(roleId)
|
SysAppRole role = sysAppRoleRepository.findById(roleId)
|
||||||
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
|
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
|
||||||
// 授权范围:普通角色仅本应用;全应用角色为系统全部应用(排除用户中心自身基石应用,
|
// 授权范围:普通角色仅本应用;全应用角色为系统全部应用(排除基石应用——口径 =
|
||||||
// 它无应用类型,前端按分类分组时会落进「未分类」组展示,不属于可授权业务应用)
|
// AppNature.isBaseApp:UserBase 自身无应用类型会落「未分类」组,显式标记 BASE 的
|
||||||
|
// 基建应用同样不属于可授权业务应用)
|
||||||
List<ApplicationInfo> apps;
|
List<ApplicationInfo> apps;
|
||||||
if (Boolean.TRUE.equals(role.getAllAppFlag())) {
|
if (Boolean.TRUE.equals(role.getAllAppFlag())) {
|
||||||
apps = new ArrayList<>();
|
apps = new ArrayList<>();
|
||||||
for (ApplicationInfo app : applicationInfoRepository.findAll()) {
|
for (ApplicationInfo app : applicationInfoRepository.findAll()) {
|
||||||
if (app.getAppKey() == null || app.getAppKey() > USER_CENTER_APP_KEY) {
|
if (!AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) {
|
||||||
apps.add(app);
|
apps.add(app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,8 +243,12 @@ public class AppRoleService implements IAppRoleService {
|
|||||||
AppRoleAuthzAppVo appVo = new AppRoleAuthzAppVo();
|
AppRoleAuthzAppVo appVo = new AppRoleAuthzAppVo();
|
||||||
BeanUtils.copyProperties(app, appVo);
|
BeanUtils.copyProperties(app, appVo);
|
||||||
List<AppModule> modules = appModuleMap.get(app.getId());
|
List<AppModule> modules = appModuleMap.get(app.getId());
|
||||||
if (modules != null) {
|
if (modules != null && !modules.isEmpty()) {
|
||||||
appVo.setModules(buildModuleTree(modules, checkedModuleIds));
|
appVo.setModules(buildModuleTree(modules, checkedModuleIds));
|
||||||
|
} else {
|
||||||
|
// 无子模块应用:授权挂在 -appId 的整应用哨兵记录上(见 AppLevelModuleId),
|
||||||
|
// 否则这类应用勾选态永远无回显(勾了刷新就丢)。
|
||||||
|
appVo.setChecked(checkedModuleIds.contains(AppLevelModuleId.of(app.getId())));
|
||||||
}
|
}
|
||||||
result.add(appVo);
|
result.add(appVo);
|
||||||
}
|
}
|
||||||
@@ -268,6 +271,18 @@ public class AppRoleService implements IAppRoleService {
|
|||||||
moduleMap.put(m.getId(), m);
|
moduleMap.put(m.getId(), m);
|
||||||
}
|
}
|
||||||
for (Integer moduleId : moduleIds) {
|
for (Integer moduleId : moduleIds) {
|
||||||
|
// 整应用授权哨兵(-appId):不走模块校验,改为校验应用存在性与角色授权范围
|
||||||
|
Integer appLevelAppId = AppLevelModuleId.resolveAppId(moduleId);
|
||||||
|
if (appLevelAppId != null) {
|
||||||
|
if (!applicationInfoRepository.existsById(appLevelAppId)) {
|
||||||
|
throw new DException("应用不存在: " + appLevelAppId);
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(role.getAllAppFlag())
|
||||||
|
&& !Objects.equals(appLevelAppId, role.getAppId())) {
|
||||||
|
throw new DException("应用 " + appLevelAppId + " 不属于该角色所属应用,普通角色不能跨应用授权");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
AppModule m = moduleMap.get(moduleId);
|
AppModule m = moduleMap.get(moduleId);
|
||||||
if (m == null) {
|
if (m == null) {
|
||||||
throw new DException("模块不存在: " + moduleId);
|
throw new DException("模块不存在: " + moduleId);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.yunhai.userbase.service.impl;
|
|||||||
import com.yunhai.userbase.entity.AppModule;
|
import com.yunhai.userbase.entity.AppModule;
|
||||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
import com.yunhai.userbase.entity.SysAppRole;
|
import com.yunhai.userbase.entity.SysAppRole;
|
||||||
|
import com.yunhai.userbase.constant.AppNature;
|
||||||
import com.yunhai.userbase.repository.AppModuleRepository;
|
import com.yunhai.userbase.repository.AppModuleRepository;
|
||||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
|
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
|
||||||
@@ -17,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import com.yunhai.userbase.model.application.AppIconMapVo;
|
||||||
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
||||||
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
||||||
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
||||||
@@ -36,8 +38,11 @@ import javax.persistence.criteria.Predicate;
|
|||||||
import javax.persistence.criteria.Root;
|
import javax.persistence.criteria.Root;
|
||||||
import javax.transaction.Transactional;
|
import javax.transaction.Transactional;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -98,11 +103,52 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
return app == null ? null : app.getId();
|
return app == null ? null : app.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ApplicationInfo getByAppCode(String appCode) {
|
||||||
|
if (StringUtils.isBlank(appCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return applicationInfoRepository.findByAppCode(appCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查的是 app_key 列而非主键 id(id ≠ app_key:流程中心 appKey=100009 主键是 100011),
|
||||||
|
* 走 findIdByAppKey + getById 两级缓存而非直查。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ApplicationInfo getByAppKey(Integer appKey) {
|
||||||
|
if (appKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Integer id = findIdByAppKey(appKey);
|
||||||
|
return id == null ? null : getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> listAppCodesByIds(Collection<Integer> ids) {
|
||||||
|
List<String> codes = new ArrayList<>();
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
for (ApplicationInfo info : applicationInfoRepository.findAllById(ids)) {
|
||||||
|
String code = info.getAppCode();
|
||||||
|
if (StringUtils.isNotBlank(code) && seen.add(code)) {
|
||||||
|
codes.add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。
|
* 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public ApplicationInfo save(ApplicationInfo info) {
|
public ApplicationInfo save(ApplicationInfo info) {
|
||||||
|
if (info.getId() != null) {
|
||||||
|
requireUnchangedAppCode(applicationInfoRepository.findPersistedAppCodeById(info.getId()),
|
||||||
|
info.getAppCode());
|
||||||
|
}
|
||||||
ApplicationInfo saved = applicationInfoRepository.save(info);
|
ApplicationInfo saved = applicationInfoRepository.save(info);
|
||||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
||||||
return saved;
|
return saved;
|
||||||
@@ -128,6 +174,11 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
if (applicationInfoRepository.existsByName(request.getName())) {
|
if (applicationInfoRepository.existsByName(request.getName())) {
|
||||||
throw new DException("应用名称已存在");
|
throw new DException("应用名称已存在");
|
||||||
}
|
}
|
||||||
|
// 技术标识唯一性(app_code = projman app.name,跳转查询键)
|
||||||
|
if (StringUtils.isNotBlank(request.getAppCode())
|
||||||
|
&& applicationInfoRepository.existsByAppCode(request.getAppCode())) {
|
||||||
|
throw new DException("技术标识已存在");
|
||||||
|
}
|
||||||
ApplicationInfo entity = new ApplicationInfo();
|
ApplicationInfo entity = new ApplicationInfo();
|
||||||
BeanUtils.copyProperties(request, entity);
|
BeanUtils.copyProperties(request, entity);
|
||||||
entity.setId(null);
|
entity.setId(null);
|
||||||
@@ -152,11 +203,42 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
}
|
}
|
||||||
ApplicationInfo entity = applicationInfoRepository.findById(id)
|
ApplicationInfo entity = applicationInfoRepository.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
|
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
|
||||||
BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret");
|
// 空值仍按既有部分提交语义处理;非空已登记标识不可真正改名。
|
||||||
|
if (StringUtils.isNotBlank(request.getAppCode())) {
|
||||||
|
requireUnchangedAppCode(entity.getAppCode(), request.getAppCode());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(request.getAppCode())
|
||||||
|
&& applicationInfoRepository.existsByAppCodeAndIdNot(request.getAppCode(), id)) {
|
||||||
|
throw new DException("技术标识已存在");
|
||||||
|
}
|
||||||
|
// appCode/appNature/iconKey/iconColor 空值不回写:部分提交场景(学校覆盖线只传 4 字段)copyProperties
|
||||||
|
// 会把 null 覆盖进主表;app_code 是引用键,清空即引用失效,仅显式提供时才更新。
|
||||||
|
// iconKey/iconColor 采用「未携带(null)=不改,携带空串=清除,携带值=更新」三分语义:
|
||||||
|
// 未启用矢量选择器的提交方不送该键即零影响;管理端全量表单显式清空即解除矢量渲染回到原回退链
|
||||||
|
BeanUtils.copyProperties(request, entity,
|
||||||
|
"id", "appKey", "appSecret", "appCode", "appNature", "iconKey", "iconColor");
|
||||||
|
if (StringUtils.isNotBlank(request.getAppCode())) {
|
||||||
|
entity.setAppCode(request.getAppCode());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(request.getAppNature())) {
|
||||||
|
entity.setAppNature(request.getAppNature());
|
||||||
|
}
|
||||||
|
if (request.getIconKey() != null) {
|
||||||
|
entity.setIconKey(StringUtils.trimToNull(request.getIconKey()));
|
||||||
|
}
|
||||||
|
if (request.getIconColor() != null) {
|
||||||
|
entity.setIconColor(StringUtils.trimToNull(request.getIconColor()));
|
||||||
|
}
|
||||||
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
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
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理);
|
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理);
|
||||||
@@ -218,7 +300,9 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
@Override
|
@Override
|
||||||
public Predicate toPredicate(Root<ApplicationInfo> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
|
public Predicate toPredicate(Root<ApplicationInfo> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
|
||||||
List<Predicate> predicates = new ArrayList<Predicate>();
|
List<Predicate> predicates = new ArrayList<Predicate>();
|
||||||
predicates.add(cb.greaterThan(root.get("appKey"), 100000));
|
// ④ 口径 = AppNature.isUserbaseSelf(仅排 UserBase 自身, Criteria 无法调 Java 方法,此为常量形态);
|
||||||
|
// 显式基石(app_nature=BASE)保留在列表供编辑/重置密钥——与 ①②③⑤ 的 isBaseApp 全排除口径不同,勿合并
|
||||||
|
predicates.add(cb.greaterThan(root.get("appKey"), AppNature.USER_CENTER_APP_KEY));
|
||||||
if (query != null) {
|
if (query != null) {
|
||||||
if (StringUtils.isNotEmpty(query.getName())) {
|
if (StringUtils.isNotEmpty(query.getName())) {
|
||||||
String keyword = query.getName();
|
String keyword = query.getName();
|
||||||
@@ -234,6 +318,15 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
predicates.add(cb.equal(root.get("categoryId"), query.getCategoryId()));
|
predicates.add(cb.equal(root.get("categoryId"), query.getCategoryId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 类别视图的排序只能内嵌在 spec(NULL 沉底须 case 表达式,Pageable 的 Sort 表达不了)。
|
||||||
|
// 生效前提:控制器对该分支传 Sort.unsorted(),否则 Pageable 排序会在此之后 cq.orderBy 整体覆盖;
|
||||||
|
// count 查询不受影响(Spring Data 会剥掉 spec 带入的 orderBy)
|
||||||
|
if (query != null && query.getCategoryId() != null) {
|
||||||
|
cq.orderBy(
|
||||||
|
cb.asc(cb.selectCase().when(cb.isNull(root.get("sortNo")), 1).otherwise(0)),
|
||||||
|
cb.asc(root.get("sortNo")),
|
||||||
|
cb.asc(root.get("id")));
|
||||||
|
}
|
||||||
return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction();
|
return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -338,4 +431,20 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
vo.setPushSecret(entity.getPushSecret());
|
vo.setPushSecret(entity.getPushSecret());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@Override
|
||||||
|
public List<AppIconMapVo> listIconMap() {
|
||||||
|
List<AppIconMapVo> result = new ArrayList<>();
|
||||||
|
for (ApplicationInfo app : applicationInfoRepository.findAll()) {
|
||||||
|
if (StringUtils.isBlank(app.getIconKey())) {
|
||||||
|
continue; // 未配置矢量键的行不下发,消费方零合并成本
|
||||||
|
}
|
||||||
|
AppIconMapVo vo = new AppIconMapVo();
|
||||||
|
vo.setId(app.getId());
|
||||||
|
vo.setIconKey(app.getIconKey());
|
||||||
|
vo.setIconColor(app.getIconColor());
|
||||||
|
result.add(vo);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,8 +92,10 @@ public class BoaUserLoader {
|
|||||||
boa.setIdcard(user.getIdcard());
|
boa.setIdcard(user.getIdcard());
|
||||||
boa.setPhone(user.getPhone());
|
boa.setPhone(user.getPhone());
|
||||||
boa.setProfilePicture(user.getProfilePicture());
|
boa.setProfilePicture(user.getProfilePicture());
|
||||||
// 用户表无 email / gender 列,置 null
|
// email 列已存在(SysUser.email,管理端编辑/批量导入均已读写):
|
||||||
boa.setEmail(null);
|
// 本人联系方式修改(oapi self/contact)后回读 BoaUser 须携带新 email,旧注释「用户表无 email 列」已过时;
|
||||||
|
// gender 列虽在实体上,但当前无写入来源,保持置 null
|
||||||
|
boa.setEmail(user.getEmail());
|
||||||
boa.setGender(null);
|
boa.setGender(null);
|
||||||
boa.setType(user.getType() != null ? user.getType().getKey() : null);
|
boa.setType(user.getType() != null ? user.getType().getKey() : null);
|
||||||
// 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用)
|
// 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用)
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ public class CacheInvalidationListener {
|
|||||||
clearQuietly(CacheNames.APP_INFO);
|
clearQuietly(CacheNames.APP_INFO);
|
||||||
clearQuietly(CacheNames.APP_KEY_TO_ID);
|
clearQuietly(CacheNames.APP_KEY_TO_ID);
|
||||||
clearQuietly(CacheNames.USER_APP_LIST);
|
clearQuietly(CacheNames.USER_APP_LIST);
|
||||||
log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId/userAppList",
|
clearQuietly(CacheNames.SCHOOL_APP_LIST);
|
||||||
|
log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId/userAppList/schoolAppList",
|
||||||
event.getAppKey(), event.getType());
|
event.getAppKey(), event.getType());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +78,9 @@ public class CacheInvalidationListener {
|
|||||||
clearQuietly(CacheNames.BOA_USER_NULL);
|
clearQuietly(CacheNames.BOA_USER_NULL);
|
||||||
clearQuietly(CacheNames.IDENTITY_CURRENT);
|
clearQuietly(CacheNames.IDENTITY_CURRENT);
|
||||||
clearQuietly(CacheNames.USER_APP_LIST);
|
clearQuietly(CacheNames.USER_APP_LIST);
|
||||||
|
clearQuietly(CacheNames.SCHOOL_APP_LIST);
|
||||||
clearQuietly(CacheNames.USER_GROUP_REL);
|
clearQuietly(CacheNames.USER_GROUP_REL);
|
||||||
log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser/identityCurrent/userAppList/userGroupRel",
|
log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser/identityCurrent/userAppList/schoolAppList/userGroupRel",
|
||||||
event.getSourceOp());
|
event.getSourceOp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +134,8 @@ public class CacheInvalidationListener {
|
|||||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||||
public void onAppAuthzChanged(AppAuthzChangedEvent event) {
|
public void onAppAuthzChanged(AppAuthzChangedEvent event) {
|
||||||
clearQuietly(CacheNames.USER_APP_LIST);
|
clearQuietly(CacheNames.USER_APP_LIST);
|
||||||
log.info("[Cache失效] 应用授权变更 op={} → 清 userAppList", event.getSourceOp());
|
clearQuietly(CacheNames.SCHOOL_APP_LIST);
|
||||||
|
log.info("[Cache失效] 应用授权变更 op={} → 清 userAppList/schoolAppList", event.getSourceOp());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== 驱逐工具(error 可观测,TTL 自愈兜底) =====================
|
// ===================== 驱逐工具(error 可观测,TTL 自愈兜底) =====================
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.yunhai.userbase.service.impl;
|
|||||||
|
|
||||||
import com.yunhai.platform.utils.exception.DException;
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
import com.yunhai.platform.utils.model.BoaModule;
|
import com.yunhai.platform.utils.model.BoaModule;
|
||||||
|
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||||
|
import com.yunhai.userbase.constant.AppLevelModuleId;
|
||||||
import com.yunhai.userbase.entity.*;
|
import com.yunhai.userbase.entity.*;
|
||||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||||
import com.yunhai.platform.utils.enums.IEnum;
|
import com.yunhai.platform.utils.enums.IEnum;
|
||||||
@@ -127,6 +129,10 @@ public class GroupAuthzService implements IGroupAuthzService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ISysGroupService sysGroupService;
|
private ISysGroupService sysGroupService;
|
||||||
|
|
||||||
|
/** 学校单点开通闸门的生效锚点解析(当前激活身份校兜底,与 AppCategoryService.resolveEffectiveSchool 同口径)。 */
|
||||||
|
@Autowired
|
||||||
|
private IdentityService identityService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ApplicationEventPublisher eventPublisher;
|
private ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
@@ -256,8 +262,14 @@ public class GroupAuthzService implements IGroupAuthzService {
|
|||||||
GroupAuthzAppVo appVo = new GroupAuthzAppVo();
|
GroupAuthzAppVo appVo = new GroupAuthzAppVo();
|
||||||
BeanUtils.copyProperties(app, appVo);
|
BeanUtils.copyProperties(app, appVo);
|
||||||
List<AppModule> modules = appModuleMap.get(app.getId());
|
List<AppModule> modules = appModuleMap.get(app.getId());
|
||||||
if (modules != null) {
|
if (modules != null && !modules.isEmpty()) {
|
||||||
appVo.setModules(buildModuleTree(modules, permMap));
|
appVo.setModules(buildModuleTree(modules, permMap));
|
||||||
|
} else {
|
||||||
|
// 无子模块应用(如「一周安排」):授权挂在 -appId 的整应用哨兵记录上(见 AppLevelModuleId),
|
||||||
|
// 有记录即视为已授权,inherit_flag 同步回显,否则该应用勾选态永远无回显。
|
||||||
|
SysGroupModulePerm appPerm = permMap.get(AppLevelModuleId.of(app.getId()));
|
||||||
|
appVo.setChecked(appPerm != null);
|
||||||
|
appVo.setInheritFlag(appPerm != null && Boolean.TRUE.equals(appPerm.getInheritFlag()));
|
||||||
}
|
}
|
||||||
result.add(appVo);
|
result.add(appVo);
|
||||||
}
|
}
|
||||||
@@ -515,6 +527,8 @@ public class GroupAuthzService implements IGroupAuthzService {
|
|||||||
/**
|
/**
|
||||||
* 解析某条授权项实际要操作的模块ID集合。<br>
|
* 解析某条授权项实际要操作的模块ID集合。<br>
|
||||||
* - moduleId 为 null 或 0:返回该应用下全部真实模块的 ID(「整应用」语义)。<br>
|
* - moduleId 为 null 或 0:返回该应用下全部真实模块的 ID(「整应用」语义)。<br>
|
||||||
|
* - 应用下没有任何模块时:返回 {@link AppLevelModuleId#of(Integer)}(-appId)——
|
||||||
|
* 把「整应用」授权落到哨兵记录上,否则这类应用永远写不进授予、也永远回显为空。<br>
|
||||||
* - 具体 moduleId:校验其确实存在且属于该应用,越权写别的应用的模块会被拒绝。<br>
|
* - 具体 moduleId:校验其确实存在且属于该应用,越权写别的应用的模块会被拒绝。<br>
|
||||||
*/
|
*/
|
||||||
private List<Integer> resolveModuleIds(Integer appId, Integer moduleId) {
|
private List<Integer> resolveModuleIds(Integer appId, Integer moduleId) {
|
||||||
@@ -523,7 +537,7 @@ public class GroupAuthzService implements IGroupAuthzService {
|
|||||||
for (AppModule m : appModuleRepository.findByAppIdIn(Collections.singletonList(appId))) {
|
for (AppModule m : appModuleRepository.findByAppIdIn(Collections.singletonList(appId))) {
|
||||||
ids.add(m.getId());
|
ids.add(m.getId());
|
||||||
}
|
}
|
||||||
return ids;
|
return ids.isEmpty() ? Collections.singletonList(AppLevelModuleId.of(appId)) : ids;
|
||||||
}
|
}
|
||||||
AppModule m = appModuleRepository.findById(moduleId).orElse(null);
|
AppModule m = appModuleRepository.findById(moduleId).orElse(null);
|
||||||
if (m == null) {
|
if (m == null) {
|
||||||
@@ -558,6 +572,12 @@ public class GroupAuthzService implements IGroupAuthzService {
|
|||||||
List<AppModule> allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
|
List<AppModule> allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
|
||||||
return assembleModuleTree(allModules);
|
return assembleModuleTree(allModules);
|
||||||
}
|
}
|
||||||
|
// 2.5 学校单点开通硬闸门(2026-10-12):生效锚点(显式 schId,或按「当前身份校→主归属校」兜底)
|
||||||
|
// 未在自身 sys_group_app_rel 开通该应用 → 直接空,角色授权与祖先组开通均不得绕过。
|
||||||
|
// 区域/根锚点按该锚点自身单点判定(不沿祖先);解析不到锚点=无学校语境=无应用。
|
||||||
|
if (!isAppOpenedAtEffectiveSchool(appId, schId, userId)) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
// 3. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
|
// 3. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
|
||||||
// - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身),
|
// - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身),
|
||||||
// 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块,
|
// 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块,
|
||||||
@@ -653,6 +673,41 @@ public class GroupAuthzService implements IGroupAuthzService {
|
|||||||
return assembleModuleTreeById(permittedModuleIds, buildAppModuleMap(appModules));
|
return assembleModuleTreeById(permittedModuleIds, buildAppModuleMap(appModules));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校单点开通硬闸门判定:应用是否落在「生效锚点」自身的开通集合内。<br>
|
||||||
|
* 闸门查询走 {@link #findAuthorizedAppIds(Integer)}(本组单点、不沿祖先、不并角色),
|
||||||
|
* 区域/根锚点同样按该锚点自身判定;显式 schId 先过 {@code assertSchoolId} 防误传非法 id。
|
||||||
|
*/
|
||||||
|
private boolean isAppOpenedAtEffectiveSchool(Integer appId, Integer schId, Integer userId) {
|
||||||
|
Integer anchor = resolveEffectiveSchoolAnchor(schId, userId);
|
||||||
|
if (anchor == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return findAuthorizedAppIds(anchor).contains(appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效学校锚点解析(与 {@code AppCategoryService.resolveEffectiveSchool} 同口径的只读版):<br>
|
||||||
|
* ① 显式 schId → assertSchoolId 锚点校验后直接使用(不校验用户归属,越权收窄由闸门自身完成);<br>
|
||||||
|
* ② null → 当前激活身份学校(sys_user_current_role);<br>
|
||||||
|
* ③ 仍无 → 主归属组向上最近学校/区域/根锚点;均无(平台账号无组关系)→ null(无学校语境)。
|
||||||
|
*/
|
||||||
|
private Integer resolveEffectiveSchoolAnchor(Integer schId, Integer userId) {
|
||||||
|
if (schId != null) {
|
||||||
|
sysGroupService.assertSchoolId(schId);
|
||||||
|
return schId;
|
||||||
|
}
|
||||||
|
BoaRoleInfo current = identityService.getCurrent(userId);
|
||||||
|
if (current != null && current.getSchId() != null) {
|
||||||
|
return current.getSchId();
|
||||||
|
}
|
||||||
|
SysUserGroupRel main = mainGroupResolver.resolve(userId).orElse(null);
|
||||||
|
if (main == null || main.getGroupId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sysGroupService.resolveSchoolId(main.getGroupId());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收集用户启用角色下=且属于目标应用 appId 的模块ID集合(角色授权并集专用的"应用过滤版")。<br>
|
* 收集用户启用角色下=且属于目标应用 appId 的模块ID集合(角色授权并集专用的"应用过滤版")。<br>
|
||||||
* 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块;
|
* 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块;
|
||||||
@@ -1847,6 +1902,11 @@ public class GroupAuthzService implements IGroupAuthzService {
|
|||||||
siblings.sort(siblingOrder);
|
siblings.sort(siblingOrder);
|
||||||
}
|
}
|
||||||
List<AppModule> roots = childrenMap.getOrDefault(0, Collections.emptyList());
|
List<AppModule> roots = childrenMap.getOrDefault(0, Collections.emptyList());
|
||||||
|
if (roots.isEmpty() && !modules.isEmpty()) {
|
||||||
|
// 脏数据兜底:parent_id 指向已删除模块时根节点全丢,会把「有模块的应用」误判成无模块应用
|
||||||
|
// (前端表现为该应用没有 + 号、勾选态恒为空),此时把所有模块提升为根节点展示
|
||||||
|
roots = modules;
|
||||||
|
}
|
||||||
List<GroupAuthzModuleVo> tree = new ArrayList<>();
|
List<GroupAuthzModuleVo> tree = new ArrayList<>();
|
||||||
for (AppModule root : roots) {
|
for (AppModule root : roots) {
|
||||||
tree.add(buildModuleNode(root, childrenMap, permMap));
|
tree.add(buildModuleNode(root, childrenMap, permMap));
|
||||||
@@ -1986,4 +2046,24 @@ public class GroupAuthzService implements IGroupAuthzService {
|
|||||||
}
|
}
|
||||||
return new ArrayList<>(appIds);
|
return new ArrayList<>(appIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Integer> findUserAuthorizedAppIds(Integer userId, Integer schoolId) {
|
||||||
|
Objects.requireNonNull(userId, "用户ID不能为空");
|
||||||
|
SysUser user = sysUserRepository.findById(userId).orElse(null);
|
||||||
|
if (user == null) {
|
||||||
|
throw new DException("用户不存在");
|
||||||
|
}
|
||||||
|
// 超级管理员:保留 bypass(D1 默认,基石账号平台级不绑定单一学校),口径与无学校版一致
|
||||||
|
if (isSuperAdmin(user.getType(), userId)) {
|
||||||
|
return findUserAuthorizedAppIds(userId);
|
||||||
|
}
|
||||||
|
// 学校单点开通口径(2026-10-12 硬闸门):生效锚点自身开通的应用集合,
|
||||||
|
// 不沿祖先链、不并入角色模块所属应用(角色授权不再补充「学校未开通」的应用)。
|
||||||
|
Integer anchor = resolveEffectiveSchoolAnchor(schoolId, userId);
|
||||||
|
if (anchor == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return findAuthorizedAppIds(anchor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,17 +76,12 @@ public class IdentityService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户全部可选身份(正常态),用于「切换身份」页渲染。
|
* 用户全部可选身份(正常态),用于「切换身份」页渲染。
|
||||||
* 排序:主身份优先 → sortNo 升序 → id 升序(「未切换默认第一个」的确定性保证)。
|
* 排序见 {@link #sortForDisplay}:主身份所在学校置顶 → 其他学校 schoolId 升序 → 校内 roleId 升序。
|
||||||
*/
|
*/
|
||||||
public List<BoaRoleInfo> listAvailable(Integer userId) {
|
public List<BoaRoleInfo> listAvailable(Integer userId) {
|
||||||
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
||||||
SysUserRoleRel main = resolveMainIdentity(userId);
|
SysUserRoleRel main = resolveMainIdentity(userId);
|
||||||
List<SysUserRoleRel> sorted = rels.stream()
|
List<SysUserRoleRel> sorted = sortForDisplay(rels, main);
|
||||||
.sorted(Comparator.comparingInt((SysUserRoleRel r) ->
|
|
||||||
main != null && Objects.equals(r.getId(), main.getId()) ? 0 : 1)
|
|
||||||
.thenComparingInt(SysUserRoleRel::getSortNo)
|
|
||||||
.thenComparingInt(SysUserRoleRel::getId))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
|
List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
|
||||||
for (SysUserRoleRel rel : sorted) {
|
for (SysUserRoleRel rel : sorted) {
|
||||||
result.add(toBoaRoleInfo(rel.getRoleId(), rel.getSchoolId()));
|
result.add(toBoaRoleInfo(rel.getRoleId(), rel.getSchoolId()));
|
||||||
@@ -94,6 +89,27 @@ public class IdentityService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 身份列表展示排序({@link #listAvailable} 与 {@code SysUserService#findUserRoles} 共用,保证两接口同序):<br>
|
||||||
|
* ① 主身份所在学校整体置顶(主身份是该校教师或管理员均成立);<br>
|
||||||
|
* ② 其余学校按 schoolId 升序;<br>
|
||||||
|
* ③ 校内按 roleId 升序(老师1 → 校领导2 → 管理员7);<br>
|
||||||
|
* ④ 兜底 id 升序。<br>
|
||||||
|
* 主身份行<b>不再单独置顶</b>:主校内部一律按 roleId,故主身份为管理员时该校教师仍排其前。
|
||||||
|
* 「未切换默认激活身份」由 {@link #getCurrent} 独立解析,不依赖本列表首元素。
|
||||||
|
*/
|
||||||
|
public static List<SysUserRoleRel> sortForDisplay(List<SysUserRoleRel> rels, SysUserRoleRel main) {
|
||||||
|
Integer mainSchoolId = main != null ? main.getSchoolId() : null;
|
||||||
|
return rels.stream()
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparingInt((SysUserRoleRel r) ->
|
||||||
|
Objects.equals(r.getSchoolId(), mainSchoolId) ? 0 : 1)
|
||||||
|
.thenComparingInt(SysUserRoleRel::getSchoolId)
|
||||||
|
.thenComparingInt(SysUserRoleRel::getRoleId)
|
||||||
|
.thenComparingInt(SysUserRoleRel::getId))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(动态解析,替代 is_main 物化字段)。
|
* 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(动态解析,替代 is_main 物化字段)。
|
||||||
* 连主身份都没有时返回 null(前端引导创建身份)。<br><br>
|
* 连主身份都没有时返回 null(前端引导创建身份)。<br><br>
|
||||||
|
|||||||
@@ -194,6 +194,15 @@ public class SchoolAppOverrideService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「全部」视图(未选应用类型)排序:按 id 正序 —— 创建顺序,最新创建的在最后。
|
||||||
|
* 与 SUPER 分支同口径(AppSettingsController:「全部」= Sort.by(ASC,"id")):
|
||||||
|
* sort_no 是「类型内」序号,跨类型打平无全局意义,故「全部」不参与生效排序。
|
||||||
|
*/
|
||||||
|
private Comparator<ApplicationInfoVo> appIdOrder() {
|
||||||
|
return Comparator.comparing(ApplicationInfoVo::getId, Comparator.nullsLast(Integer::compareTo));
|
||||||
|
}
|
||||||
|
|
||||||
/** 生效排序:override sortNo 优先,空沿用主表(空视为999),并列按 id 升序 */
|
/** 生效排序:override sortNo 优先,空沿用主表(空视为999),并列按 id 升序 */
|
||||||
private Comparator<ApplicationInfoVo> effectiveAppOrder(Map<Integer, SysSchoolAppOverride> overrides) {
|
private Comparator<ApplicationInfoVo> effectiveAppOrder(Map<Integer, SysSchoolAppOverride> overrides) {
|
||||||
return (a, b) -> {
|
return (a, b) -> {
|
||||||
@@ -243,9 +252,12 @@ public class SchoolAppOverrideService {
|
|||||||
filtered.add(app);
|
filtered.add(app);
|
||||||
}
|
}
|
||||||
Map<Integer, SysSchoolAppOverride> overrides = appOverridesFor(schools);
|
Map<Integer, SysSchoolAppOverride> overrides = appOverridesFor(schools);
|
||||||
|
// 「全部」视图(未选应用类型)按 id 正序——与 SUPER 分支同口径(AppSettingsController:
|
||||||
|
// 全部 = Sort.by(ASC,"id"))。sort_no 是「类型内」序号,跨类型打平无全局意义,
|
||||||
|
// 故「全部」不参与生效排序;选中类型仍走 override/主表生效排序(拖拽结果可见)。
|
||||||
List<ApplicationInfoVo> vos = filtered.stream()
|
List<ApplicationInfoVo> vos = filtered.stream()
|
||||||
.map(app -> mergeAppVo(app, overrides))
|
.map(app -> mergeAppVo(app, overrides))
|
||||||
.sorted(effectiveAppOrder(overrides))
|
.sorted(categoryId == null ? appIdOrder() : effectiveAppOrder(overrides))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
return pageInMemory(vos, pageable);
|
return pageInMemory(vos, pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ public class SysGroupService implements ISysGroupService {
|
|||||||
/** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */
|
/** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */
|
||||||
private static final int SYSTEM_USER_ID = 0;
|
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} 表示「根节点(教育云中心)」。
|
* key 为 {@code null} 表示「根节点(教育云中心)」。
|
||||||
@@ -228,6 +235,51 @@ public class SysGroupService implements ISysGroupService {
|
|||||||
deleteInternal(id);
|
deleteInternal(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启停状态翻转(2026-10-20 部门线 P0 最小批):严格单列 UPDATE。<br>
|
||||||
|
* ① 只走 {@link SysGroupRepository#updateStatus}(@Modifying JPQL,不触发实体监听器/审计列);
|
||||||
|
* ② 不判组类型、不动删除行为、不碰任何其他表;
|
||||||
|
* ③ 组域写口义务:发 {@link GroupChangedEvent} 驱逐 groupSnapshot 缓存——
|
||||||
|
* oapi 组树(tree/byId/list)与 GetTree 都吃快照,不发则透出的 status 最多滞后 30min;
|
||||||
|
* 不发 DataSyncEvent(推送通道 push_enabled 默认关,status 也无推送消费方)。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void changeStatus(Integer id, boolean disabled) {
|
||||||
|
if (id == null || sysGroupRepository.findById(id).orElse(null) == null) {
|
||||||
|
throw new DException("系统组不存在");
|
||||||
|
}
|
||||||
|
int updated = sysGroupRepository.updateStatus(id, disabled ? 1 : 0);
|
||||||
|
if (updated == 0) {
|
||||||
|
throw new DException("系统组不存在");
|
||||||
|
}
|
||||||
|
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
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Integer id, Integer schoolId) {
|
public void delete(Integer id, Integer schoolId) {
|
||||||
@@ -318,7 +370,9 @@ public class SysGroupService implements ISysGroupService {
|
|||||||
Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap();
|
Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap();
|
||||||
// 从根(pid=0)开始组装树
|
// 从根(pid=0)开始组装树
|
||||||
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
|
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
|
@Override
|
||||||
@@ -330,7 +384,83 @@ public class SysGroupService implements ISysGroupService {
|
|||||||
if (scope == null || scope.isSuper()) {
|
if (scope == null || scope.isSuper()) {
|
||||||
return getTree();
|
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
|
@Override
|
||||||
@@ -850,14 +980,20 @@ public class SysGroupService implements ISysGroupService {
|
|||||||
|
|
||||||
// ===================== 私有工具 =====================
|
// ===================== 私有工具 =====================
|
||||||
|
|
||||||
/** SysGroup -> BoaGroup(id/name/parentId/type/sort);children 默认 null(未展开)。 */
|
/**
|
||||||
|
* SysGroup -> BoaGroup(id/name/parentId/type/sort + status);children 默认 null(未展开)。<br>
|
||||||
|
* 出参载体为 {@link com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo}(P0 起 oapi 组树透出启停状态;
|
||||||
|
* 返回类型保持 BoaGroup,消费方零改动,JSON 面多一个 status 键)。null 视为 0(存量行/未迁移列)。
|
||||||
|
*/
|
||||||
private BoaGroup convertToBoaGroup(SysGroup entity) {
|
private BoaGroup convertToBoaGroup(SysGroup entity) {
|
||||||
BoaGroup bg = new BoaGroup();
|
com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo bg =
|
||||||
|
new com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo();
|
||||||
bg.setId(entity.getId());
|
bg.setId(entity.getId());
|
||||||
bg.setName(entity.getName());
|
bg.setName(entity.getName());
|
||||||
bg.setParentId(entity.getParentId());
|
bg.setParentId(entity.getParentId());
|
||||||
bg.setType(entity.getType() == null ? null : entity.getType().getKey());
|
bg.setType(entity.getType() == null ? null : entity.getType().getKey());
|
||||||
bg.setSort(entity.getSort());
|
bg.setSort(entity.getSort());
|
||||||
|
bg.setStatus(entity.getStatus() == null ? 0 : entity.getStatus());
|
||||||
return bg;
|
return bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ public class SysUserService implements ISysUserService {
|
|||||||
private static final Set<String> SUPER_ADMIN_ACCOUNTS =
|
private static final Set<String> SUPER_ADMIN_ACCOUNTS =
|
||||||
new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3"));
|
new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3"));
|
||||||
|
|
||||||
|
/** 邮箱格式(本人联系方式修改用):本地段常规字符 + 点分域名,总长另由请求 @Size 限制 */
|
||||||
|
private static final java.util.regex.Pattern CONTACT_EMAIL =
|
||||||
|
java.util.regex.Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$");
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private SysUserRepository sysUserRepository;
|
private SysUserRepository sysUserRepository;
|
||||||
|
|
||||||
@@ -289,6 +293,52 @@ public class SysUserService implements ISysUserService {
|
|||||||
sysUserRepository.save(entity);
|
sysUserRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改本人联系方式(oapi /oapi/user/self/contact):只写 phone/email 两字段,其余不动。<br>
|
||||||
|
* 空白入参=「不修改该字段」(不清空——清空语义不在本接口范围);手机号是 byPhone 身份维度,
|
||||||
|
* 改值前全库查重;更新后发 UPDATE_USER 推送 + UserChangedEvent(带旧 phone: key 提示)驱逐
|
||||||
|
* boaUser 缓存,控制器紧跟着的 getUserInfoWithDeptById 回读即新值(AFTER_COMMIT 后于回读执行)。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void updateSelfContact(Integer userId, String phone, String email) {
|
||||||
|
SysUser entity = sysUserRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new DException("系统用户不存在"));
|
||||||
|
String newPhone = StringUtils.trimToNull(phone);
|
||||||
|
String newEmail = StringUtils.trimToNull(email);
|
||||||
|
if (newPhone == null && newEmail == null) {
|
||||||
|
throw new DException("手机号与邮箱至少填写一项");
|
||||||
|
}
|
||||||
|
if (newPhone != null && !ValidatorUtil.isPhone(newPhone)) {
|
||||||
|
throw new DException("手机号格式不正确");
|
||||||
|
}
|
||||||
|
if (newEmail != null && !CONTACT_EMAIL.matcher(newEmail).matches()) {
|
||||||
|
throw new DException("邮箱格式不正确");
|
||||||
|
}
|
||||||
|
if (newPhone != null && !newPhone.equals(entity.getPhone())) {
|
||||||
|
SysUser samePhone = sysUserRepository.findByPhone(newPhone);
|
||||||
|
if (samePhone != null && !samePhone.getId().equals(userId)) {
|
||||||
|
throw new DException("手机号已被其他用户使用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 改手机号后旧 phone: 维度缓存 key 无处回查,须随事件显式驱逐(同 editInternal 口径);
|
||||||
|
// 仅改邮箱时手机号未变,不加提示(避免无谓多驱逐一个 key)
|
||||||
|
Set<String> staleKeyHints = new HashSet<>();
|
||||||
|
if (newPhone != null && StringUtils.isNotBlank(entity.getPhone())
|
||||||
|
&& !entity.getPhone().equals(newPhone)) {
|
||||||
|
staleKeyHints.add("phone:" + entity.getPhone());
|
||||||
|
}
|
||||||
|
if (newPhone != null) {
|
||||||
|
entity.setPhone(newPhone);
|
||||||
|
}
|
||||||
|
if (newEmail != null) {
|
||||||
|
entity.setEmail(newEmail);
|
||||||
|
}
|
||||||
|
entity.setUpdateUser(SYSTEM_USER_ID);
|
||||||
|
sysUserRepository.save(entity);
|
||||||
|
publishUserEvent(PushInterfaceType.UPDATE_USER, userId, staleKeyHints);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开放接口的用户身份写入(追加语义,只加不删):<br>
|
* 开放接口的用户身份写入(追加语义,只加不删):<br>
|
||||||
* 学校范围 = 显式 schoolId,缺省按 request.groupId 解析(与新增口同一约定,调用方不感知解析逻辑);
|
* 学校范围 = 显式 schoolId,缺省按 request.groupId 解析(与新增口同一约定,调用方不感知解析逻辑);
|
||||||
@@ -677,7 +727,8 @@ public class SysUserService implements ISysUserService {
|
|||||||
return new HashSet<>(sysGroupService.resolveSchoolIds(groupIds).values());
|
return new HashSet<>(sysGroupService.resolveSchoolIds(groupIds).values());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 身份行展示顺序:主身份优先 → sortNo → id(与 IdentityService.getUserRoles 同序)。 */
|
/** 管理端列表「用户身份」单值列取首行用的顺序:主身份优先 → sortNo → id。
|
||||||
|
* 注意:这与面向用户的身份列表 {@link IdentityService#sortForDisplay}(主校置顶 → 校内 roleId)不同,刻意保持。 */
|
||||||
private Comparator<SysUserRoleRel> identityRowOrder(SysUserRoleRel mainRel) {
|
private Comparator<SysUserRoleRel> identityRowOrder(SysUserRoleRel mainRel) {
|
||||||
return Comparator.comparingInt((SysUserRoleRel r) ->
|
return Comparator.comparingInt((SysUserRoleRel r) ->
|
||||||
mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1)
|
mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1)
|
||||||
@@ -1436,16 +1487,12 @@ public class SysUserService implements ISysUserService {
|
|||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
throw new DException("用户ID不能为空");
|
throw new DException("用户ID不能为空");
|
||||||
}
|
}
|
||||||
// 数据源:sys_user_role_rel(平台身份角色权威源),内存排序 主身份优先 → sortNo → id
|
// 数据源:sys_user_role_rel(平台身份角色权威源),内存排序与 /oapi/identity/available 同序:
|
||||||
// (修正:此前误读 sys_user_app_role_rel 业务权限角色 + 主归属组当学校,导致「本组」bug)
|
// 主身份所在学校置顶 → 其他学校 schoolId 升序 → 校内 roleId 升序(见 IdentityService#sortForDisplay)
|
||||||
|
// (修正历史:此前误读 sys_user_app_role_rel 业务权限角色 + 主归属组当学校,导致「本组」bug)
|
||||||
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
||||||
SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId);
|
SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId);
|
||||||
List<SysUserRoleRel> sorted = rels.stream()
|
List<SysUserRoleRel> sorted = IdentityService.sortForDisplay(rels, mainRel);
|
||||||
.sorted(Comparator.comparingInt((SysUserRoleRel r) ->
|
|
||||||
mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1)
|
|
||||||
.thenComparingInt(SysUserRoleRel::getSortNo)
|
|
||||||
.thenComparingInt(SysUserRoleRel::getId))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
|
List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
|
||||||
for (SysUserRoleRel rel : sorted) {
|
for (SysUserRoleRel rel : sorted) {
|
||||||
BoaRoleInfo info = new BoaRoleInfo();
|
BoaRoleInfo info = new BoaRoleInfo();
|
||||||
@@ -1795,6 +1842,24 @@ public class SysUserService implements ISysUserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== 按 id 批量窄读 =====================
|
||||||
|
|
||||||
|
/** 批量上限:聚合页一次回显的收件用户规模远小于此,防误用放大查询 */
|
||||||
|
private static final int LIST_BY_IDS_MAX = 200;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SysUser> listByIds(List<Integer> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<Integer> safeIds = ids.stream()
|
||||||
|
.filter(id -> id != null && id > 0)
|
||||||
|
.distinct()
|
||||||
|
.limit(LIST_BY_IDS_MAX)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return safeIds.isEmpty() ? Collections.emptyList() : sysUserRepository.findAllById(safeIds);
|
||||||
|
}
|
||||||
|
|
||||||
// ===================== 推送事件 + 缓存失效事件 =====================
|
// ===================== 推送事件 + 缓存失效事件 =====================
|
||||||
// 用户域全部写口在此收口:DataSyncEvent 由 PushDispatcher 消费(push_enabled 默认 0 不触发);
|
// 用户域全部写口在此收口:DataSyncEvent 由 PushDispatcher 消费(push_enabled 默认 0 不触发);
|
||||||
// UserChangedEvent 由 CacheInvalidationListener AFTER_COMMIT 消费,按 id/login/phone
|
// UserChangedEvent 由 CacheInvalidationListener AFTER_COMMIT 消费,按 id/login/phone
|
||||||
|
|||||||
@@ -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>
|
* 目标用户的归属组必须与当前用户数据范围有交集(SUPER 不限制)。<br>
|
||||||
* 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。
|
* 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。
|
||||||
|
|||||||
@@ -379,6 +379,8 @@ public class FileUtil {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。<br>
|
* MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。<br>
|
||||||
|
* svg 后缀走白名单消毒流程(AppIcon 方案 P4):≤64KB + viewBox ≤1024 + 元素/属性白名单消毒,
|
||||||
|
* 落盘的是消毒后内容——不把 svg 加进危险后缀黑名单(正常图标功能保留)。<br>
|
||||||
*
|
*
|
||||||
* @param file 上传文件
|
* @param file 上传文件
|
||||||
* @param subDir 子目录
|
* @param subDir 子目录
|
||||||
@@ -394,14 +396,30 @@ public class FileUtil {
|
|||||||
ext = (ext == null || ext.trim().length() == 0) ? "" : "." + ext.toLowerCase();
|
ext = (ext == null || ext.trim().length() == 0) ? "" : "." + ext.toLowerCase();
|
||||||
// 后缀黑名单校验
|
// 后缀黑名单校验
|
||||||
validateExt(ext);
|
validateExt(ext);
|
||||||
|
// svg 消毒(含 64KB/viewBox 校验):落盘消毒后字节,而非原始上传内容
|
||||||
|
byte[] sanitized = null;
|
||||||
|
if (".svg".equals(ext)) {
|
||||||
|
try {
|
||||||
|
String raw = new String(file.getBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
sanitized = SvgSanitizer.sanitize(raw).getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
} catch (DException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new DException("SVG 文件处理失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
ensureStoreBase();
|
ensureStoreBase();
|
||||||
try {
|
try {
|
||||||
String dirPath = BASE_STORE_PATH + File.separator + subDir;
|
String dirPath = BASE_STORE_PATH + File.separator + subDir;
|
||||||
cn.hutool.core.io.FileUtil.mkdir(dirPath);
|
cn.hutool.core.io.FileUtil.mkdir(dirPath);
|
||||||
String fileName = IdUtil.fastSimpleUUID() + ext;
|
String fileName = IdUtil.fastSimpleUUID() + ext;
|
||||||
File targetFile = new File(dirPath, fileName);
|
File targetFile = new File(dirPath, fileName);
|
||||||
// 流式写入,不加载全文件到内存
|
if (sanitized != null) {
|
||||||
cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile);
|
cn.hutool.core.io.FileUtil.writeBytes(sanitized, targetFile);
|
||||||
|
} else {
|
||||||
|
// 流式写入,不加载全文件到内存
|
||||||
|
cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile);
|
||||||
|
}
|
||||||
return buildUrl(subDir, fileName);
|
return buildUrl(subDir, fileName);
|
||||||
} catch (DException e) {
|
} catch (DException e) {
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package com.yunhai.userbase.utils;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
|
import org.w3c.dom.NamedNodeMap;
|
||||||
|
import org.w3c.dom.Node;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
|
import javax.xml.XMLConstants;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import javax.xml.transform.OutputKeys;
|
||||||
|
import javax.xml.transform.Transformer;
|
||||||
|
import javax.xml.transform.TransformerFactory;
|
||||||
|
import javax.xml.transform.dom.DOMSource;
|
||||||
|
import javax.xml.transform.stream.StreamResult;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传 SVG 白名单式消毒器(AppIcon 方案 P4 安全收口)。<br>
|
||||||
|
* 背景:上传框早已 accept=".svg" 且 /upload/icon/** 同源可访问,存储型 XSS(如
|
||||||
|
* {@code <svg onload=alert(1)>})此前无任何拦截——本类在落盘前消毒,而非把 svg 加进
|
||||||
|
* 危险后缀黑名单(那会砍掉正常功能)。<br>
|
||||||
|
* 策略(白名单优先,不依赖 jsoup/dom4j 等额外依赖):
|
||||||
|
* <ul>
|
||||||
|
* <li>元素白名单:svg 结构/图形/渐变/文本/滤镜元素之外(script、foreignObject、animate、
|
||||||
|
* image、a、switch 等)整棵子树删除;</li>
|
||||||
|
* <li>属性白名单 + 三条硬规则:on* 事件属性一律删、style(可携带 url(javascript:))一律删、
|
||||||
|
* href/xlink:href 仅保留 "#" 开头的文档内引用(外部引用一律删);</li>
|
||||||
|
* <li>注释/PI/CDATA 节点删除;DOCTYPE/外部实体在解析层直接禁止(XXE);</li>
|
||||||
|
* <li>消毒产物重新解析校验——仍是合法 XML 且根元素为 svg 才放行;</li>
|
||||||
|
* <li>尺寸校验:字节数 ≤64KB;viewBox 宽高 ≤1024(缺失 viewBox 不阻断,仅校验存在的值)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class SvgSanitizer {
|
||||||
|
|
||||||
|
/** 图标上传字节数上限(与前端 before-upload 同口径) */
|
||||||
|
public static final int MAX_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
/** viewBox 宽/高上限(AppIcon 资产库 1024 网格口径) */
|
||||||
|
public static final double MAX_VIEWBOX = 1024.0;
|
||||||
|
|
||||||
|
/** 元素白名单(统一小写比较;SVG 局部名大小写敏感,比较前折叠) */
|
||||||
|
private static final Set<String> ALLOWED_ELEMENTS = new HashSet<>(Arrays.asList(
|
||||||
|
"svg", "g", "defs", "symbol", "use", "path", "rect", "circle", "ellipse", "line",
|
||||||
|
"polyline", "polygon", "text", "tspan", "textpath",
|
||||||
|
"lineargradient", "radialgradient", "stop", "clippath", "mask", "pattern", "marker",
|
||||||
|
"title", "desc",
|
||||||
|
"filter", "fegaussianblur", "feoffset", "feblend", "fecolormatrix", "fecomposite",
|
||||||
|
"feflood", "femerge", "femergenode", "fecomponenttransfer",
|
||||||
|
"fefuncr", "fefuncg", "fefuncb", "fefuna"));
|
||||||
|
|
||||||
|
/** 属性白名单(统一小写比较;href 走专用规则,不在此集合;不收带前缀属性——序列化层对非 ns-aware DOM 的前缀属性不稳定,内部引用统一规范为无前缀 href) */
|
||||||
|
private static final Set<String> ALLOWED_ATTRS = new HashSet<>(Arrays.asList(
|
||||||
|
"id", "class", "version", "baseprofile", "xmlns", "preserveaspectratio",
|
||||||
|
"viewbox", "width", "height", "x", "y", "dx", "dy",
|
||||||
|
"d", "points", "transform", "clip-path", "mask", "filter",
|
||||||
|
"fill", "fill-opacity", "fill-rule", "color", "opacity",
|
||||||
|
"stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit",
|
||||||
|
"stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "clip-rule",
|
||||||
|
"cx", "cy", "r", "rx", "ry", "x1", "y1", "x2", "y2",
|
||||||
|
"gradientunits", "gradienttransform", "spreadmethod", "offset", "stop-color", "stop-opacity",
|
||||||
|
"patternunits", "patterncontentunits", "patterntransform",
|
||||||
|
"markerwidth", "markerheight", "refx", "refy", "orient", "markerunits",
|
||||||
|
"text-anchor", "font-family", "font-size", "font-weight", "font-style",
|
||||||
|
"letter-spacing", "word-spacing", "dominant-baseline", "alignment-baseline",
|
||||||
|
"rotate", "textlength", "lengthadjust", "display", "visibility"));
|
||||||
|
|
||||||
|
private SvgSanitizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消毒上传 SVG 并执行尺寸校验,返回可安全落盘的内容(UTF-8 文本)。<br>
|
||||||
|
* 任何解析失败/结构异常/超限均抛 {@link DException}(提示词面相用户,不透出内部栈)。
|
||||||
|
*
|
||||||
|
* @param raw 原始 SVG 文本
|
||||||
|
* @return 消毒后的 SVG 文本(合法 XML,根元素 svg)
|
||||||
|
*/
|
||||||
|
public static String sanitize(String raw) {
|
||||||
|
if (raw == null || raw.trim().isEmpty()) {
|
||||||
|
throw new DException("SVG 内容为空");
|
||||||
|
}
|
||||||
|
byte[] bytes = raw.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length > MAX_BYTES) {
|
||||||
|
throw new DException("SVG 文件超过 64KB 上限");
|
||||||
|
}
|
||||||
|
Document doc = parse(raw);
|
||||||
|
Element root = doc.getDocumentElement();
|
||||||
|
if (root == null || !"svg".equals(localName(root.getNodeName()))) {
|
||||||
|
throw new DException("SVG 根元素必须是 <svg>");
|
||||||
|
}
|
||||||
|
checkViewBox(root);
|
||||||
|
stripDisallowed(doc);
|
||||||
|
String out = serialize(doc);
|
||||||
|
// 消毒产物复解析:保证落盘的仍是合法 svg(白名单误删导致结构破坏时在此拦截)
|
||||||
|
Element reroot = parse(out).getDocumentElement();
|
||||||
|
if (reroot == null || !"svg".equals(localName(reroot.getNodeName()))) {
|
||||||
|
throw new DException("SVG 消毒后校验失败");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深度遍历:删白名单外元素整棵子树 + 清洗保留元素属性 + 删注释/PI/CDATA。 */
|
||||||
|
private static void stripDisallowed(Node node) {
|
||||||
|
NodeList children = node.getChildNodes();
|
||||||
|
for (int i = children.getLength() - 1; i >= 0; i--) {
|
||||||
|
Node c = children.item(i);
|
||||||
|
short type = c.getNodeType();
|
||||||
|
if (type == Node.ELEMENT_NODE) {
|
||||||
|
if (!ALLOWED_ELEMENTS.contains(localName(c.getNodeName()))) {
|
||||||
|
node.removeChild(c);
|
||||||
|
} else {
|
||||||
|
stripDisallowed(c);
|
||||||
|
}
|
||||||
|
} else if (type == Node.COMMENT_NODE || type == Node.PROCESSING_INSTRUCTION_NODE
|
||||||
|
|| type == Node.CDATA_SECTION_NODE) {
|
||||||
|
node.removeChild(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.getNodeType() == Node.ELEMENT_NODE) {
|
||||||
|
cleanAttrs((Element) node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 属性清洗:on 开头的事件属性与 style 一律删、href 仅留内部锚点、其余按白名单。 */
|
||||||
|
private static void cleanAttrs(Element el) {
|
||||||
|
NamedNodeMap attrs = el.getAttributes();
|
||||||
|
for (int i = attrs.getLength() - 1; i >= 0; i--) {
|
||||||
|
Node a = attrs.item(i);
|
||||||
|
String name = a.getNodeName(); // 可能带前缀(xlink:href)
|
||||||
|
String local = localName(name);
|
||||||
|
if (local.startsWith("on") || "style".equals(local)) {
|
||||||
|
el.removeAttribute(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("href".equals(local)) {
|
||||||
|
String value = a.getNodeValue().trim();
|
||||||
|
if (!value.startsWith("#")) {
|
||||||
|
el.removeAttribute(name); // 外部引用(http/data/javascript: 等)一律删
|
||||||
|
} else if (!"href".equals(name)) {
|
||||||
|
// 内部引用:规范为无前缀 href(SVG2 形式,浏览器全兼容;
|
||||||
|
// 非 ns-aware DOM 序列化带前缀属性不稳定,且 xlink 声明可能已被裁掉)
|
||||||
|
el.removeAttribute(name);
|
||||||
|
el.setAttribute("href", value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!ALLOWED_ATTRS.contains(local)) {
|
||||||
|
el.removeAttribute(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** viewBox 校验:宽高均须为正数且 ≤1024;缺失不阻断(存量无 viewBox 图标功能不砍)。 */
|
||||||
|
private static void checkViewBox(Element root) {
|
||||||
|
String vb = attrIgnoreCase(root, "viewBox");
|
||||||
|
if (vb == null || vb.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String[] parts = vb.trim().split("[\\s,]+");
|
||||||
|
if (parts.length != 4) {
|
||||||
|
throw new DException("SVG viewBox 格式不合法");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double w = Double.parseDouble(parts[2]);
|
||||||
|
double h = Double.parseDouble(parts[3]);
|
||||||
|
if (w <= 0 || h <= 0) {
|
||||||
|
throw new DException("SVG viewBox 宽高必须为正数");
|
||||||
|
}
|
||||||
|
if (w > MAX_VIEWBOX || h > MAX_VIEWBOX) {
|
||||||
|
throw new DException("SVG viewBox 宽高不能超过 1024");
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new DException("SVG viewBox 格式不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String attrIgnoreCase(Element el, String name) {
|
||||||
|
NamedNodeMap attrs = el.getAttributes();
|
||||||
|
for (int i = 0; i < attrs.getLength(); i++) {
|
||||||
|
Node a = attrs.item(i);
|
||||||
|
if (localName(a.getNodeName()).equals(localName(name))) {
|
||||||
|
return a.getNodeValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析(禁 DOCTYPE/外部实体/XXE)。解析失败 = 文件不是合法 XML,直接拒绝。 */
|
||||||
|
private static Document parse(String xml) {
|
||||||
|
try {
|
||||||
|
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
|
||||||
|
f.setNamespaceAware(false);
|
||||||
|
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
f.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||||
|
f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||||
|
f.setXIncludeAware(false);
|
||||||
|
f.setExpandEntityReferences(false);
|
||||||
|
f.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||||
|
f.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||||
|
return f.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (DException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new DException("SVG 文件解析失败(不是合法的 SVG)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 序列化(无 XML 声明头、UTF-8;浏览器可直接渲染)。 */
|
||||||
|
private static String serialize(Document doc) {
|
||||||
|
try {
|
||||||
|
TransformerFactory tf = TransformerFactory.newInstance();
|
||||||
|
tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||||
|
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||||
|
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
|
||||||
|
Transformer t = tf.newTransformer();
|
||||||
|
t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
|
||||||
|
t.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
|
||||||
|
t.setOutputProperty(OutputKeys.METHOD, "xml");
|
||||||
|
StringWriter sw = new StringWriter();
|
||||||
|
t.transform(new DOMSource(doc), new StreamResult(sw));
|
||||||
|
return sw.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new DException("SVG 消毒序列化失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名字折叠:取冒号后局部名并转小写(namespaceAware=false 时 nodeName 可能带前缀)。 */
|
||||||
|
private static String localName(String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int idx = name.indexOf(':');
|
||||||
|
String local = idx >= 0 ? name.substring(idx + 1) : name;
|
||||||
|
return local.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ yh:
|
|||||||
enabled: true
|
enabled: true
|
||||||
identity:
|
identity:
|
||||||
# 允许调用 oapi/identity/switch 的应用 appKey 白名单(逗号分隔;100001=UserHome,后期会新增)
|
# 允许调用 oapi/identity/switch 的应用 appKey 白名单(逗号分隔;100001=UserHome,后期会新增)
|
||||||
switch-allowed-app-keys: 100001,100002
|
switch-allowed-app-keys: 100001,100002,100009
|
||||||
|
|
||||||
# 接口文档(springdoc):分组见 SwaggerConfig(oapi=开放接口契约权威源 / api=业务接口)
|
# 接口文档(springdoc):分组见 SwaggerConfig(oapi=开放接口契约权威源 / api=业务接口)
|
||||||
# 契约快照导出:GET /v3/api-docs/oapi
|
# 契约快照导出:GET /v3/api-docs/oapi
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 2026-09-28 应用身份统一:app_code(技术标识)+ app_nature(应用性质)
|
||||||
|
-- 方案:《消息卡片直批与跳转基址统一方案-20260928.md》§4 / §11.4
|
||||||
|
-- 库:160 生产 userbase 库(实测名 proj18;执行前 SHOW DATABASES 再核对)
|
||||||
|
-- 执行顺序不可颠倒:加列 → 回填 → 校验 → 收紧(最后才允许建 UserHome 行,另行立项)
|
||||||
|
-- 幂等约定:每段执行前先 SHOW COLUMNS FROM application_info LIKE '<列>'; 已存在则跳过该段。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ---------- 1. 加列(可 NULL,过渡期约束靠应用层) ----------
|
||||||
|
ALTER TABLE application_info
|
||||||
|
ADD COLUMN app_nature VARCHAR(16) NULL COMMENT '应用性质:BASE=基石 / BUSINESS=业务',
|
||||||
|
ADD COLUMN app_code VARCHAR(32) NULL COMMENT '技术标识=projman app.name(跳转查询键)',
|
||||||
|
ADD COLUMN miniprogram_url VARCHAR(255) NULL COMMENT '微信小程序应用地址';
|
||||||
|
|
||||||
|
-- ---------- 2. 回填 app_nature ----------
|
||||||
|
-- 存量 15 行全为业务应用;UserBase 自身(100000)不标 BASE(由 appKey<=100000 条件一覆盖),统一 BUSINESS。
|
||||||
|
UPDATE application_info SET app_nature = 'BUSINESS' WHERE app_nature IS NULL;
|
||||||
|
|
||||||
|
-- ---------- 3. 回填 app_code ----------
|
||||||
|
-- 3.1 自动段:projman.app.project_name = userbase.name 精确命中(实测 11 条)。
|
||||||
|
-- ⚠️ 执行前核对 projman 库名(下方写的是仓库配置值 bsch_projman,160 实际名以 SHOW DATABASES 为准);
|
||||||
|
-- 若 projman 库不在同一 MySQL 实例,则先在 projman 侧查出 (project_name, name) 清单,改用 3.2 的逐行形式。
|
||||||
|
UPDATE application_info u
|
||||||
|
JOIN bsch_projman.app p ON p.project_name = u.name
|
||||||
|
SET u.app_code = p.name
|
||||||
|
WHERE u.app_code IS NULL;
|
||||||
|
|
||||||
|
-- 3.2 人工段:中文名与 projman 不对应(方案 §4.3 台账),显式指定
|
||||||
|
UPDATE application_info SET app_code = 'userbase' WHERE name = '用户中心平台' AND app_code IS NULL;
|
||||||
|
UPDATE application_info SET app_code = 'userclass' WHERE name = '学校管理' AND app_code IS NULL;
|
||||||
|
UPDATE application_info SET app_code = 'docprint' WHERE name = '文印申请' AND app_code IS NULL;
|
||||||
|
|
||||||
|
-- 3.3 孤儿:成绩管理无 projman 部署,保留 NULL 观察(用户已定,勿回填)
|
||||||
|
|
||||||
|
-- ---------- 4. 校验(预期:总 15 行;app_code NULL 恰 1 行且为「成绩管理」;app_nature NULL 为 0) ----------
|
||||||
|
SELECT COUNT(*) AS total_rows,
|
||||||
|
SUM(app_code IS NULL) AS code_null_cnt,
|
||||||
|
SUM(app_code IS NULL AND name <> '成绩管理') AS code_null_unexpected,
|
||||||
|
SUM(app_nature IS NULL) AS nature_null_cnt
|
||||||
|
FROM application_info;
|
||||||
|
|
||||||
|
-- ---------- 5. 收紧 ----------
|
||||||
|
-- app_code 加 UNIQUE(应用层 add/edit 已做重复校验,此处落库兜底)。
|
||||||
|
-- NOT NULL 暂缓:成绩管理孤儿行按约定保留 NULL,待其定论(上线或删除)后再收紧。
|
||||||
|
ALTER TABLE application_info ADD UNIQUE KEY uk_application_app_code (app_code);
|
||||||
@@ -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,43 @@
|
|||||||
|
package com.yunhai.userbase.constant;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppNature 判定语义单测(方案 §8 用例:基石应用两条件取「或」)。
|
||||||
|
*/
|
||||||
|
class AppNatureTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isBaseApp_三档标准用例() {
|
||||||
|
assertTrue(AppNature.isBaseApp(100000, AppNature.BUSINESS.name()), "UserBase 自身:条件一命中");
|
||||||
|
assertTrue(AppNature.isBaseApp(100015, AppNature.BASE.name()), "显式标记 BASE:条件二命中");
|
||||||
|
assertFalse(AppNature.isBaseApp(100001, AppNature.BUSINESS.name()), "普通业务应用:两条件均不命中");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isUserbaseSelf_null与边界() {
|
||||||
|
assertFalse(AppNature.isUserbaseSelf(null));
|
||||||
|
assertTrue(AppNature.isUserbaseSelf(100000));
|
||||||
|
assertFalse(AppNature.isUserbaseSelf(100001));
|
||||||
|
// 理论不存在(appKey 自增起点 100000),防御性覆盖边界语义
|
||||||
|
assertTrue(AppNature.isUserbaseSelf(99999));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isMarkedBase_严格等值() {
|
||||||
|
assertTrue(AppNature.isMarkedBase("BASE"));
|
||||||
|
assertFalse(AppNature.isMarkedBase(null));
|
||||||
|
assertFalse(AppNature.isMarkedBase("BUSINESS"));
|
||||||
|
assertFalse(AppNature.isMarkedBase("base"), "大小写敏感,与枚举 name 严格相等");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isBaseApp_条件一或二任一命中() {
|
||||||
|
assertTrue(AppNature.isBaseApp(100000, null), "UserBase 自身无需标记");
|
||||||
|
assertTrue(AppNature.isBaseApp(null, "BASE"), "appKey 缺失但显式标记 BASE 仍算基石");
|
||||||
|
assertFalse(AppNature.isBaseApp(null, null), "双缺省按业务应用放行(⑤ 原行为:appKey null 进授权范围)");
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package com.yunhai.userbase.controller.oapi;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||||
|
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /oapi/app/authorized-codes 判权端点单测(消息跳转收口 C1)。<br>
|
||||||
|
* 控制器薄委托:判权来源收窄重载 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}
|
||||||
|
* (2026-10-12 硬闸门 D2 收敛,走当前身份校兜底;超管返回全部 / 普通用户返回子集 / 空权限返回空),
|
||||||
|
* 数据面 {@code listAppCodesByIds} 映射 app_code(空值跳过/去重语义见 ApplicationInfoLookupTest)。
|
||||||
|
*/
|
||||||
|
@DisplayName("AppController.authorizedCodes - 有权 app_code 集合")
|
||||||
|
class AppControllerAuthorizedCodesTest {
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private AppController controller;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IGroupAuthzService groupAuthzService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IApplicationInfoService applicationInfoService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
MockitoAnnotations.openMocks(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 超管_返回全部应用code() {
|
||||||
|
when(groupAuthzService.findUserAuthorizedAppIds(10006, null)).thenReturn(Arrays.asList(100011, 100013, 100015));
|
||||||
|
when(applicationInfoService.listAppCodesByIds(Arrays.asList(100011, 100013, 100015)))
|
||||||
|
.thenReturn(Arrays.asList("flow-center", "notice", "userhome"));
|
||||||
|
|
||||||
|
List<String> codes = controller.authorizedCodes(10006);
|
||||||
|
|
||||||
|
assertEquals(Arrays.asList("flow-center", "notice", "userhome"), codes);
|
||||||
|
verify(applicationInfoService).listAppCodesByIds(Arrays.asList(100011, 100013, 100015));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 普通用户_仅返回有权子集() {
|
||||||
|
when(groupAuthzService.findUserAuthorizedAppIds(201, null)).thenReturn(Collections.singletonList(100013));
|
||||||
|
when(applicationInfoService.listAppCodesByIds(Collections.singletonList(100013)))
|
||||||
|
.thenReturn(Collections.singletonList("notice"));
|
||||||
|
|
||||||
|
assertEquals(Collections.singletonList("notice"), controller.authorizedCodes(201));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 空权限_返回空集合() {
|
||||||
|
when(groupAuthzService.findUserAuthorizedAppIds(202, null)).thenReturn(Collections.emptyList());
|
||||||
|
when(applicationInfoService.listAppCodesByIds(Collections.emptyList()))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
assertEquals(Collections.emptyList(), controller.authorizedCodes(202));
|
||||||
|
verify(groupAuthzService).findUserAuthorizedAppIds(202, null);
|
||||||
|
verify(applicationInfoService).listAppCodesByIds(Collections.emptyList());
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package com.yunhai.userbase.model.application;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import javax.validation.ConstraintViolation;
|
||||||
|
import javax.validation.Validation;
|
||||||
|
import javax.validation.Validator;
|
||||||
|
import javax.validation.ValidatorFactory;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app_code / app_nature 编辑必填校验组单测(消息方案 C5,2026-09-28 拍板:新增/编辑均必填,
|
||||||
|
* 存量 null 历史行不追溯)。Create 组此前已必填;本测试冻结 Update 组同口径——
|
||||||
|
* 编辑提交置空 → 校验失败;带值 → 通过。学校模式覆盖线由前端分支原样回传原值满足校验
|
||||||
|
* (后端 editAppForSchool 只读 name/desc/pcIcon,其余字段忽略)。
|
||||||
|
*/
|
||||||
|
@DisplayName("ApplicationInfoRequest - app_code/app_nature 新增与编辑均必填")
|
||||||
|
class ApplicationInfoRequestValidationTest {
|
||||||
|
|
||||||
|
private static Validator validator;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUp() {
|
||||||
|
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
|
||||||
|
validator = factory.getValidator();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApplicationInfoRequest validRequest() {
|
||||||
|
ApplicationInfoRequest request = new ApplicationInfoRequest();
|
||||||
|
request.setId(100011);
|
||||||
|
request.setName("流程中心");
|
||||||
|
request.setCategoryId(2);
|
||||||
|
request.setAppCode("flow-center");
|
||||||
|
request.setAppNature("BUSINESS");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasViolation(Set<ConstraintViolation<ApplicationInfoRequest>> violations, String field) {
|
||||||
|
return violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals(field));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void edit_空appCode_校验失败() {
|
||||||
|
ApplicationInfoRequest request = validRequest();
|
||||||
|
request.setAppCode(" ");
|
||||||
|
Set<ConstraintViolation<ApplicationInfoRequest>> violations =
|
||||||
|
validator.validate(request, ValidationGroups.Update.class);
|
||||||
|
assertTrue(hasViolation(violations, "appCode"), "编辑时技术标识置空必须被 Update 组拦截");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void edit_空appNature_校验失败() {
|
||||||
|
ApplicationInfoRequest request = validRequest();
|
||||||
|
request.setAppNature(null);
|
||||||
|
Set<ConstraintViolation<ApplicationInfoRequest>> violations =
|
||||||
|
validator.validate(request, ValidationGroups.Update.class);
|
||||||
|
assertTrue(hasViolation(violations, "appNature"), "编辑时应用性质置空必须被 Update 组拦截");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void edit_带appCodeAppNature_通过() {
|
||||||
|
Set<ConstraintViolation<ApplicationInfoRequest>> violations =
|
||||||
|
validator.validate(validRequest(), ValidationGroups.Update.class);
|
||||||
|
assertFalse(hasViolation(violations, "appCode"));
|
||||||
|
assertFalse(hasViolation(violations, "appNature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_空appCode_校验失败_口径不变() {
|
||||||
|
ApplicationInfoRequest request = validRequest();
|
||||||
|
request.setId(null);
|
||||||
|
request.setAppCode(null);
|
||||||
|
Set<ConstraintViolation<ApplicationInfoRequest>> violations =
|
||||||
|
validator.validate(request, ValidationGroups.Create.class);
|
||||||
|
assertTrue(hasViolation(violations, "appCode"), "新增必填为既有口径,防回归");
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
package com.yunhai.userbase.service.impl;
|
||||||
|
|
||||||
|
import com.yunhai.platform.utils.model.BoaApp;
|
||||||
|
import com.yunhai.platform.utils.model.BoaAppType;
|
||||||
|
import com.yunhai.userbase.entity.AppCategory;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.repository.AppCategoryRepository;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||||
|
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||||
|
import com.yunhai.userbase.service.ISysGroupService;
|
||||||
|
import com.yunhai.userbase.service.resolve.MainGroupResolver;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.MockitoAnnotations;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
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.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyMap;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppCategoryService.listBoaAppType 基石应用过滤单元测试(2026-10-08 修复)。<br>
|
||||||
|
* 契约:用户端应用列表(门户 /apps)不出现基石应用 ——
|
||||||
|
* <ul>
|
||||||
|
* <li>UserBase 自身(appKey ≤ 100000)被排除(超管分支 findUserAuthorizedAppIds 返回全量时的兜底);</li>
|
||||||
|
* <li>app_nature=BASE(UserHome / 移动门户)被排除;</li>
|
||||||
|
* <li>BUSINESS 应用保留;</li>
|
||||||
|
* <li>无基石行时输出与改动前逐项等价(不误伤)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class AppCategoryServiceBaseAppFilterTest {
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private AppCategoryService appCategoryService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AppCategoryRepository appCategoryRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApplicationInfoRepository applicationInfoRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IGroupAuthzService groupAuthzService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SchoolAppOverrideService schoolAppOverrideService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IdentityService identityService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private MainGroupResolver mainGroupResolver;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ISysGroupService sysGroupService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
MockitoAnnotations.openMocks(this);
|
||||||
|
AppCategory category = new AppCategory();
|
||||||
|
category.setId(10);
|
||||||
|
category.setName("办公管理");
|
||||||
|
category.setSortNo(1);
|
||||||
|
when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category));
|
||||||
|
// 无学校语境(平台账号):identity 无、主归属无 → applyAlias 不合并,直接看构建结果
|
||||||
|
when(identityService.getCurrent(1)).thenReturn(null);
|
||||||
|
when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty());
|
||||||
|
// 合并口透传第一参数(本测试只断言过滤后的构建结果)
|
||||||
|
when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any()))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基石三类:UserBase 自身(appKey=100000)/ app_nature=BASE 均排除,BUSINESS 保留 */
|
||||||
|
@Test
|
||||||
|
void baseApps_excluded_businessKept() {
|
||||||
|
List<ApplicationInfo> apps = Arrays.asList(
|
||||||
|
app(1, 100000, null, "用户中心"),
|
||||||
|
app(2, 100001, "BASE", "用户之家"),
|
||||||
|
app(3, 100002, "BUSINESS", "通知公告"));
|
||||||
|
when(applicationInfoRepository.findAll()).thenReturn(apps);
|
||||||
|
// 超管分支:授权集合为全量(基石也在内)——正是 bug1 的触发条件(收窄重载 null 锚点=超管 bypass)
|
||||||
|
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Arrays.asList(1, 2, 3));
|
||||||
|
|
||||||
|
List<BoaAppType> result = appCategoryService.listBoaAppType(1, null, null);
|
||||||
|
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
List<BoaApp> kept = result.get(0).getApps();
|
||||||
|
assertEquals(1, kept.size());
|
||||||
|
assertEquals(Integer.valueOf(3), kept.get(0).getId());
|
||||||
|
assertEquals("通知公告", kept.get(0).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无基石行时过滤逻辑零影响:全部 BUSINESS 应用按原顺序保留 */
|
||||||
|
@Test
|
||||||
|
void noBaseApps_allKeptUnchanged() {
|
||||||
|
when(applicationInfoRepository.findAll()).thenReturn(Arrays.asList(
|
||||||
|
app(3, 100002, "BUSINESS", "通知公告"),
|
||||||
|
app(4, null, null, "老应用")));
|
||||||
|
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Arrays.asList(3, 4));
|
||||||
|
|
||||||
|
List<BoaApp> kept = appCategoryService.listBoaAppType(1, null, null).get(0).getApps();
|
||||||
|
|
||||||
|
assertEquals(2, kept.size());
|
||||||
|
assertEquals(Integer.valueOf(3), kept.get(0).getId());
|
||||||
|
assertEquals(Integer.valueOf(4), kept.get(1).getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无权限 / 无客户端命中的应用原本就被挡(回归:基石过滤不改变这两条既有口径) */
|
||||||
|
@Test
|
||||||
|
void unauthorizedBaseApp_stillNoneKept() {
|
||||||
|
when(applicationInfoRepository.findAll())
|
||||||
|
.thenReturn(new ArrayList<>(Collections.singletonList(app(2, 100001, "BASE", "用户之家"))));
|
||||||
|
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Collections.singletonList(2));
|
||||||
|
|
||||||
|
assertEquals(0, appCategoryService.listBoaAppType(1, null, null).get(0).getApps().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationInfo app(Integer id, Integer appKey, String appNature, String name) {
|
||||||
|
ApplicationInfo app = new ApplicationInfo();
|
||||||
|
app.setId(id);
|
||||||
|
app.setAppKey(appKey);
|
||||||
|
app.setAppNature(appNature);
|
||||||
|
app.setName(name);
|
||||||
|
app.setCategoryId(10);
|
||||||
|
app.setSortNo(id);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -31,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.ArgumentMatchers.anyMap;
|
import static org.mockito.ArgumentMatchers.anyMap;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -93,7 +94,8 @@ class AppCategoryServiceSchoolAliasTest {
|
|||||||
app.setName("通知公告");
|
app.setName("通知公告");
|
||||||
app.setSortNo(1);
|
app.setSortNo(1);
|
||||||
when(applicationInfoRepository.findAll()).thenReturn(Collections.singletonList(app));
|
when(applicationInfoRepository.findAll()).thenReturn(Collections.singletonList(app));
|
||||||
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Collections.singletonList(100));
|
// 权限集合改用学校收窄重载(2026-10-12 硬闸门);本测试只断言生效学校解析,schoolId 传 null 覆盖
|
||||||
|
when(groupAuthzService.findUserAuthorizedAppIds(eq(1), any())).thenReturn(Collections.singletonList(100));
|
||||||
// 合并口透传第一参数(本测试只断言入参,合并行为见 SchoolAppOverrideServiceApplyAliasTest)
|
// 合并口透传第一参数(本测试只断言入参,合并行为见 SchoolAppOverrideServiceApplyAliasTest)
|
||||||
when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any()))
|
when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any()))
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|||||||
@@ -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,89 @@
|
|||||||
|
package com.yunhai.userbase.service.impl;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.entity.SysAppRole;
|
||||||
|
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
|
||||||
|
import com.yunhai.userbase.repository.AppModuleRepository;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
|
||||||
|
import com.yunhai.userbase.repository.SysAppRoleRepository;
|
||||||
|
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.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⑤ 全应用角色授权范围基石排除单测(方案 §8 口径差异用例)。<br>
|
||||||
|
* 判定 = AppNature.isBaseApp(UserBase 自身 ∪ 显式 BASE);appKey=null 且未标记 BASE 的行
|
||||||
|
* 沿用原行为(appKey > 100000 的原始条件对 null 求值即入列)。
|
||||||
|
*/
|
||||||
|
class AppRoleServiceBaseAppExcludeTest {
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private AppRoleService appRoleService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SysAppRoleRepository sysAppRoleRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApplicationInfoRepository applicationInfoRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AppModuleRepository appModuleRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
MockitoAnnotations.openMocks(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApplicationInfo app(int id, Integer appKey, String name, String nature) {
|
||||||
|
ApplicationInfo a = new ApplicationInfo();
|
||||||
|
a.setId(id);
|
||||||
|
a.setAppKey(appKey);
|
||||||
|
a.setName(name);
|
||||||
|
a.setAppNature(nature);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findRoleModuleTree_全应用角色排除基石_保留业务与null缺省行() {
|
||||||
|
SysAppRole role = new SysAppRole();
|
||||||
|
role.setId(7);
|
||||||
|
role.setAllAppFlag(true);
|
||||||
|
|
||||||
|
List<ApplicationInfo> all = Arrays.asList(
|
||||||
|
app(1, 100000, "用户中心平台", null), // UserBase 自身 → 排除
|
||||||
|
app(2, 100001, "通知公告", "BUSINESS"), // 业务 → 保留
|
||||||
|
app(3, 100015, "用户主页", "BASE"), // 显式基石 → 排除(④ 与 ①②③⑤ 口径差异的关键行)
|
||||||
|
app(4, null, "遗留无key行", null), // 双缺省 → 原行为保留
|
||||||
|
app(5, null, "标记BASE无key行", "BASE")); // 显式基石 → 排除
|
||||||
|
when(sysAppRoleRepository.findById(7)).thenReturn(Optional.of(role));
|
||||||
|
when(applicationInfoRepository.findAll()).thenReturn(all);
|
||||||
|
when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(Collections.emptyList());
|
||||||
|
when(sysAppRoleModuleRelRepository.findByRoleId(7)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
List<AppRoleAuthzAppVo> result = appRoleService.findRoleModuleTree(7);
|
||||||
|
|
||||||
|
List<String> names = result.stream().collect(
|
||||||
|
java.util.stream.Collectors.mapping(AppRoleAuthzAppVo::getName, java.util.stream.Collectors.toList()));
|
||||||
|
assertTrue(names.contains("通知公告"), "业务应用必须可见");
|
||||||
|
assertTrue(names.contains("遗留无key行"), "appKey=null 未标记行沿用原行为");
|
||||||
|
assertFalse(names.contains("用户中心平台"), "UserBase 自身排除");
|
||||||
|
assertFalse(names.contains("用户主页"), "显式 BASE 排除——授权范围不见基石");
|
||||||
|
assertFalse(names.contains("标记BASE无key行"), "显式 BASE 排除与 appKey 无关");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,138 @@
|
|||||||
|
package com.yunhai.userbase.service.impl;
|
||||||
|
|
||||||
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||||
|
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 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.assertSame;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* by-code / by-key 查询数据面单测(方案 §5.1 阻塞项防回归)。<br>
|
||||||
|
* 关键契约:by-key 查的是 app_key 列而非主键 id——160 实测 id ≠ app_key
|
||||||
|
* (15 行中仅 2 行相等,流程中心 appKey=100009 的主键是 100011,id=100009 是课堂作业),
|
||||||
|
* 传错会把「流程中心」静默查成「课堂作业」。
|
||||||
|
*/
|
||||||
|
class ApplicationInfoLookupTest {
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ApplicationInfoService applicationInfoService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApplicationInfoRepository applicationInfoRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
MockitoAnnotations.openMocks(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApplicationInfo flowCenter() {
|
||||||
|
ApplicationInfo app = new ApplicationInfo();
|
||||||
|
app.setId(100011);
|
||||||
|
app.setAppKey(100009);
|
||||||
|
app.setName("流程中心");
|
||||||
|
app.setAppCode("flow-center");
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getByAppKey_按appKey列查询_绝不按主键id() {
|
||||||
|
ApplicationInfo center = flowCenter();
|
||||||
|
when(applicationInfoRepository.findByAppKey(100009)).thenReturn(center);
|
||||||
|
when(applicationInfoRepository.findById(100011)).thenReturn(Optional.of(center));
|
||||||
|
|
||||||
|
ApplicationInfo result = applicationInfoService.getByAppKey(100009);
|
||||||
|
|
||||||
|
assertSame(center, result);
|
||||||
|
assertEquals("流程中心", result.getName());
|
||||||
|
verify(applicationInfoRepository).findByAppKey(100009);
|
||||||
|
// 防回归核心:主键 id=100009 是「课堂作业」,按 id 查即静默跳错
|
||||||
|
verify(applicationInfoRepository, never()).findById(100009);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getByAppKey_查无返回null() {
|
||||||
|
when(applicationInfoRepository.findByAppKey(999999)).thenReturn(null);
|
||||||
|
assertNull(applicationInfoService.getByAppKey(999999));
|
||||||
|
assertNull(applicationInfoService.getByAppKey(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getByAppCode_按技术标识查询() {
|
||||||
|
ApplicationInfo center = flowCenter();
|
||||||
|
when(applicationInfoRepository.findByAppCode("flow-center")).thenReturn(center);
|
||||||
|
|
||||||
|
assertSame(center, applicationInfoService.getByAppCode("flow-center"));
|
||||||
|
verify(applicationInfoRepository).findByAppCode("flow-center");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getByAppCode_空白入参不落库() {
|
||||||
|
assertNull(applicationInfoService.getByAppCode(null));
|
||||||
|
assertNull(applicationInfoService.getByAppCode(" "));
|
||||||
|
verify(applicationInfoRepository, never()).findByAppCode(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void edit_空appCode不回写主表_防部分提交清空引用键() {
|
||||||
|
ApplicationInfo existing = flowCenter();
|
||||||
|
when(applicationInfoRepository.findById(100011)).thenReturn(Optional.of(existing));
|
||||||
|
when(applicationInfoRepository.existsByNameAndIdNot("流程中心", 100011)).thenReturn(false);
|
||||||
|
when(applicationInfoRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
com.yunhai.userbase.model.application.ApplicationInfoRequest request =
|
||||||
|
new com.yunhai.userbase.model.application.ApplicationInfoRequest();
|
||||||
|
request.setName("流程中心");
|
||||||
|
// 模拟学校覆盖线/部分提交:不回传 appCode/appNature
|
||||||
|
request.setAppCode(null);
|
||||||
|
request.setAppNature(null);
|
||||||
|
|
||||||
|
applicationInfoService.edit(100011, request);
|
||||||
|
|
||||||
|
assertEquals("flow-center", existing.getAppCode(), "引用键不得被 null 覆盖清空");
|
||||||
|
assertNull(existing.getAppNature(), "性质同样不得被 null 覆盖");
|
||||||
|
verify(applicationInfoRepository, never()).existsByAppCodeAndIdNot(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listAppCodesByIds_空appCode行跳过_按首现去重() {
|
||||||
|
ApplicationInfo center = flowCenter();
|
||||||
|
ApplicationInfo legacy = new ApplicationInfo();
|
||||||
|
legacy.setId(100012);
|
||||||
|
legacy.setAppCode(null);
|
||||||
|
ApplicationInfo notice = new ApplicationInfo();
|
||||||
|
notice.setId(100013);
|
||||||
|
notice.setAppCode("notice");
|
||||||
|
when(applicationInfoRepository.findAllById(any())).thenReturn(
|
||||||
|
java.util.Arrays.asList(center, legacy, notice, center));
|
||||||
|
|
||||||
|
java.util.List<String> codes = applicationInfoService.listAppCodesByIds(
|
||||||
|
java.util.Arrays.asList(100011, 100012, 100013));
|
||||||
|
|
||||||
|
assertEquals(java.util.Arrays.asList("flow-center", "notice"), codes,
|
||||||
|
"null app_code 跳过(存量历史行),重复去重");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listAppCodesByIds_空入参返回空不出站() {
|
||||||
|
assertEquals(java.util.Collections.emptyList(), applicationInfoService.listAppCodesByIds(null));
|
||||||
|
assertEquals(java.util.Collections.emptyList(), applicationInfoService.listAppCodesByIds(
|
||||||
|
java.util.Collections.emptyList()));
|
||||||
|
verify(applicationInfoRepository, never()).findAllById(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user